fix: integrate develop and preserve invocation notification recovery

This commit is contained in:
2026-10-05 22:25:32 +09:00
92 changed files with 14528 additions and 1005 deletions
Generated
+7
View File
@@ -64,6 +64,7 @@ dependencies = [
"dotenv",
"eventsource-stream",
"futures",
"imagesize",
"reqwest",
"schemars",
"serde",
@@ -2188,6 +2189,12 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "imagesize"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
[[package]]
name = "include_dir"
version = "0.7.4"
+1
View File
@@ -32,6 +32,7 @@ reqwest = { version = "0.13", default-features = false, features = ["stream", "j
eventsource-stream = "0.2"
zstd = "0.13"
base64 = "0.22.1"
imagesize = "0.14"
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
agen-macros = { workspace = true }
+45 -1
View File
@@ -626,6 +626,10 @@ pub struct Engine<C: LlmClient, S: EngineState = Mutable, A: Send + Sync = ()> {
/// engine history. An error rejects the item and aborts the turn, allowing
/// upper layers to make durable storage the commit gate.
history_append_cbs: Vec<Box<dyn Fn(&Item) -> Result<(), String> + Send + Sync>>,
/// Durable host commit gate for image-result corrections. Runs before the
/// correction becomes live or a replacement request can be sent.
image_rejection_handler:
Option<Box<dyn Fn(&HistoryEntry<A>, &Item) -> Result<(), String> + Send + Sync>>,
/// Request configuration (max_tokens, temperature, etc.)
request_config: RequestConfig,
/// Cancel notification channel (for interrupting execution)
@@ -971,6 +975,16 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
self.history_append_cbs.push(Box::new(callback));
}
/// Set the host's durable commit gate for correcting a rejected ViewImage
/// result. The original annotation/identity must be preserved. A failed
/// commit leaves both history and the outgoing request untouched.
pub fn set_image_rejection_handler(
&mut self,
handler: impl Fn(&HistoryEntry<A>, &Item) -> Result<(), String> + Send + Sync + 'static,
) {
self.image_rejection_handler = Some(Box::new(handler));
}
fn emit_history_append(&self, item: &Item) -> Result<(), EngineError> {
for cb in &self.history_append_cbs {
cb(item).map_err(EngineError::HistoryAppend)?;
@@ -3205,7 +3219,34 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
"Sending request to LLM"
);
let stream = self.open_stream_with_retry(request, turn, llm_call).await?;
let mut request = request;
let stream = loop {
match self
.open_stream_with_retry(request.clone(), turn, llm_call)
.await
{
Ok(stream) => break stream,
Err(EngineError::Client(error)) if error.is_image_size_rejection() => {
let Some((history_index, request_index)) =
crate::image_recovery::largest_candidate(history, &request)
else {
return Err(EngineError::Client(error));
};
let entry = &history.entries()[history_index];
let replacement = crate::image_recovery::rejected_result(&entry.item);
if let Some(commit) = &self.image_rejection_handler {
commit(entry, &replacement).map_err(EngineError::HistoryAppend)?;
}
history.entries_mut()[history_index].item = replacement.clone();
// Rebuild the provider request from the corrected projection,
// retaining prior prune/interceptor decisions. No tool runs
// or assistant output have occurred for this failed request.
request.items[request_index] = replacement;
self.emit_warning(crate::image_recovery::REJECTION_MESSAGE);
}
Err(error) => return Err(error),
}
};
self.tool_call_collector.begin_response();
let mut stream = ResponseStreamPump::start(stream);
let mut early_tools: Option<EarlyToolExecutionBatch> = None;
@@ -3567,6 +3608,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
warning_cbs: Vec::new(),
tool_result_cbs: Vec::new(),
history_append_cbs: Vec::new(),
image_rejection_handler: None,
request_config: RequestConfig::default(),
cancel_tx,
cancel_rx,
@@ -3856,6 +3898,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Mutable, A> {
warning_cbs: self.warning_cbs,
tool_result_cbs: self.tool_result_cbs,
history_append_cbs: self.history_append_cbs,
image_rejection_handler: self.image_rejection_handler,
request_config: self.request_config,
cancel_tx: self.cancel_tx,
@@ -4040,6 +4083,7 @@ impl<C: LlmClient, A: Send + Sync> Engine<C, Locked, A> {
warning_cbs: self.warning_cbs,
tool_result_cbs: self.tool_result_cbs,
history_append_cbs: self.history_append_cbs,
image_rejection_handler: self.image_rejection_handler,
request_config: self.request_config,
cancel_tx: self.cancel_tx,
+106
View File
@@ -0,0 +1,106 @@
//! Recovery is driven by provider rejection, never a locally chosen image limit.
use crate::{History, Item, Role, ToolResultDisposition, llm_client::Request, tool::Attachment};
pub(crate) const REJECTION_MESSAGE: &str = "ViewImage image omitted from model input after the provider rejected image size. This is a recovery candidate, not proof that this particular image caused the rejection. Resize or crop the image and retry ViewImage; do not read the unchanged image again.";
/// Keep the most recent response's tool results eligible, including early tool
/// results interleaved with that response's later text/calls. Empty boundaries
/// from older versions' failed requests are not evidence of accepted input.
pub(crate) fn accepted_input_end<'a>(items: impl Iterator<Item = &'a Item>) -> Option<usize> {
let mut response_start = None;
let mut accepted_prefix = None;
for (index, item) in items.enumerate() {
match item {
Item::AssistantResponseBoundary { .. } => response_start = Some(index),
Item::Message {
role: Role::Assistant,
..
}
| Item::ToolCall { .. }
| Item::Reasoning { .. } => {
accepted_prefix = Some(response_start.unwrap_or(index));
}
Item::Message {
role: Role::User, ..
} => response_start = None,
_ => {}
}
}
accepted_prefix
}
/// Select only a ViewImage result actually sent in the rejected request. The
/// tool contract returns exactly one image, so each correction removes one
/// image and preserves the tool call/result pair. Other tools and user uploads
/// are deliberately not rewritten by this recovery policy.
pub(crate) fn largest_candidate<A>(
history: &History<A>,
request: &Request,
) -> Option<(usize, usize)> {
let start = accepted_input_end(history.items()).unwrap_or(0);
let mut largest = None;
for (index, entry) in history.entries().iter().enumerate().skip(start) {
let Item::ToolResult {
call_id,
attachments,
..
} = &entry.item
else {
continue;
};
let [Attachment::Image(image)] = attachments.as_slice() else {
continue;
};
let from_view_image = history.items().any(|item| {
matches!(
item,
Item::ToolCall { call_id: id, name, .. } if id == call_id && name == "ViewImage"
)
});
if !from_view_image {
continue;
}
let sent = request.items.iter().position(|item| {
matches!(
item,
Item::ToolResult { call_id: id, attachments: sent, .. }
if id == call_id && sent == attachments
)
});
let Some(request_index) = sent else { continue };
// Header inspection only; no raster allocation or size threshold.
// Unknown dimensions sort by bytes as a deterministic fallback.
let pixels = imagesize::blob_size(image.data())
.ok()
.map(|size| (size.width as u64).saturating_mul(size.height as u64))
.unwrap_or(0);
let rank = (pixels, image.data().len(), index);
if largest
.as_ref()
.is_none_or(|(previous, _)| rank > *previous)
{
largest = Some((rank, (index, request_index)));
}
}
largest.map(|(_, candidate)| candidate)
}
pub(crate) fn rejected_result(item: &Item) -> Item {
let mut item = item.clone();
if let Item::ToolResult {
summary,
content,
attachments,
disposition,
is_error,
..
} = &mut item
{
*summary = REJECTION_MESSAGE.to_owned();
*content = None;
attachments.clear();
*disposition = ToolResultDisposition::Error;
*is_error = true;
}
item
}
+1
View File
@@ -3,6 +3,7 @@
mod engine;
mod handler;
mod history;
mod image_recovery;
mod message;
pub(crate) mod callback;
+29
View File
@@ -90,6 +90,35 @@ impl ClientError {
}
}
/// A provider-declared image size rejection, not a generic invalid input.
/// Some providers (including OpenAI) expose only `invalid_value` here, so
/// narrowly recognize their image-size diagnostic without hardcoded limits.
pub fn is_image_size_rejection(&self) -> bool {
let Self::Api {
status: Some(400 | 413 | 422),
code,
message,
..
} = self
else {
return false;
};
if matches!(
code.as_deref(),
Some("image_too_large" | "image_size_exceeded")
) {
return true;
}
let message = message.to_ascii_lowercase();
message.contains("image")
&& ((message.contains("patches")
&& message.contains("exceed")
&& message.contains("limit"))
|| message.contains("image is too large")
|| message.contains("image too large")
|| (message.contains("image dimensions") && message.contains("exceed")))
}
pub fn retry_after(&self) -> Option<Duration> {
match self {
ClientError::Api { retry_after, .. } => *retry_after,
+6 -7
View File
@@ -136,14 +136,13 @@ pub fn project(items: &mut [Item], indices: &[usize]) -> usize {
/// Remove binary attachments after the first model request that could consume them.
///
/// Attachments on tool results following the latest model output are fresh outputs for the
/// immediate follow-up request and remain visible. Once a later assistant/reasoning/tool-call
/// item exists, the model has already had an opportunity to inspect the attachment. Subsequent
/// requests keep the durable tool summary and can call the originating tool again when the image
/// is still needed. Only the request-context clone is modified.
/// immediate follow-up request and remain visible. With early dispatch, later
/// text/calls in the same response have not consumed its tool results: use the
/// confirmed response boundary, not that response's last individual item.
/// Subsequent requests keep the durable tool summary and can call the originating
/// tool again when the image is still needed. Only the request-context clone is modified.
pub fn project_consumed_attachments(items: &mut [Item]) -> usize {
let latest_model_output = items.iter().rposition(|item| {
item.is_assistant_message() || item.is_reasoning() || item.is_tool_call()
});
let latest_model_output = crate::image_recovery::accepted_input_end(items.iter());
let mut count = 0;
for (index, item) in items.iter_mut().enumerate() {
let is_fresh = latest_model_output.is_some_and(|boundary| index > boundary);
+350
View File
@@ -0,0 +1,350 @@
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream};
use agen::tool::{Attachment, ImageAttachment};
use agen::{Engine, EngineRunExit, History, Item, ToolResultDisposition};
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
fn rejection() -> ClientError {
ClientError::Api { status: Some(400), code: Some("invalid_value".into()), message: "The image you provided requires 52150 patches after processing, exceeding the limit of 30000. Please resize the image and try again.".into(), retry_after: None }
}
fn image_result(id: &str, width: u32, height: u32, padding: usize) -> Item {
// PNG header: dimension inspection never decodes/allocates the pixel plane.
let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
bytes.extend(width.to_be_bytes());
bytes.extend(height.to_be_bytes());
bytes.resize(40 + padding, 0);
Item::ToolResult {
id: None,
call_id: id.into(),
summary: "Attached image".into(),
content: None,
disposition: ToolResultDisposition::Success,
is_error: false,
attachments: vec![Attachment::Image(ImageAttachment::new("image/png", bytes))],
}
}
fn history() -> History {
History::from_items(vec![
Item::user_message("inspect"),
Item::tool_call("small", "ViewImage", "{}"),
Item::tool_call("large", "ViewImage", "{}"),
image_result("small", 10, 10, 2000),
image_result("large", 1600, 33349, 0),
])
}
fn images(request: &Request) -> Vec<String> {
request
.items
.iter()
.filter_map(|item| match item {
Item::ToolResult {
call_id,
attachments,
..
} if !attachments.is_empty() => Some(call_id.clone()),
_ => None,
})
.collect()
}
#[derive(Clone)]
struct Client {
requests: Arc<Mutex<Vec<Request>>>,
failures: usize,
other_error: bool,
}
impl Client {
fn new(failures: usize) -> Self {
Self {
requests: Arc::default(),
failures,
other_error: false,
}
}
}
#[async_trait]
impl LlmClient for Client {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
let mut requests = self.requests.lock().unwrap();
requests.push(request);
if requests.len() <= self.failures {
return Err(rejection());
}
if self.other_error {
return Err(ClientError::Api {
status: Some(400),
code: Some("invalid_value".into()),
message: "invalid tool arguments".into(),
retry_after: None,
});
}
Ok(Box::pin(futures::stream::iter(vec![
Ok(Event::text_block_start(0)),
Ok(Event::text_delta(0, "resize and retry")),
Ok(Event::text_block_stop(0, None)),
Ok(Event::Status(StatusEvent {
status: ResponseStatus::Completed,
})),
])))
}
}
#[tokio::test]
async fn removes_largest_pixels_not_largest_file_and_keeps_pairing() {
let client = Client::new(1);
let observer = client.clone();
let mut history = history();
let mut engine = Engine::new(client);
let committed = Arc::new(Mutex::new(Vec::new()));
let commits = committed.clone();
engine.set_image_rejection_handler(move |original, replacement| {
commits
.lock()
.unwrap()
.push((original.item.clone(), replacement.clone()));
Ok(())
});
let output = engine.run(&mut history, "continue").await;
assert!(
matches!(output.result, EngineRunExit::Finished),
"{:?}",
output.result
);
let requests = observer.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert_eq!(images(&requests[0]), ["small", "large"]);
assert_eq!(images(&requests[1]), ["small"]);
assert!(requests[1].items.iter().any(|item| matches!(item, Item::ToolResult { call_id, is_error: true, disposition: ToolResultDisposition::Error, summary, .. } if call_id == "large" && summary.contains("retry ViewImage"))));
assert_eq!(committed.lock().unwrap().len(), 1);
assert_eq!(
history
.items()
.filter(|item| matches!(item, Item::ToolResult {call_id, ..} if call_id == "large"))
.count(),
1
);
}
#[tokio::test]
async fn multiple_rejections_remove_one_image_at_a_time_and_stop_at_exhaustion() {
let client = Client::new(99);
let observer = client.clone();
let mut history = history();
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
let requests = observer.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert_eq!(images(&requests[1]), ["small"]);
assert!(images(&requests[2]).is_empty());
}
#[tokio::test]
async fn unrelated_error_stops_recovery_without_removing_another_image() {
let mut client = Client::new(1);
client.other_error = true;
let observer = client.clone();
let mut history = history();
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
assert_eq!(observer.requests.lock().unwrap().len(), 2);
assert!(history.items().any(|item| matches!(item, Item::ToolResult {call_id, attachments, ..} if call_id == "small" && !attachments.is_empty())));
}
#[tokio::test]
async fn persistence_failure_does_not_change_history_or_retry() {
let client = Client::new(99);
let observer = client.clone();
let mut history = history();
let original = history.items_cloned();
let mut engine = Engine::new(client);
engine.set_image_rejection_handler(|_, _| Err("disk full".into()));
let out = engine.run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
assert_eq!(
&history.items_cloned()[..original.len()],
original.as_slice()
);
assert_eq!(observer.requests.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn previously_accepted_images_are_not_recovery_candidates() {
let client = Client::new(99);
let observer = client.clone();
let mut history = history();
history.push(Item::assistant_message("I saw those images"));
history.push(Item::tool_call("new", "ViewImage", "{}"));
history.push(image_result("new", 1, 1, 0));
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Interrupted(_)));
let requests = observer.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
// Already-consumed images are projected out, but their durable records
// must not be rewritten as failures.
assert!(images(&requests[1]).is_empty());
assert_eq!(history.items().filter(|item| matches!(item, Item::ToolResult {call_id, is_error: false, attachments, ..} if (call_id == "small" || call_id == "large") && !attachments.is_empty())).count(), 2);
}
#[tokio::test]
async fn legacy_empty_failed_request_boundaries_do_not_hide_candidates() {
let client = Client::new(1);
let observer = client.clone();
let mut history = history();
history.push(Item::assistant_response_boundary("failed-1"));
history.push(Item::assistant_response_boundary("failed-2"));
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(matches!(out.result, EngineRunExit::Finished));
assert_eq!(images(&observer.requests.lock().unwrap()[1]), ["small"]);
}
#[tokio::test]
async fn early_results_remain_candidates_despite_later_text_in_same_response() {
let client = Client::new(1);
let observer = client.clone();
let mut history = History::from_items(vec![
Item::user_message("inspect"),
Item::assistant_response_boundary("response-1"),
Item::tool_call("image", "ViewImage", "{}"),
image_result("image", 99, 99, 0),
Item::assistant_message("waiting for image"),
]);
let out = Engine::new(client).run(&mut history, "continue").await;
assert!(
matches!(out.result, EngineRunExit::Finished),
"{:?}; requests: {:?}",
out.result,
observer.requests.lock().unwrap()
);
assert!(images(&observer.requests.lock().unwrap()[1]).is_empty());
}
#[derive(Clone)]
struct CountingTool {
image: bool,
count: Arc<std::sync::atomic::AtomicUsize>,
}
#[async_trait]
impl agen::tool::Tool for CountingTool {
async fn execute(
&self,
_: &str,
_: agen::tool::ToolExecutionContext,
) -> Result<agen::tool::ToolOutput, agen::tool::ToolError> {
self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let attachments = if self.image {
let Item::ToolResult { attachments, .. } = image_result("image", 1600, 33349, 0) else {
unreachable!()
};
attachments
} else {
Vec::new()
};
Ok(agen::tool::ToolOutput {
summary: "executed once".into(),
content: None,
attachments,
})
}
}
#[derive(Clone)]
struct ToolClient(Client);
#[async_trait]
impl LlmClient for ToolClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
fn tool_call_completion_support(&self) -> agen::llm_client::ToolCallCompletionSupport {
agen::llm_client::ToolCallCompletionSupport::PerBlock
}
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
if self.0.requests.lock().unwrap().is_empty() {
self.0.requests.lock().unwrap().push(request);
return Ok(Box::pin(futures::stream::iter(
vec![
Event::tool_use_start(0, "image", "ViewImage"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::tool_use_start(1, "side-effect", "Bash"),
Event::tool_input_delta(1, "{}"),
Event::tool_use_stop(1),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
.into_iter()
.map(Ok),
)));
}
self.0.stream(request).await
}
}
#[tokio::test]
async fn fresh_image_recovery_does_not_repeat_tools_in_either_dispatch_mode() {
for mode in [
agen::ToolCallDispatchMode::AfterResponse,
agen::ToolCallDispatchMode::OnToolCallComplete,
] {
let client = Client::new(2);
let mut engine = Engine::new(ToolClient(client.clone()));
engine.set_tool_call_dispatch_mode(mode);
let mut counts = Vec::new();
for (name, image) in [("ViewImage", true), ("Bash", false)] {
let tool = CountingTool {
image,
count: Arc::default(),
};
counts.push(tool.count.clone());
engine.register_tool(Arc::new(move || {
(
agen::tool::ToolMeta::new(name)
.description("test")
.input_schema(serde_json::json!({"type":"object"})),
Arc::new(tool.clone()),
)
}));
}
let mut history = History::new();
let out = engine.run(&mut history, "inspect").await;
assert!(
matches!(out.result, EngineRunExit::Finished),
"{:?}",
out.result
);
assert!(
counts
.iter()
.all(|count| count.load(std::sync::atomic::Ordering::SeqCst) == 1)
);
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert_eq!(images(&requests[1]), ["image"]);
assert!(images(&requests[2]).is_empty());
}
}
#[test]
fn image_error_classifier_is_not_a_generic_400_retry() {
assert!(rejection().is_image_size_rejection());
for (status, message) in [
(400, "invalid image format"),
(400, "image URL not found"),
(400, "context window exceeds token limit"),
(401, "image is too large"),
(500, "image is too large"),
] {
assert!(
!ClientError::Api {
status: Some(status),
code: Some("invalid_value".into()),
message: message.into(),
retry_after: None
}
.is_image_size_rejection()
);
}
}
+2
View File
@@ -25,6 +25,8 @@ Does not own:
Profiles are reusable recipes; resolved Manifests are runtime contracts. Keep runtime-bound fields such as Worker name, concrete delegated scope, sockets, session pointers, and raw secrets out of reusable Profiles.
`feature.workdir_catalog.enabled` defaults to `true` for read-only Repository/Workdir/attachment reference discovery in WIP mode. It is independent of `feature.manage_workdir` (which defaults disabled), registers no normal Tools, and does not grant filesystem, attachment, or Workspace authority. Permissions remain independent. Standalone Tools-mode profiles may retain this default; only WIP installation consumes the flag. Set it to `false` to opt out.
## See also
- [`../../docs/design/profiles-manifests-prompts.md`](../../docs/design/profiles-manifests-prompts.md)
+116 -4
View File
@@ -51,8 +51,9 @@ pub struct WorkerManifestConfig {
/// is disabled; `Some` requires `default_action` during final resolve.
#[serde(default)]
pub permissions: Option<PermissionConfigPartial>,
/// Explicit built-in feature/tool-surface enablement. Absent flags resolve
/// disabled after cascade merge.
/// Built-in feature/tool-surface enablement. Absent tool flags resolve
/// disabled after cascade merge; the read-only WIP workdir catalog defaults
/// enabled without granting authority.
#[serde(default)]
pub feature: FeatureConfigPartial,
/// Explicit Model Context Protocol provider declarations. Config parsing
@@ -95,6 +96,8 @@ pub struct FeatureConfigPartial {
#[serde(default)]
pub manage_workdir: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub workdir_catalog: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub ticket: Option<TicketFeatureConfigPartial>,
#[serde(default)]
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
@@ -136,6 +139,11 @@ impl FeatureConfigPartial {
other.manage_workdir,
FeatureFlagConfigPartial::merge,
),
workdir_catalog: merge_option(
self.workdir_catalog,
other.workdir_catalog,
FeatureFlagConfigPartial::merge,
),
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
merge_request: merge_option(
self.merge_request,
@@ -388,6 +396,12 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.manage_workdir
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
workdir_catalog: FeatureFlagConfig {
enabled: value
.workdir_catalog
.and_then(|flag| flag.enabled)
.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED),
},
ticket: value
.ticket
.map(TicketFeatureConfig::from)
@@ -579,6 +593,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
workspace_worker_discovery: Some(value.workspace_worker_discovery.into()),
objective: Some(value.objective.into()),
manage_workdir: Some(value.manage_workdir.into()),
workdir_catalog: Some(value.workdir_catalog.into()),
ticket: Some(value.ticket.into()),
merge_request: Some(value.merge_request.into()),
orchestration: Some(value.orchestration.into()),
@@ -766,7 +781,8 @@ impl WorkerManifestConfig {
/// Base config populated with the in-code per-field defaults listed in
/// [`crate::defaults`]. This is not a selectable Profile and does not
/// enable a launch capability surface. Profile and one-file Manifest
/// enable launch authority or normal Tools. The read-only WIP workdir
/// catalog defaults enabled independently. Profile and one-file Manifest
/// resolvers start from this layer so every per-field default lives at
/// exactly one call site (the `defaults` module).
///
@@ -2232,7 +2248,7 @@ worker_max_turns = 7
}
#[test]
fn feature_flags_default_disabled_in_resolved_manifest() {
fn tool_feature_flags_default_disabled_except_read_only_workdir_catalog() {
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
assert!(!manifest.feature.task.enabled);
assert!(!manifest.feature.memory.profile.enabled);
@@ -2240,10 +2256,106 @@ worker_max_turns = 7
assert!(!manifest.feature.sub_worker.enabled);
assert!(!manifest.feature.objective.enabled);
assert!(!manifest.feature.manage_workdir.enabled);
assert!(manifest.feature.workdir_catalog.enabled);
assert!(!manifest.feature.ticket.enabled);
assert!(!manifest.feature.merge_request.any());
}
#[test]
fn workdir_catalog_defaults_agree_for_partial_and_resolved_configs() {
assert!(FeatureConfig::default().workdir_catalog.enabled);
assert!(FeatureConfigPartial::default().workdir_catalog.is_none());
for source in ["", "[feature]\n", "[feature.workdir_catalog]\n"] {
let partial = WorkerManifestConfig::from_toml(source).unwrap();
let feature = FeatureConfig::from(partial.feature);
assert!(feature.workdir_catalog.enabled, "{source}");
let value: toml::Value = toml::from_str(source).unwrap();
let resolved: FeatureConfig = serde_json::from_value(
serde_json::to_value(
value
.get("feature")
.cloned()
.unwrap_or_else(|| toml::Value::Table(Default::default())),
)
.unwrap(),
)
.unwrap();
assert_eq!(feature, resolved);
}
for source in ["{}", r#"{"workdir_catalog": {}}"#] {
let feature: FeatureConfig = serde_json::from_str(source).unwrap();
assert!(feature.workdir_catalog.enabled);
assert!(!feature.manage_workdir.enabled);
}
}
#[test]
fn workdir_catalog_is_independent_and_merges_partial_layers() {
for catalog in [false, true] {
for manage in [false, true] {
let source = format!(
"[feature.workdir_catalog]\nenabled = {catalog}\n\
[feature.manage_workdir]\nenabled = {manage}\n"
);
let lower = WorkerManifestConfig::from_toml(&source).unwrap();
let empty = WorkerManifestConfig::from_toml("[feature.workdir_catalog]").unwrap();
let cfg = minimal_valid().merge(lower.clone()).merge(empty);
let manifest: WorkerManifest = cfg.try_into().unwrap();
assert_eq!(manifest.feature.workdir_catalog.enabled, catalog);
assert_eq!(manifest.feature.manage_workdir.enabled, manage);
let override_source =
format!("[feature.workdir_catalog]\nenabled = {}\n", !catalog);
let upper = WorkerManifestConfig::from_toml(&override_source).unwrap();
let manifest: WorkerManifest = minimal_valid()
.merge(lower)
.merge(upper)
.try_into()
.unwrap();
assert_eq!(manifest.feature.workdir_catalog.enabled, !catalog);
assert_eq!(manifest.feature.manage_workdir.enabled, manage);
}
}
}
#[test]
fn workdir_catalog_roundtrips_config_and_persisted_snapshots() {
for enabled in [false, true] {
let mut cfg = minimal_valid();
cfg.feature.workdir_catalog = Some(FeatureFlagConfigPartial {
enabled: Some(enabled),
});
let manifest: WorkerManifest = cfg.try_into().unwrap();
let partial = FeatureConfigPartial::from(manifest.feature.clone());
assert_eq!(
partial.workdir_catalog.as_ref().unwrap().enabled,
Some(enabled)
);
let serialized = toml::to_string(&partial).unwrap();
let partial: FeatureConfigPartial = toml::from_str(&serialized).unwrap();
assert_eq!(FeatureConfig::from(partial), manifest.feature);
let serialized = serde_json::to_value(&manifest.feature).unwrap();
assert_eq!(serialized["workdir_catalog"]["enabled"], enabled);
let feature: FeatureConfig = serde_json::from_value(serialized).unwrap();
assert_eq!(feature, manifest.feature);
let snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap();
let restored =
crate::read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap();
assert_eq!(restored.feature.workdir_catalog.enabled, enabled);
assert!(!restored.feature.manage_workdir.enabled);
let mut legacy = snapshot;
legacy["manifest"]["feature"]
.as_object_mut()
.unwrap()
.remove("workdir_catalog");
let restored = crate::read_persisted_worker_manifest_snapshot(legacy).unwrap();
assert!(restored.feature.workdir_catalog.enabled);
assert!(!restored.feature.manage_workdir.enabled);
}
}
#[test]
fn from_toml_parses_explicit_feature_flags() {
let cfg = WorkerManifestConfig::from_toml(
+4
View File
@@ -6,6 +6,10 @@
//! `pub const`. Both paths read from this module, so changing a
//! default requires editing exactly one line.
/// Read-only Repository/Workdir/attachment reference discovery in WIP mode.
/// This enables a reference provider, not normal Tools or any authority grant.
pub const WORKDIR_CATALOG_ENABLED: bool = true;
/// Byte-size cap applied to any tool's `content` output when no
/// per-tool override is set. See [`crate::ToolOutputLimits`].
pub const TOOL_OUTPUT_MAX_BYTES: usize = 64 * 1024;
+29 -2
View File
@@ -65,8 +65,9 @@ pub struct WorkerManifest {
/// permission layer is disabled and tool calls run as before.
#[serde(default)]
pub permissions: Option<ToolPermissionConfig>,
/// Explicit built-in feature/tool-surface enablement. Omitted feature flags
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
/// Built-in feature/tool-surface enablement. Omitted tool features resolve
/// disabled; the read-only WIP workdir catalog defaults enabled without
/// granting authority or registering normal Tools.
#[serde(default)]
pub feature: FeatureConfig,
/// Explicit external Model Context Protocol provider configuration. This
@@ -131,6 +132,15 @@ pub struct FeatureConfig {
pub objective: FeatureFlagConfig,
#[serde(default)]
pub manage_workdir: FeatureFlagConfig,
/// Read-only Repository/Workdir/attachment reference discovery. Defaults
/// enabled, is installed only in WIP mode, and exposes no normal Tools.
/// Independent of `manage_workdir`; permissions and host authority remain
/// separate, and discovering a reference grants no attachment or file access.
#[serde(
default = "default_workdir_catalog",
deserialize_with = "deserialize_workdir_catalog"
)]
pub workdir_catalog: FeatureFlagConfig,
#[serde(default)]
pub ticket: TicketFeatureConfig,
#[serde(default)]
@@ -139,6 +149,22 @@ pub struct FeatureConfig {
pub orchestration: FeatureFlagConfig,
}
fn default_workdir_catalog() -> FeatureFlagConfig {
FeatureFlagConfig {
enabled: defaults::WORKDIR_CATALOG_ENABLED,
}
}
fn deserialize_workdir_catalog<'de, D>(deserializer: D) -> Result<FeatureFlagConfig, D::Error>
where
D: serde::Deserializer<'de>,
{
let flag = config::FeatureFlagConfigPartial::deserialize(deserializer)?;
Ok(FeatureFlagConfig {
enabled: flag.enabled.unwrap_or(defaults::WORKDIR_CATALOG_ENABLED),
})
}
impl Default for FeatureConfig {
fn default() -> Self {
Self {
@@ -153,6 +179,7 @@ impl Default for FeatureConfig {
workspace_worker_discovery: FeatureFlagConfig::disabled(),
objective: FeatureFlagConfig::disabled(),
manage_workdir: FeatureFlagConfig::disabled(),
workdir_catalog: default_workdir_catalog(),
ticket: TicketFeatureConfig::default(),
merge_request: MergeRequestFeatureConfig::default(),
orchestration: FeatureFlagConfig::disabled(),
+55
View File
@@ -181,6 +181,8 @@ pub fn validate_profile_execution_target(
if feature.flow.enabled {
requirements.insert(WorkspaceAuthorityRequirement::Flow);
}
// workdir_catalog is a WIP-only reference provider, not Workspace authority
// or normal Tools. It must not prevent standalone Tools-mode launches.
if feature.manage_workdir.enabled {
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
}
@@ -1586,6 +1588,59 @@ mod tests {
assert!(!resolved.manifest.feature.manage_workdir.enabled);
}
#[test]
fn workdir_catalog_does_not_require_workspace_authority_or_grant_scope() {
let tmp = TempDir::new().unwrap();
for catalog in [None, Some(false), Some(true)] {
let mut artifact = serde_json::json!({
"worker": { "mode": "tools" },
"feature": { "manage_workdir": { "enabled": false } },
"permissions": { "default_action": "deny" }
});
if let Some(enabled) = catalog {
artifact["feature"]["workdir_catalog"] = serde_json::json!({ "enabled": enabled });
}
let resolved = resolve_profile_artifact_value(
artifact,
ProfileSource::Archive {
archive_id: "catalog-test".into(),
source: "test".into(),
},
tmp.path(),
"catalog-worker",
)
.unwrap();
let manifest = resolved.manifest;
assert_eq!(
manifest.feature.workdir_catalog.enabled,
catalog.unwrap_or(true)
);
assert!(!manifest.feature.manage_workdir.enabled);
assert_eq!(manifest.worker.mode, crate::WorkerMode::Tools);
assert!(manifest.scope.allow.is_empty());
assert!(manifest.delegation_scope.allow.is_empty());
assert_eq!(
manifest.permissions.as_ref().unwrap().default_action,
crate::ToolPermissionAction::Deny
);
validate_profile_execution_target(&manifest, ProfileExecutionTarget::Standalone)
.unwrap();
let mut manage_manifest = manifest;
manage_manifest.feature.manage_workdir.enabled = true;
let error = validate_profile_execution_target(
&manage_manifest,
ProfileExecutionTarget::Standalone,
)
.unwrap_err();
assert!(matches!(
error,
ProfileError::UnsupportedExecutionTarget { requirements, .. }
if requirements == vec![WorkspaceAuthorityRequirement::ManageWorkdir]
));
}
}
#[test]
fn standalone_rejects_profiles_that_require_workspace_authority() {
let tmp = TempDir::new().unwrap();
+277 -3
View File
@@ -190,8 +190,14 @@ impl_openapi_schema!(
WorkingDirectoryListResponse,
WorkingDirectoryDetailResponse,
WorkingDirectoryCreateResponse,
CurrentWorkerWorkdirCatalogQuery,
CurrentWorkerWorkdirCatalogResponse,
CurrentWorkerWorkdirAttachRequest,
CurrentWorkerWorkdirAttachmentResponse,
CurrentWorkerWorkdirAttachmentItem,
CurrentWorkerWorkdirAttachmentListQuery,
CurrentWorkerWorkdirAttachmentListResponse,
CurrentWorkerWorkdirDetachQuery,
CurrentWorkerWorkdirOperationRequest,
CurrentWorkerWorkdirOperationResponse,
ExternalWorkdirGrantCreateRequest,
@@ -237,6 +243,7 @@ impl_openapi_schema!(
MemoryConsolidateStagingRequest,
MemoryConsolidationResponse,
SubjektivSubjectCreateRequest,
SubjektivSubjectBehaviorUpdateRequest,
SubjektivSubjectListQuery,
SubjektivSubjectListResponse,
SubjektivSubjectResponse,
@@ -1163,6 +1170,20 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[path] subject_id: String,
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
#[patch(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/behavior",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 409, 500],
bearer_auth = true,
browser_auth = true,
)]
async fn subjektiv_subject_behavior_update(
&self,
#[path] workspace_id: String,
#[path] subject_id: String,
#[body] request: SubjektivSubjectBehaviorUpdateRequest,
) -> Result<SubjektivSubjectResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/surface",
status = 200,
@@ -2549,6 +2570,32 @@ pub trait ServerApi {
#[path] working_directory_id: String,
#[body] request: WorkingDirectoryRemovalRequest,
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/workers/self/workdir-catalog",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500],
openapi = false,
)]
async fn current_worker_workdir_catalog(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[query] query: CurrentWorkerWorkdirCatalogQuery,
) -> Result<CurrentWorkerWorkdirCatalogResponse, RepositoryApiError>;
#[get(
"/api/w/{workspace_id}/workers/self/workdir-attachments",
status = 200,
error_status = 400,
additional_error_statuses = [401, 403, 404, 500],
openapi = false,
)]
async fn current_worker_workdir_attachment_list(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[query] query: CurrentWorkerWorkdirAttachmentListQuery,
) -> Result<CurrentWorkerWorkdirAttachmentListResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/workers/self/workdir-attachments",
status = 200,
@@ -2574,6 +2621,7 @@ pub trait ServerApi {
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] alias: String,
#[query] query: CurrentWorkerWorkdirDetachQuery,
) -> Result<CurrentWorkerWorkdirAttachmentResponse, RepositoryApiError>;
#[post(
"/api/w/{workspace_id}/workers/self/workdir-session/operations",
@@ -4579,9 +4627,20 @@ pub enum SubjektivSubjectState {
#[serde(deny_unknown_fields)]
pub struct SubjektivSubjectCreateRequest {
pub role: String,
#[serde(default)]
pub behavior_md: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivSubjectBehaviorUpdateRequest {
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub expected_behavior_revision: u64,
pub behavior_md: String,
}
pub const SUBJEKTIV_BROWSER_MAX_LIST_LIMIT: usize = 100;
pub const SUBJEKTIV_MAX_BEHAVIOR_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
@@ -4609,6 +4668,9 @@ pub struct SubjektivSubjectListResponse {
pub struct SubjektivSubjectResponse {
pub id: String,
pub role: String,
pub behavior_md: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub behavior_revision: u64,
pub state: SubjektivSubjectState,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub store_revision: u64,
@@ -5442,10 +5504,22 @@ pub struct SubjektivSurfaceFailureResponse {
pub status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivResidentContextOutput {
/// Exact user-managed document. Empty means explicitly unset.
pub behavior_md: String,
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
pub behavior_revision: u64,
/// Independently-fresh generated Memory projection.
pub memory_surface: memory::backend::MemoryResidentSummaryOutput,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "operation", content = "input", rename_all = "snake_case")]
pub enum SubjektivMemoryBackendOperation {
ResidentSummary(memory::backend::MemoryResidentSummaryOperation),
ResidentContext(memory::backend::MemoryResidentSummaryOperation),
Query(SubjektivMemoryQueryRequest),
Read(SubjektivMemoryReadRequest),
ListRevisions(SubjektivMemoryListRevisionsRequest),
@@ -5618,6 +5692,7 @@ pub struct SubjektivMemoryStageExplicitResponse {
#[serde(tag = "result", content = "data", rename_all = "snake_case")]
pub enum SubjektivMemoryBackendResponse {
ResidentSummary(memory::backend::MemoryResidentSummaryOutput),
ResidentContext(SubjektivResidentContextOutput),
Query(SubjektivMemoryQueryResponse),
Read(SubjektivMemoryReadResponse),
ListRevisions(SubjektivMemoryListRevisionsResponse),
@@ -6754,6 +6829,69 @@ pub struct CurrentWorkerWorkdirAttachRequest {
pub working_directory_id: String,
}
/// Stable Workdir-ID keyset paging over the authoritative inventory, not a Runtime snapshot.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirCatalogQuery {
/// Defaults to 50; valid range is 1..=100 scanned registry records.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
/// Exclusive stable Workdir ID. An empty policy-filtered page can still have a next cursor.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirCatalogResponse {
pub workspace_id: String,
pub items: Vec<WorkingDirectorySummary>,
pub next_cursor: Option<String>,
/// Opaque digest of the complete authoritative inventory and its occupancy.
pub revision: String,
}
/// Bounded, alias-ordered paging over the current Worker's active attachments.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirAttachmentListQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offset: Option<u32>,
/// Exact caller-scoped connection lookup, applied before paging; 1..=128 bytes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connection_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirAttachmentItem {
pub alias: String,
pub working_directory_id: String,
pub capabilities: workdir::WorkdirSessionCapabilities,
/// Opaque durable identity of this attachment lifetime, not a Workdir ID.
pub connection_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirAttachmentListResponse {
pub workspace_id: String,
pub items: Vec<CurrentWorkerWorkdirAttachmentItem>,
pub next_offset: Option<u32>,
/// Opaque digest of this caller's complete active connection set, independent of query paging/filtering.
pub revision: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirDetachQuery {
/// When omitted, retain the normal Tool's unconditional detach contract.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_connection_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirAttachmentResponse {
@@ -9869,9 +10007,11 @@ pub fn memory_api_typescript() -> String {
// `ts-rs` solely for this read-only projection.
let subjektiv_browser_declarations = r#"export type SubjektivSubjectState = "active" | "retired";
export type SubjektivSubjectCreateRequest = { role: string, };
export type SubjektivSubjectCreateRequest = { role: string, behavior_md?: string, };
export type SubjektivSubjectResponse = { id: string, role: string, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectBehaviorUpdateRequest = { expected_behavior_revision: number, behavior_md: string, };
export type SubjektivSubjectResponse = { id: string, role: string, behavior_md: string, behavior_revision: number, state: SubjektivSubjectState, store_revision: number, created_at: string, updated_at: string, current_worker?: | import("./worker-launch-api").WorkerLaunchWorkerSummary | null, };
export type SubjektivSubjectListResponse = { limit: number, items: Array<SubjektivSubjectResponse>, next_cursor?: string | null, has_more: boolean, };
@@ -9904,8 +10044,11 @@ export type SubjektivMemoryListRevisionsResponse = { memory_id: string, current_
let limits = format!(
"export const MEMORY_API_LIMITS = {{\n maxResponseBytes: {MEMORY_API_MAX_RESPONSE_BYTES},\n maxDocumentBytes: {MEMORY_API_MAX_DOCUMENT_BYTES},\n maxCollectionItems: {MEMORY_API_MAX_COLLECTION_ITEMS},\n maxStringBytes: {MEMORY_API_MAX_STRING_BYTES},\n maxIdentifierBytes: {MEMORY_API_MAX_IDENTIFIER_BYTES},\n}} as const;"
);
let subjektiv_limits = format!(
"export const SUBJEKTIV_API_LIMITS = {{\n maxBehaviorBytes: {SUBJEKTIV_MAX_BEHAVIOR_BYTES},\n}} as const;"
);
format!(
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{}\n\n{subjektiv_browser_declarations}\n",
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{limits}\n\n{subjektiv_limits}\n\n{}\n\n{subjektiv_browser_declarations}\n",
declarations
.into_iter()
.map(|declaration| format!("export {declaration}"))
@@ -11938,6 +12081,135 @@ mod tests {
);
}
#[test]
fn current_worker_attachment_contract_keeps_normal_tool_response_closed() {
let lookup = CurrentWorkerWorkdirAttachmentListQuery {
limit: Some(1),
offset: None,
connection_id: Some("opaque-connection".to_string()),
};
let value = serde_json::json!({"limit": 1, "connection_id": "opaque-connection"});
assert_eq!(serde_json::to_value(&lookup).unwrap(), value);
assert_eq!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListQuery>(value).unwrap(),
lookup
);
assert_eq!(
serde_json::to_value(CurrentWorkerWorkdirAttachmentListQuery::default()).unwrap(),
serde_json::json!({})
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListQuery>(
serde_json::json!({"connection_id": 123})
)
.is_err()
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListQuery>(
serde_json::json!({"expected_connection_id": "wrong-field"})
)
.is_err()
);
let query: CurrentWorkerWorkdirDetachQuery =
serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(query.expected_connection_id, None);
assert_eq!(serde_json::to_value(query).unwrap(), serde_json::json!({}));
assert!(
serde_json::from_value::<CurrentWorkerWorkdirDetachQuery>(
serde_json::json!({"connection_id":"wrong-field"})
)
.is_err()
);
let response = CurrentWorkerWorkdirAttachmentResponse {
workspace_id: "workspace".to_string(),
alias: "checkout".to_string(),
working_directory_id: "workdir".to_string(),
capabilities: workdir::WorkdirSessionCapabilities::EMPTY,
attached: false,
};
let value = serde_json::to_value(&response).unwrap();
assert_eq!(value.as_object().unwrap().len(), 5);
assert!(value.get("connection_id").is_none());
assert_eq!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentResponse>(value).unwrap(),
response
);
let list = CurrentWorkerWorkdirAttachmentListResponse {
workspace_id: "workspace".to_string(),
revision: "opaque-revision".to_string(),
next_offset: Some(1),
items: vec![CurrentWorkerWorkdirAttachmentItem {
alias: "checkout".to_string(),
working_directory_id: "workdir".to_string(),
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
connection_id: "opaque".to_string(),
}],
};
assert_eq!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListResponse>(
serde_json::to_value(&list).unwrap()
)
.unwrap(),
list
);
}
#[test]
fn current_worker_workdir_catalog_contract_is_bounded_and_revision_is_required() {
let query = CurrentWorkerWorkdirCatalogQuery {
limit: Some(100),
cursor: Some("stable-id".to_string()),
};
assert_eq!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogQuery>(
serde_json::to_value(&query).unwrap()
)
.unwrap(),
query
);
assert_eq!(
serde_json::to_value(CurrentWorkerWorkdirCatalogQuery::default()).unwrap(),
serde_json::json!({})
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogQuery>(
serde_json::json!({"offset": 0})
)
.is_err()
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogQuery>(
serde_json::json!({"limit": -1})
)
.is_err()
);
let response = CurrentWorkerWorkdirCatalogResponse {
workspace_id: "workspace".to_string(),
items: Vec::new(),
next_cursor: Some("scanned-legacy-row".to_string()),
revision: "opaque".to_string(),
};
assert_eq!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogResponse>(
serde_json::to_value(&response).unwrap()
)
.unwrap(),
response
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogResponse>(
serde_json::json!({"workspace_id": "workspace", "items": [], "next_cursor": null})
)
.is_err()
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListResponse>(
serde_json::json!({"workspace_id": "workspace", "items": [], "next_offset": null})
)
.is_err()
);
}
#[test]
fn workspace_and_repository_response_shapes_round_trip() {
let workspace = serde_json::json!({
@@ -12942,6 +13214,8 @@ mod openapi_artifact_tests {
// identity, or a one-use Reviewer capability. They remain generated Rust client/Axum
// operations, but must not appear as unauthenticated operations in the public OpenAPI.
const SIGNED_INTERNAL: &[&str] = &[
"current_worker_workdir_catalog",
"current_worker_workdir_attachment_list",
"current_worker_workdir_attach",
"current_worker_workdir_detach",
"current_worker_workdir_operation",
+1
View File
@@ -1071,6 +1071,7 @@ fn persisted_entry_units(entries: &[LogEntry]) -> usize {
| LogEntry::AnnotatedUserInput { history, .. } => history.len(),
LogEntry::AnnotatedAssistantItem { .. }
| LogEntry::AnnotatedToolResult { .. }
| LogEntry::ToolResultCorrected { .. }
| LogEntry::AnnotatedSystemItem { .. } => 1,
_ => 0,
};
+297 -31
View File
@@ -7,7 +7,7 @@ use protocol::{
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use thiserror::Error;
@@ -139,6 +139,17 @@ struct RetainedHistoryCursor {
before_offset: u64,
record_end_offset: Option<u64>,
seed_entry_index: Option<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
corrections: Vec<ToolCorrectionPosition>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct ToolCorrectionPosition {
entry_id: String,
segment_id: SegmentId,
start: u64,
end: u64,
}
#[derive(Debug)]
@@ -445,6 +456,7 @@ pub fn read_retained_session_history_page(
limits,
&mut scanned_bytes,
&mut scanned_entries,
&mut Vec::new(),
)?;
Some(SessionHistoryLineageBoundary {
lineage_id: lineage_identity(active.session_id, &lineage[1..]),
@@ -456,6 +468,10 @@ pub fn read_retained_session_history_page(
} else {
None
};
let mut correction_positions = cursor
.as_ref()
.map(|c| c.corrections.clone())
.unwrap_or_default();
let mut turns = read_history_turns_backward(
&store,
active.session_id,
@@ -465,11 +481,23 @@ pub fn read_retained_session_history_page(
limits,
&mut scanned_bytes,
&mut scanned_entries,
&mut correction_positions,
)?;
let has_more = turns.len() > page_limit;
if has_more {
turns.truncate(page_limit);
}
// Carry only corrections whose original results are still on older pages.
let returned_ids: HashSet<_> = turns
.iter()
.flat_map(|turn| {
turn.turn
.entries
.iter()
.map(|entry| entry.entry_id.as_str())
})
.collect();
correction_positions.retain(|position| !returned_ids.contains(position.entry_id.as_str()));
let next_cursor = if has_more {
let oldest = turns
.last()
@@ -485,6 +513,7 @@ pub fn read_retained_session_history_page(
before_offset: oldest.position.before_offset,
record_end_offset: oldest.position.record_end_offset,
seed_entry_index: oldest.position.seed_entry_index,
corrections: correction_positions,
})?)
} else {
None
@@ -702,6 +731,7 @@ fn persisted_entry_units(entries: &[LogEntry]) -> usize {
| LogEntry::AnnotatedUserInput { history, .. } => history.len(),
LogEntry::AnnotatedAssistantItem { .. }
| LogEntry::AnnotatedToolResult { .. }
| LogEntry::ToolResultCorrected { .. }
| LogEntry::AnnotatedSystemItem { .. } => 1,
_ => 0,
};
@@ -814,10 +844,56 @@ fn read_history_turns_backward(
limits: RetainedHistoryReadLimits,
scanned_bytes: &mut u64,
scanned_entries: &mut usize,
correction_positions: &mut Vec<ToolCorrectionPosition>,
) -> Result<Vec<PositionedHistoryTurn>, RetainedHistoryReadError> {
let mut turns = Vec::new();
let mut pending_entries = Vec::new();
let mut seen_entries = HashSet::new();
let mut corrections = HashMap::<String, SessionSnapshotEntryData>::new();
// Carry only bounded log positions, never correction text or image bodies,
// across pages. Re-read and validate the referenced records from adopted
// lineage rather than rescanning an arbitrarily large newer suffix.
for position in correction_positions.iter() {
let segment = lineage
.iter()
.find(|s| s.segment_id == position.segment_id)
.ok_or(RetainedHistoryReadError::InvalidCursor)?;
let adopted_end = locate_adopted_segment_end(
store,
session_id,
segment,
limits,
scanned_bytes,
scanned_entries,
)?;
if position.start >= position.end || position.end > adopted_end {
return Err(RetainedHistoryReadError::InvalidCursor);
}
let record = store
.read_log_record_range_read_only_bounded(
session_id,
position.segment_id,
position.start,
position.end,
limits.max_scan_bytes.saturating_sub(*scanned_bytes),
)
.map_err(map_history_cursor_store_error)?;
*scanned_bytes = scanned_bytes
.checked_add(record.end_offset - record.start_offset)
.ok_or(RetainedHistoryReadError::ResourceLimit)?;
*scanned_entries = scanned_entries
.checked_add(persisted_entry_units(std::slice::from_ref(&record.entry)))
.ok_or(RetainedHistoryReadError::ResourceLimit)?;
if *scanned_entries > limits.max_entries {
return Err(RetainedHistoryReadError::ResourceLimit);
}
if !matches!(&record.entry, LogEntry::ToolResultCorrected { entry, .. } if entry.metadata.entry_id.0 == position.entry_id)
|| !capture_tool_correction(&record.entry, &mut corrections)
{
return Err(RetainedHistoryReadError::InvalidCursor);
}
}
let (start_segment_index, start_position, cursor_turn_id) = match start {
Some((position, turn_id)) => {
@@ -878,32 +954,6 @@ fn read_history_turns_backward(
for (segment_index, segment) in lineage.iter().enumerate().skip(start_segment_index) {
let starting_here = segment_index == start_segment_index;
if starting_here
&& start_position
.as_ref()
.and_then(|position| position.seed_entry_index)
.is_some()
{
let seed_index = start_position
.as_ref()
.and_then(|position| position.seed_entry_index)
.expect("checked seed cursor");
if collect_history_entries_backward(
&segment.seed_entries[..seed_index],
segment.segment_id,
0,
None,
true,
turn_limit,
&mut seen_entries,
&mut pending_entries,
&mut turns,
) {
return Ok(turns);
}
continue;
}
let adopted_end = locate_adopted_segment_end(
store,
session_id,
@@ -923,6 +973,32 @@ fn read_history_turns_backward(
if before_offset > adopted_end {
return Err(RetainedHistoryReadError::InvalidCursor);
}
if starting_here
&& start_position
.as_ref()
.and_then(|position| position.seed_entry_index)
.is_some()
{
let seed_index = start_position
.as_ref()
.and_then(|position| position.seed_entry_index)
.expect("checked seed cursor");
if collect_history_entries_backward(
&corrected_history_entries(&segment.seed_entries[..seed_index], &corrections),
segment.segment_id,
0,
None,
true,
turn_limit,
&mut seen_entries,
&mut pending_entries,
&mut turns,
) {
return Ok(turns);
}
continue;
}
let mut reader = store
.open_retained_segment_reader(session_id, segment.segment_id, before_offset)
.map_err(map_history_store_error)?;
@@ -949,7 +1025,25 @@ fn read_history_turns_backward(
}
continue;
}
let entries = project_history_record(session_id, segment.segment_id, &record.entry);
if capture_tool_correction(&record.entry, &mut corrections) {
let LogEntry::ToolResultCorrected { entry, .. } = &record.entry else {
unreachable!()
};
let position = ToolCorrectionPosition {
entry_id: entry.metadata.entry_id.0.clone(),
segment_id: segment.segment_id,
start: record.start_offset,
end: record.end_offset,
};
if !correction_positions.contains(&position) {
correction_positions.push(position);
}
continue;
}
let entries = corrected_history_entries(
&project_history_record(session_id, segment.segment_id, &record.entry),
&corrections,
);
if collect_history_entries_backward(
&entries,
segment.segment_id,
@@ -967,7 +1061,7 @@ fn read_history_turns_backward(
if segment_index + 1 == lineage.len()
&& collect_history_entries_backward(
&segment.seed_entries,
&corrected_history_entries(&segment.seed_entries, &corrections),
segment.segment_id,
0,
None,
@@ -984,6 +1078,38 @@ fn read_history_turns_backward(
Ok(turns)
}
fn capture_tool_correction(
record: &LogEntry,
corrections: &mut HashMap<String, SessionSnapshotEntryData>,
) -> bool {
let LogEntry::ToolResultCorrected { entry, .. } = record else {
return false;
};
if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) {
// We read backwards: the first correction seen is the latest one.
corrections
.entry(entry.metadata.entry_id.0.clone())
.or_insert(data);
}
true
}
fn corrected_history_entries(
entries: &[SessionSnapshotEntry],
corrections: &HashMap<String, SessionSnapshotEntryData>,
) -> Vec<SessionSnapshotEntry> {
entries
.iter()
.cloned()
.map(|mut entry| {
if let Some(data) = corrections.get(&entry.entry_id) {
entry.data = data.clone();
}
entry
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn collect_history_entries_backward(
entries: &[SessionSnapshotEntry],
@@ -1052,9 +1178,13 @@ fn is_user_entry(entry: &SessionSnapshotEntry) -> bool {
fn encode_history_cursor(
cursor: &RetainedHistoryCursor,
) -> Result<String, RetainedHistoryReadError> {
serde_json::to_vec(cursor)
let encoded = serde_json::to_vec(cursor)
.map(|bytes| URL_SAFE_NO_PAD.encode(bytes))
.map_err(|_| RetainedHistoryReadError::CorruptLog)
.map_err(|_| RetainedHistoryReadError::CorruptLog)?;
if encoded.len() > 4096 {
return Err(RetainedHistoryReadError::ResourceLimit);
}
Ok(encoded)
}
fn decode_history_cursor(cursor: &str) -> Result<RetainedHistoryCursor, RetainedHistoryReadError> {
@@ -1252,6 +1382,19 @@ pub(crate) fn project_session_snapshot_for_segment(
entries.push(history_entry(entry, *ts, data));
}
}
LogEntry::ToolResultCorrected { ts, entry } => {
if let Some(data) = project_item(&entry.metadata.entry_id.0, &entry.item) {
if let Some(existing) = entries
.iter_mut()
.find(|existing| existing.entry_id == entry.metadata.entry_id.0)
{
// The original result keeps its place/time in the conversation.
existing.data = data;
} else {
entries.push(history_entry(entry, *ts, data));
}
}
}
LogEntry::AnnotatedSystemItem { ts, entry, .. } => entries.push(system_entry(
&entry.item,
entry.metadata.entry_id.0.clone(),
@@ -1782,6 +1925,129 @@ mod tests {
.collect()
}
#[test]
fn image_correction_keeps_original_position_across_history_pages() {
let session_id = crate::new_session_id();
let segment_id = crate::new_segment_id();
let mut image = assistant_message(1, "image");
image.item = agen::Item::tool_result_item_with_disposition_and_attachments(
"image-call",
"original image",
None,
agen::ToolResultDisposition::Success,
vec![agen::tool::Attachment::Image(
agen::tool::ImageAttachment::new("image/png", vec![1_u8, 2, 3]),
)],
)
.into();
let mut corrected = image.clone();
corrected.item = agen::Item::tool_result_item_with_disposition_and_attachments(
"image-call",
"Resize or crop and retry ViewImage",
None,
agen::ToolResultDisposition::Error,
Vec::new(),
)
.into();
let mut log = vec![segment_start(session_id, Vec::new(), None, None)];
append_turn(&mut log, 1);
log.push(LogEntry::AnnotatedToolResult {
ts: 15,
entry: image.clone(),
});
append_turn(&mut log, 2);
append_turn(&mut log, 3);
log.push(LogEntry::ToolResultCorrected {
ts: 35,
entry: corrected,
});
let (_root, aggregate_root) = persist_history_fixture(
"corrected-image",
session_id,
segment_id,
vec![(segment_id, log.clone())],
);
let first = read_retained_session_history_page(
&aggregate_root,
"corrected-image",
None,
Some(1),
RetainedHistoryReadLimits::default(),
)
.unwrap();
assert_eq!(turn_ids(&first), ["user-3"]);
assert!(
!first.turns[0]
.entries
.iter()
.any(|entry| entry.entry_id == image.metadata.entry_id.0)
);
let second = read_retained_session_history_page(
&aggregate_root,
"corrected-image",
first.next_cursor.as_deref(),
Some(1),
RetainedHistoryReadLimits::default(),
)
.unwrap();
assert_eq!(turn_ids(&second), ["user-2"]);
let third = read_retained_session_history_page(
&aggregate_root,
"corrected-image",
second.next_cursor.as_deref(),
Some(1),
RetainedHistoryReadLimits::default(),
)
.unwrap();
assert_eq!(turn_ids(&third), ["user-1"]);
let entry = third.turns[0]
.entries
.iter()
.find(|entry| entry.entry_id == image.metadata.entry_id.0)
.unwrap();
assert_eq!(entry.timestamp, 15);
assert!(
matches!(&entry.data, SessionSnapshotEntryData::ToolResult { is_error: true, attachments, .. } if attachments.is_empty())
);
assert_eq!(
*entry,
*project_session_snapshot(session_id, &log)
.entries
.iter()
.find(|entry| entry.entry_id == image.metadata.entry_id.0)
.unwrap()
);
let restored = crate::collect_state(&log);
assert_eq!(
restored
.annotated_history
.iter()
.filter(|entry| entry.metadata.entry_id == image.metadata.entry_id)
.count(),
1
);
assert!(
matches!(&restored.annotated_history.iter().find(|entry| entry.metadata.entry_id == image.metadata.entry_id).unwrap().item, LoggedItem::ToolResult { is_error: true, attachments, .. } if attachments.is_empty())
);
// Cursor pointers are only hints: the referenced record is read under
// current lineage authority and must actually be a correction record.
let mut cursor = decode_history_cursor(first.next_cursor.as_deref().unwrap()).unwrap();
cursor.corrections[0].segment_id = crate::new_segment_id();
let forged = encode_history_cursor(&cursor).unwrap();
assert_eq!(
read_retained_session_history_page(
&aggregate_root,
"corrected-image",
Some(&forged),
Some(1),
RetainedHistoryReadLimits::default()
)
.unwrap_err(),
RetainedHistoryReadError::InvalidCursor
);
}
#[test]
fn retained_history_pages_twelve_turns_as_five_five_two_across_compaction() {
let worker_name = "worker-history-12";
+13
View File
@@ -107,6 +107,9 @@ pub enum LogEntry {
/// Canonical tool output and metadata committed as one journal record.
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
/// Append-only correction of a provider-rejected image result. Retains the
/// original stable identity; replay replaces rather than appends the item.
ToolResultCorrected { ts: u64, entry: LoggedHistoryEntry },
/// Canonical typed system event and model-visible metadata committed
/// together.
@@ -399,6 +402,16 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.annotated_history.push(entry.clone());
state.history.push(Item::from(entry.item.clone()));
}
LogEntry::ToolResultCorrected { entry, .. } => {
if let Some(index) = state
.annotated_history
.iter()
.position(|existing| existing.metadata.entry_id == entry.metadata.entry_id)
{
state.annotated_history[index] = entry.clone();
state.history[index] = Item::from(entry.item.clone());
}
}
LogEntry::AnnotatedSystemItem {
entry, extensions, ..
} => {
+30
View File
@@ -175,6 +175,15 @@ pub enum SystemItem {
prompt_provenance: Option<PromptRenderProvenance>,
},
/// A Host-fetched change to user-managed Subject behavior. Each changed
/// revision is appended rather than rewriting prior prompt/session content.
SubjectBehaviorRefresh {
body: String,
behavior_revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
prompt_provenance: Option<PromptRenderProvenance>,
},
/// Synthetic note inserted after an interrupted turn before the next
/// user input. `body` is the exact LLM-context text explaining that the
/// previous turn was cut short.
@@ -201,6 +210,7 @@ impl SystemItem {
}
SystemItem::TaskReminder { body, .. } => body.clone(),
SystemItem::ResidentSummaryRefresh { body, .. } => body.clone(),
SystemItem::SubjectBehaviorRefresh { body, .. } => body.clone(),
SystemItem::Interrupt { body, .. } => body.clone(),
}
}
@@ -224,6 +234,7 @@ impl SystemItem {
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
SystemItem::TaskReminder { .. } => "task_reminder",
SystemItem::ResidentSummaryRefresh { .. } => "resident_summary_refresh",
SystemItem::SubjectBehaviorRefresh { .. } => "subject_behavior_refresh",
SystemItem::Interrupt { .. } => "interrupt",
}
}
@@ -280,6 +291,25 @@ mod tests {
));
}
#[test]
fn subject_behavior_refresh_round_trips_with_revision_and_exact_body() {
let item = SystemItem::SubjectBehaviorRefresh {
body: "current Subject context".to_string(),
behavior_revision: 7,
prompt_provenance: None,
};
let raw = serde_json::to_string(&item).unwrap();
let parsed: SystemItem = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed.history_text(), "current Subject context");
assert!(matches!(
parsed,
SystemItem::SubjectBehaviorRefresh {
behavior_revision: 7,
..
}
));
}
#[test]
fn notification_history_text_returns_stored_body() {
let item = SystemItem::Notification {
+1
View File
@@ -3042,6 +3042,7 @@ impl App {
| session_store::SystemItem::FileAttachment { body, .. }
| session_store::SystemItem::SkillActivation { body, .. }
| session_store::SystemItem::ResidentSummaryRefresh { body, .. }
| session_store::SystemItem::SubjectBehaviorRefresh { body, .. }
| session_store::SystemItem::Interrupt { body, .. } => {
self.task_store.apply_system_message_text(&body);
self.blocks.push(Block::SystemMessage { text: body });
+32 -5
View File
@@ -381,10 +381,10 @@ impl RuntimeOwnedWorkspaceClient {
self
}
fn execute_with_permission(
fn execute_with_optional_timeout(
&self,
request: WorkspaceRequest,
permission: &'static str,
timeout: Option<Duration>,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let method = match request.method {
WorkspaceRequestMethod::Get => reqwest::Method::GET,
@@ -409,9 +409,9 @@ impl RuntimeOwnedWorkspaceClient {
path_and_query: request.path,
body,
headers,
permission: permission.to_string(),
permission: WORKSPACE_REQUEST_PERMISSION.to_string(),
worker_id: Some(self.worker_id.clone()),
timeout: self.request_timeout,
timeout,
max_response_bytes: 8 * 1024 * 1024,
})
.map_err(|error| {
@@ -478,7 +478,15 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.execute_with_permission(request, WORKSPACE_REQUEST_PERMISSION)
self.execute_with_optional_timeout(request, self.request_timeout)
}
fn execute_with_timeout(
&self,
request: WorkspaceRequest,
timeout: Duration,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.execute_with_optional_timeout(request, Some(timeout))
}
fn execute_server_operation(
@@ -1341,6 +1349,25 @@ mod tests {
server.join().unwrap();
}
#[test]
fn runtime_owned_workspace_client_honors_per_request_timeouts() {
let (base_url, server) = delayed_workspace_response(Duration::from_millis(75));
let client =
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a");
let error = client
.execute_with_timeout(
WorkspaceRequest::json(WorkspaceRequestMethod::Post, "/api/test", "{}"),
Duration::from_millis(20),
)
.unwrap_err();
assert_eq!(client.request_timeout, None);
let message = error.to_string();
assert!(message.contains("POST /api/test"), "{message}");
assert!(message.contains("timed out"), "{message}");
server.join().unwrap();
}
#[test]
fn runtime_owned_workspace_client_reports_request_timeouts() {
let (base_url, server) = delayed_workspace_response(Duration::from_millis(75));
+102 -24
View File
@@ -489,11 +489,29 @@ fn stage_pending_notifications<St: Store + Clone>(
count
}
/// The durable input append is the activation boundary. Clear live state in
/// the same poll, before invocation preparation can checkpoint it again; the
/// oneshot only publishes the already-completed transition to the Controller.
pub(crate) fn input_committed_hook<St: Store + Clone>(
pending: crate::worker::PendingSubmissionHandle<St>,
submission_id: String,
sender: oneshot::Sender<()>,
) -> impl FnOnce() {
move || {
pending.finish_activation(&submission_id);
let _ = sender.send(());
}
}
fn notification_coalesce_remaining<St: Store + Clone>(
pending_submissions: &crate::worker::PendingSubmissionHandle<St>,
notify_buffer: &NotifyBuffer,
delay: Duration,
can_schedule_run: bool,
) -> Option<Duration> {
if !can_schedule_run {
return None;
}
let Some(accepted_at_ms) = pending_submissions.oldest_pending_notification_accepted_at_ms()
else {
return notify_buffer
@@ -1434,6 +1452,7 @@ where
crate::feature::builtin::memory::ordinary_subjektiv_resident_summary_source(
worker.manifest(),
worker.workspace_client_handle(),
worker.prompts(),
)?
{
let workspace_id = worker
@@ -1563,7 +1582,13 @@ where
),
);
}
if feature_config.manage_workdir.enabled {
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
let mut workdir_wip_feature = None;
if feature_config.manage_workdir.enabled
|| (wip_mode
&& feature_config.workdir_catalog.enabled
&& worker.workspace_client_handle().is_available())
{
// Workdir lifecycle is Workspace control-plane authority. The Worker
// receives only the injected WorkspaceClient and never Runtime URLs,
// repository paths, materializer handles, or cleanup sessions.
@@ -1579,7 +1604,7 @@ where
}
let shutdown_registry = spawned_registry.clone();
let reopen_registry = spawned_registry.clone();
feature_registry.add_module(
let module =
crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle(
workspace_client,
worker.workdir_sessions(),
@@ -1588,8 +1613,11 @@ where
Box::pin(async move { child_registry.shutdown_internal().await })
}),
Arc::new(move || reopen_registry.reopen_internal()),
),
);
);
if feature_config.manage_workdir.enabled {
feature_registry.add_module(module.clone());
}
workdir_wip_feature = Some(module);
}
if feature_config.workspace_worker_discovery.enabled {
let workspace_client = worker.workspace_client_handle();
@@ -1644,20 +1672,34 @@ where
}
let host_worker_observation_provider = worker.worker_observation_provider();
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
let wip_permissions = worker.manifest().permissions.clone();
let mut wip_mount_registry = crate::wip::WipMountRegistry::new();
if wip_mode && let Some(feature) = &workdir_wip_feature {
crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
&mut wip_mount_registry,
feature,
feature_config.workdir_catalog.enabled,
feature_config.manage_workdir.enabled,
wip_permissions.clone(),
)
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("mount Workdir WIP projection: {error}"),
)
})?;
}
{
let workspace_client = worker.workspace_client_handle();
let engine = worker.engine_mut();
if feature_config.merge_request.any() && wip_mode {
let feature_route = wip_mount_registry
.allocate_feature_route("merge-request")
let namespace_route = wip_mount_registry
.allocate_namespace("merge-request", "merge-requests")
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("allocate Merge Request WIP route: {error}"),
format!("allocate Merge Request WIP namespace: {error}"),
)
})?;
crate::feature::builtin::merge_request::mount_workspace_http_merge_request_wip(
@@ -1665,7 +1707,7 @@ where
workspace_client.clone(),
feature_config.merge_request,
wip_permissions.clone(),
&feature_route,
&namespace_route,
)
.map_err(|error| {
std::io::Error::new(
@@ -1682,12 +1724,12 @@ where
intake: feature_config.ticket.intake,
workflow: feature_config.ticket.workflow,
};
let feature_route = wip_mount_registry
.allocate_feature_route("ticket")
let namespace_route = wip_mount_registry
.allocate_namespace("ticket", "tickets")
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("allocate Ticket WIP route: {error}"),
format!("allocate Ticket WIP namespace: {error}"),
)
})?;
crate::feature::builtin::ticket::mount_workspace_http_ticket_wip(
@@ -1695,7 +1737,7 @@ where
workspace_client.clone(),
ticket_access,
wip_permissions.clone(),
&feature_route,
&namespace_route,
)
.map_err(|error| {
std::io::Error::new(
@@ -1716,19 +1758,19 @@ where
engine.register_tool(definition);
}
if wip_mode {
let feature_route = wip_mount_registry
.allocate_feature_route("objective")
let namespace_route = wip_mount_registry
.allocate_namespace("objective", "objectives")
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("allocate Objective WIP route: {error}"),
format!("allocate Objective WIP namespace: {error}"),
)
})?;
crate::feature::builtin::objective::mount_workspace_http_objective_wip(
&mut wip_mount_registry,
workspace_client.clone(),
wip_permissions.clone(),
&feature_route,
&namespace_route,
)
.map_err(|error| {
std::io::Error::new(
@@ -2140,9 +2182,11 @@ async fn controller_loop<C, St>(
submission.input,
vec![extension],
submission.provenance,
move || {
let _ = input_commit_tx.send(());
},
input_committed_hook(
pending_submissions.clone(),
committed_submission.submission_id.clone(),
input_commit_tx,
),
),
&mut method_rx,
&working_event_tx,
@@ -2199,10 +2243,6 @@ async fn controller_loop<C, St>(
.await
}
};
if notify_buffer.is_empty() {
pending_submissions.finish_notification_batch();
}
if !shutdown && may_drain_pending && new_status == WorkerStatus::Idle {
match prepare_pending_run(&pending_submissions, &notify_buffer, None, false) {
Ok(Some(next)) => {
@@ -2251,12 +2291,16 @@ async fn controller_loop<C, St>(
continue;
}
// An expired Notify deadline must not repeatedly schedule a run that
// cannot cross the invocation fence. Keep the accepted notification for
// a fresh input/recovery boundary instead of retrying business effects.
let notification_delay = (shared_state.catalog_status() == WorkerStatus::Idle)
.then(|| {
notification_coalesce_remaining(
&pending_submissions,
&notify_buffer,
notification_coalesce_delay,
worker.can_schedule_notification_run(),
)
})
.flatten();
@@ -3930,6 +3974,40 @@ mod tests {
assert!(notify_buffer.has_notification_pending());
}
#[test]
fn invocation_fence_suspends_expired_notification_deadline_without_dropping_input() {
let temp = TempDir::new().unwrap();
let pending = crate::worker::PendingSubmissionHandle::for_test(temp.path());
let buffer = NotifyBuffer::new();
pending
.accept_notification("held".into(), "held".into())
.unwrap();
let before = pending.persisted_entries_for_test().len();
assert_eq!(
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, true),
Some(Duration::ZERO)
);
for _ in 0..4 {
assert_eq!(
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, false),
None
);
}
assert_eq!(pending.persisted_entries_for_test().len(), before);
assert_eq!(pending.snapshot().notification_count, 1);
// A busy-time staged notification is equally retained behind the fence.
stage_pending_notifications(&pending, &buffer);
assert_eq!(
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, false),
None
);
assert!(buffer.has_notification_pending());
assert_eq!(
notification_coalesce_remaining(&pending, &buffer, Duration::ZERO, true),
Some(Duration::ZERO)
);
}
#[test]
fn restored_mixed_activations_preserve_global_fifo_order() {
let temp = TempDir::new().unwrap();
@@ -5,6 +5,8 @@
//! endpoints, credentials, materializer handles, and operation sessions stay
//! behind [`WorkspaceClient`].
pub mod wip;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
@@ -67,6 +69,7 @@ pub struct ManageWorkdirFeature {
session_router: Arc<workdir::WorkdirSessionRouter>,
before_workdir_release: Option<BeforeWorkdirRelease>,
after_workdir_attach: Option<AfterWorkdirAttach>,
attachment_mutation_lock: Arc<tokio::sync::Mutex<()>>,
}
impl std::fmt::Debug for ManageWorkdirFeature {
@@ -86,6 +89,7 @@ impl ManageWorkdirFeature {
session_router: Arc::new(workdir::WorkdirSessionRouter::new()),
before_workdir_release: None,
after_workdir_attach: None,
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
@@ -100,8 +104,19 @@ impl ManageWorkdirFeature {
session_router,
before_workdir_release: Some(before_workdir_release),
after_workdir_attach: Some(after_workdir_attach),
attachment_mutation_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
fn backend(&self) -> WorkspaceHttpWorkdirBackend {
let mut backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
.with_session_router(self.session_router.clone())
.with_child_lifecycle(
self.before_workdir_release.clone(),
self.after_workdir_attach.clone(),
);
backend.attachment_mutation_lock = self.attachment_mutation_lock.clone();
backend
}
}
pub fn manage_workdir_feature(client: Arc<dyn WorkspaceClient>) -> ManageWorkdirFeature {
@@ -120,12 +135,7 @@ impl FeatureModule for ManageWorkdirFeature {
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone())
.with_session_router(self.session_router.clone())
.with_child_lifecycle(
self.before_workdir_release.clone(),
self.after_workdir_attach.clone(),
);
let backend = self.backend();
for (name, definition) in [
(
LIST_TOOL,
@@ -581,11 +591,24 @@ impl WorkspaceHttpWorkdirBackend {
}
fn detach(&self, alias: &str) -> Result<ToolOutput, ToolError> {
self.detach_expected(alias, None)
}
fn detach_expected(
&self,
alias: &str,
expected_connection_id: Option<&str>,
) -> Result<ToolOutput, ToolError> {
let workspace_id = encode_path_segment(self.workspace_id()?);
let alias_path = encode_path_segment(alias);
let suffix = expected_connection_id
.map(|id| format!("?expected_connection_id={}", encode_path_segment(id)))
.unwrap_or_default();
let response = self.execute_json::<WorkdirAttachmentResponse>(WorkspaceRequest {
method: WorkspaceRequestMethod::Delete,
path: format!("/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}"),
path: format!(
"/api/w/{workspace_id}/workers/self/workdir-attachments/{alias_path}{suffix}"
),
body: None,
})?;
workdir_output(
@@ -622,6 +645,72 @@ impl WorkspaceHttpWorkdirBackend {
)
}
async fn detach_managed(
&self,
alias: &str,
expected_connection_id: Option<&str>,
) -> Result<ToolOutput, ToolError> {
let _mutation_guard = self.attachment_mutation_lock.lock().await;
let alias = validate_identity(alias, DETACH_TOOL, "alias")?;
if let Some(expected) = expected_connection_id {
// Under the shared local mutation lock, check lifetime before any
// local route exclusion or child cleanup. Backend DELETE still performs
// the authoritative conditional mutation under its lifecycle lock.
let workspace = encode_path_segment(self.workspace_id()?);
let page: serde_json::Value = self.execute_json(WorkspaceRequest::get(format!(
"/api/w/{workspace}/workers/self/workdir-attachments?limit=1&connection_id={}",
encode_path_segment(expected)
)))?;
let current = page
.get("items")
.and_then(serde_json::Value::as_array)
.is_some_and(|items| {
items.iter().any(|item| {
item.get("connection_id")
.and_then(serde_json::Value::as_str)
== Some(expected)
&& item.get("alias").and_then(serde_json::Value::as_str) == Some(alias)
})
});
if !current {
return Err(ToolError::StructuredConflict {
code: "workspace_http_409".into(),
message: "attachment lifetime changed before local cleanup".into(),
});
}
}
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
self.session_router
.begin_detach(&alias_key)
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if let Some(before_release) = &self.before_workdir_release
&& let Err(error) = before_release().await
{
self.session_router.cancel_detach(&alias_key);
return Err(ToolError::ExecutionFailed(format!(
"stop Internal SubWorkers before Workdir detach: {error}"
)));
}
let result = match self.detach_expected(alias, expected_connection_id) {
Ok(result) => result,
Err(error) => {
self.session_router.cancel_detach(&alias_key);
return Err(error);
}
};
self.session_router
.finish_detach(&alias_key)
.await
.map_err(|error| {
ToolError::ExecutionFailed(format!(
"detach Workdir session route after Backend release: {error}"
))
})?;
Ok(result)
}
fn execute_json<T: for<'de> Deserialize<'de>>(
&self,
request: WorkspaceRequest,
@@ -697,42 +786,10 @@ impl Tool for WorkspaceHttpWorkdirTool {
result
}
WorkdirOperation::Detach => {
let _mutation_guard = self.backend.attachment_mutation_lock.lock().await;
let input = parse_input::<WorkdirDetachInput>(input_json)?;
let alias = validate_identity(&input.alias, DETACH_TOOL, "alias")?;
let alias_key = workdir::WorkdirAttachmentAlias::new(alias)
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
self.backend
.session_router
.begin_detach(&alias_key)
.await
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if let Some(before_release) = &self.backend.before_workdir_release
&& let Err(error) = before_release().await
{
self.backend.session_router.cancel_detach(&alias_key);
return Err(ToolError::ExecutionFailed(format!(
"stop Internal SubWorkers before Workdir detach: {error}"
)));
}
let result = match self.backend.detach(alias) {
Ok(result) => result,
Err(error) => {
self.backend.session_router.cancel_detach(&alias_key);
return Err(error);
}
};
self.backend
.session_router
.finish_detach(&alias_key)
.await
.map_err(|error| {
ToolError::ExecutionFailed(format!(
"detach Workdir session route after Backend release: {error}"
))
})?;
Ok(result)
self.backend.detach_managed(&input.alias, None).await
}
WorkdirOperation::Delete => self
.backend
.delete(parse_input::<WorkdirDeleteInput>(input_json)?),
@@ -747,6 +804,12 @@ fn parse_input<T: for<'de> Deserialize<'de>>(input: &str) -> Result<T, ToolError
fn decode_response<T: for<'de> Deserialize<'de>>(
response: WorkspaceResponse,
) -> Result<T, ToolError> {
if matches!(response.status, 401 | 403 | 404 | 409) {
return Err(ToolError::StructuredConflict {
code: format!("workspace_http_{}", response.status),
message: "Workspace lifecycle authority rejected the request before execution".into(),
});
}
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Workspace Workdir API returned HTTP {}: {}",
File diff suppressed because it is too large Load Diff
+399 -68
View File
@@ -4,7 +4,9 @@
//! authority. In that case model-visible Memory tools must go through the
//! workspace backend instead of resolving `.yoi/memory` from a Worker workdir.
use std::sync::Arc;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use arc_swap::ArcSwap;
@@ -23,11 +25,15 @@ use crate::feature::{
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
HookDeclaration, ToolContribution, ToolDeclaration,
};
use crate::hook::{Hook, HookError, HookErrorCategory, HookExecutionPolicy, WorkerRestored};
use crate::hook::{
BeforeSessionRewrite, BeforeSessionRewriteAction, BeforeSessionRewriteContext, Hook, HookError,
HookErrorCategory, HookExecutionPolicy, HookPreRequestAction, PreLlmRequest, PreRequestContext,
WorkerRestored,
};
use crate::prompt::catalog::{PromptCatalog, WorkerPrompt};
use crate::worker::{
SystemPromptContribution, SystemPromptContributionSource, WorkspaceClient,
WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
ResidentContextRefresh, SystemPromptContribution, SystemPromptContributionSource,
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
};
#[derive(Clone, Debug)]
@@ -73,6 +79,8 @@ pub enum WorkspaceMemoryBackendError {
Decode(#[from] serde_json::Error),
#[error("workspace memory backend rejected operation: {0}")]
Backend(String),
#[error("workspace memory backend returned an invalid response: {0}")]
InvalidResponse(String),
}
impl dyn WorkspaceClient + '_ {
@@ -150,9 +158,24 @@ impl dyn WorkspaceClient + '_ {
}
}
const RESIDENT_SUMMARY_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
async fn execute_subjektiv_memory_operation(
client: &dyn WorkspaceClient,
operation: server_api::SubjektivMemoryBackendOperation,
) -> Result<server_api::SubjektivMemoryBackendResponse, WorkspaceMemoryBackendError> {
execute_subjektiv_memory_operation_with(client, operation, |client, request| {
client.execute(request)
})
}
fn execute_subjektiv_memory_operation_with(
client: &dyn WorkspaceClient,
operation: server_api::SubjektivMemoryBackendOperation,
execute: impl FnOnce(
&dyn WorkspaceClient,
WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, WorkspaceClientError>,
) -> Result<server_api::SubjektivMemoryBackendResponse, WorkspaceMemoryBackendError> {
let workspace_id =
client
@@ -163,11 +186,14 @@ async fn execute_subjektiv_memory_operation(
client.kind()
),
})?;
let response = client.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/subjektiv/memory"),
serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })?,
))?;
let response = execute(
client,
WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/subjektiv/memory"),
serde_json::to_string(&server_api::SubjektivMemoryBackendRequest { operation })?,
),
)?;
let status = reqwest::StatusCode::from_u16(response.status)
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
if !response.is_success() {
@@ -653,12 +679,17 @@ struct WorkspaceResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
}
struct WorkspaceSubjektivResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
#[derive(Default)]
struct SubjectBehaviorRefreshState {
applied: Option<(u64, String)>,
pending: HashMap<u64, String>,
}
const READY_EMPTY_RESIDENT_SURFACE: &str =
"The current ready resident memory surface is intentionally empty.";
struct WorkspaceSubjektivResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
behavior_state: Mutex<SubjectBehaviorRefreshState>,
}
fn resident_summary_contribution(
output: memory::backend::MemoryResidentSummaryOutput,
@@ -696,36 +727,133 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
}
}
impl WorkspaceSubjektivResidentSummarySource {
async fn fetch(
&self,
) -> Result<server_api::SubjektivResidentContextOutput, WorkspaceMemoryBackendError> {
match execute_subjektiv_memory_operation_with(
self.client.as_ref(),
server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
|client, request| {
client.execute_with_timeout(request, RESIDENT_SUMMARY_REQUEST_TIMEOUT)
},
)? {
server_api::SubjektivMemoryBackendResponse::ResidentContext(output) => Ok(output),
other => Err(WorkspaceMemoryBackendError::InvalidResponse(format!(
"unexpected subject resident context response: {other:?}"
))),
}
}
fn render(
&self,
output: &server_api::SubjektivResidentContextOutput,
) -> Result<String, String> {
use memory::backend::MemoryResidentSummaryAvailability as Availability;
let availability = match output.memory_surface.availability {
Availability::Ready => "ready",
Availability::Ungenerated => "ungenerated",
Availability::Stale => "stale",
Availability::Failed => "failed",
Availability::Unavailable => "unavailable",
};
self.prompts
.load()
.subjektiv_resident_context(
&output.behavior_md,
availability,
output.memory_surface.content.as_deref(),
)
.map_err(|error| error.to_string())
}
fn remember_behavior(&self, output: &server_api::SubjektivResidentContextOutput) {
let mut state = self
.behavior_state
.lock()
.expect("subject behavior state poisoned");
state.applied = Some((output.behavior_revision, output.behavior_md.clone()));
state.pending.remove(&output.behavior_revision);
}
}
#[async_trait]
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
async fn load(&self) -> SystemPromptContribution {
match execute_subjektiv_memory_operation(
self.client.as_ref(),
server_api::SubjektivMemoryBackendOperation::ResidentSummary(Default::default()),
)
.await
{
Ok(server_api::SubjektivMemoryBackendResponse::ResidentSummary(output)) => {
match resident_summary_contribution(output) {
SystemPromptContribution::Ready(content) if content.trim().is_empty() => {
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.to_string())
}
contribution => contribution,
match self.fetch().await {
Ok(output) => match self.render(&output) {
Ok(rendered) => {
self.remember_behavior(&output);
SystemPromptContribution::Ready(rendered)
}
}
Ok(other) => {
tracing::debug!(?other, "unexpected subject resident Memory response");
SystemPromptContribution::Unavailable
}
Err(error) => {
tracing::error!(%error, "subject resident context render failed");
SystemPromptContribution::Ready(
"The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
.to_string(),
)
}
},
Err(error) => {
tracing::debug!(%error, "subject resident Memory unavailable");
SystemPromptContribution::Unavailable
tracing::error!(%error, "subject resident context fetch failed");
SystemPromptContribution::Ready(
"The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory."
.to_string(),
)
}
}
}
async fn load_changed_resident_context(
&self,
) -> Result<Option<ResidentContextRefresh>, String> {
let output = self.fetch().await.map_err(|error| error.to_string())?;
let changed = self
.behavior_state
.lock()
.expect("subject behavior state poisoned")
.applied
.as_ref()
.is_none_or(|(revision, body)| {
*revision != output.behavior_revision || body != &output.behavior_md
});
if !changed {
return Ok(None);
}
let rendered = self.render(&output)?;
let revision = output.behavior_revision;
self.behavior_state
.lock()
.expect("subject behavior state poisoned")
.pending
.insert(revision, output.behavior_md);
Ok(Some(ResidentContextRefresh {
body: rendered,
revision,
}))
}
fn confirm_resident_context_revision(&self, revision: u64) {
let mut state = self
.behavior_state
.lock()
.expect("subject behavior state poisoned");
if let Some(body) = state.pending.remove(&revision) {
state.applied = Some((revision, body));
}
}
fn invalidate_resident_context_representation(&self) {
self.behavior_state
.lock()
.expect("subject behavior state poisoned")
.applied = None;
}
}
const SUBJEKTIV_RESTORE_REFRESH_HOOK: &str = "refresh-resident-surface-after-restore";
const SUBJEKTIV_BEHAVIOR_REFRESH_HOOK: &str = "refresh-subject-behavior-before-request";
const SUBJEKTIV_BEHAVIOR_REWRITE_HOOK: &str = "invalidate-subject-behavior-before-rewrite";
/// subjektiv-owned restore behavior. Initial prompt contribution remains a
/// shared host facility, while loading and rendering a post-restore refresh is
@@ -773,6 +901,14 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
SUBJEKTIV_RESTORE_REFRESH_HOOK,
FeatureHookPoint::WorkerRestored,
))
.with_hook(HookDeclaration::new(
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
FeatureHookPoint::PreLlmRequest,
))
.with_hook(HookDeclaration::new(
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
FeatureHookPoint::BeforeSessionRewrite,
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
@@ -784,10 +920,64 @@ impl FeatureModule for SubjektivResidentRestoreRefreshFeature {
prompts: Arc::clone(&self.prompts),
workspace_id: self.workspace_id.clone(),
},
)?;
context.hooks().add_pre_llm_request(
SUBJEKTIV_BEHAVIOR_REFRESH_HOOK,
HookExecutionPolicy::fail_closed(),
SubjektivBehaviorRefreshHook {
source: Arc::clone(&self.source),
},
)?;
context.hooks().add_before_session_rewrite(
SUBJEKTIV_BEHAVIOR_REWRITE_HOOK,
HookExecutionPolicy::fail_closed(),
SubjektivBehaviorRewriteHook {
source: Arc::clone(&self.source),
},
)
}
}
struct SubjektivBehaviorRewriteHook {
source: Arc<dyn SystemPromptContributionSource>,
}
#[async_trait]
impl Hook<BeforeSessionRewrite> for SubjektivBehaviorRewriteHook {
async fn call(
&self,
_context: &BeforeSessionRewriteContext,
) -> Result<BeforeSessionRewriteAction, HookError> {
self.source.invalidate_resident_context_representation();
Ok(BeforeSessionRewriteAction::Continue)
}
}
struct SubjektivBehaviorRefreshHook {
source: Arc<dyn SystemPromptContributionSource>,
}
#[async_trait]
impl Hook<PreLlmRequest> for SubjektivBehaviorRefreshHook {
async fn call(&self, context: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
let refresh = self
.source
.load_changed_resident_context()
.await
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error))?;
if let Some(refresh) = refresh {
let system_items = context.system_items().ok_or_else(|| {
HookError::new(
HookErrorCategory::Dependency,
"Subject behavior refresh requires durable session append authority",
)
})?;
system_items.append_subject_behavior_refresh(refresh.body, refresh.revision);
}
Ok(HookPreRequestAction::Continue)
}
}
struct SubjektivResidentRestoreRefreshHook {
source: Arc<dyn SystemPromptContributionSource>,
prompts: Arc<ArcSwap<PromptCatalog>>,
@@ -931,6 +1121,7 @@ pub(crate) fn ordinary_subjektiv_features_enabled(manifest: &manifest::WorkerMan
pub(crate) fn ordinary_subjektiv_resident_summary_source(
manifest: &manifest::WorkerManifest,
client: Arc<dyn WorkspaceClient>,
prompts: Arc<ArcSwap<PromptCatalog>>,
) -> std::io::Result<Option<Arc<dyn SystemPromptContributionSource>>> {
if !ordinary_subjektiv_features_enabled(manifest) {
return Ok(None);
@@ -968,6 +1159,8 @@ pub(crate) fn ordinary_subjektiv_resident_summary_source(
}
Ok(Some(Arc::new(WorkspaceSubjektivResidentSummarySource {
client,
prompts,
behavior_state: Mutex::new(SubjectBehaviorRefreshState::default()),
})))
}
@@ -1100,6 +1293,10 @@ mod tests {
use agen::tool::ToolDefinition;
use std::sync::Mutex;
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
}
fn test_client() -> Arc<dyn WorkspaceClient> {
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
"workspace",
@@ -1112,7 +1309,9 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability,
content: Option<String>,
scope_allowed: bool,
behavior: Mutex<(u64, String)>,
paths: Mutex<Vec<String>>,
timeouts: Mutex<Vec<Duration>>,
}
impl SubjectResidentClient {
@@ -1124,7 +1323,9 @@ mod tests {
availability,
content: content.map(str::to_string),
scope_allowed: true,
behavior: Mutex::new((3, "Be deliberate.".to_string())),
paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()),
}
}
@@ -1133,7 +1334,9 @@ mod tests {
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
content: Some("must not be injected".into()),
scope_allowed: false,
behavior: Mutex::new((3, "must not be injected".to_string())),
paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()),
}
}
}
@@ -1161,7 +1364,7 @@ mod tests {
serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap();
assert!(matches!(
request.operation,
server_api::SubjektivMemoryBackendOperation::ResidentSummary(_)
server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
));
if !self.scope_allowed {
return Ok(WorkspaceResponse {
@@ -1169,19 +1372,33 @@ mod tests {
body: "subject scope unavailable".into(),
});
}
let (behavior_revision, behavior_md) = self.behavior.lock().unwrap().clone();
Ok(WorkspaceResponse {
status: 200,
body: serde_json::to_string(
&server_api::SubjektivMemoryBackendResponse::ResidentSummary(
memory::backend::MemoryResidentSummaryOutput {
availability: self.availability,
content: self.content.clone(),
&server_api::SubjektivMemoryBackendResponse::ResidentContext(
server_api::SubjektivResidentContextOutput {
behavior_md,
behavior_revision,
memory_surface: memory::backend::MemoryResidentSummaryOutput {
availability: self.availability,
content: self.content.clone(),
},
},
),
)
.unwrap(),
})
}
fn execute_with_timeout(
&self,
request: WorkspaceRequest,
timeout: Duration,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.timeouts.lock().unwrap().push(timeout);
self.execute(request)
}
}
fn resident_client(content: &str) -> Arc<dyn WorkspaceClient> {
@@ -1479,29 +1696,44 @@ permission = "write"
memory::backend::MemoryResidentSummaryAvailability::Ready,
Some("current subject surface"),
));
let ready = ordinary_subjektiv_resident_summary_source(&manifest, ready_client.clone())
.unwrap()
.unwrap();
assert_eq!(
ready.load().await,
SystemPromptContribution::Ready("current subject surface".into())
);
let ready = ordinary_subjektiv_resident_summary_source(
&manifest,
ready_client.clone(),
test_prompts(),
)
.unwrap()
.unwrap();
let loaded = ready.load().await;
let SystemPromptContribution::Ready(loaded) = loaded else {
panic!("subject resident context must be present");
};
assert!(loaded.contains("Be deliberate."));
assert!(loaded.contains("current subject surface"));
assert_eq!(
ready_client.paths.lock().unwrap().clone(),
vec!["/api/w/workspace/subjektiv/memory".to_string()]
);
assert_eq!(
ready_client.timeouts.lock().unwrap().as_slice(),
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT]
);
let ready_empty_client = Arc::new(SubjectResidentClient::new(
memory::backend::MemoryResidentSummaryAvailability::Ready,
None,
));
let ready_empty = ordinary_subjektiv_resident_summary_source(&manifest, ready_empty_client)
.unwrap()
.unwrap();
assert_eq!(
ready_empty.load().await,
SystemPromptContribution::Ready(READY_EMPTY_RESIDENT_SURFACE.into())
);
let ready_empty = ordinary_subjektiv_resident_summary_source(
&manifest,
ready_empty_client,
test_prompts(),
)
.unwrap()
.unwrap();
let SystemPromptContribution::Ready(ready_empty) = ready_empty.load().await else {
panic!("empty surface must retain Subject behavior");
};
assert!(ready_empty.contains("Be deliberate."));
assert!(ready_empty.contains("intentionally empty"));
for availability in [
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
@@ -1514,22 +1746,107 @@ permission = "write"
availability,
Some("must not be injected"),
)),
test_prompts(),
)
.unwrap()
.unwrap();
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
let SystemPromptContribution::Ready(loaded) = source.load().await else {
panic!("Subject behavior must survive unavailable surfaces");
};
assert!(loaded.contains("Be deliberate."));
assert!(!loaded.contains("must not be injected"));
}
}
#[tokio::test]
async fn subject_resident_source_emits_only_changed_behavior_revisions() {
let manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::new(
memory::backend::MemoryResidentSummaryAvailability::Ungenerated,
None,
));
let source =
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
let initial = source.load().await;
assert!(matches!(initial, SystemPromptContribution::Ready(_)));
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_none()
);
*client.behavior.lock().unwrap() = (4, "Ask when uncertain.".to_string());
let refresh = source
.load_changed_resident_context()
.await
.unwrap()
.expect("changed behavior must produce one refresh");
assert_eq!(refresh.revision, 4);
assert!(refresh.body.contains("Ask when uncertain."));
source.confirm_resident_context_revision(refresh.revision);
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_none()
);
*client.behavior.lock().unwrap() = (5, String::new());
let cleared = source
.load_changed_resident_context()
.await
.unwrap()
.expect("cleared behavior must produce one refresh");
assert_eq!(cleared.revision, 5);
assert!(cleared.body.contains("No user-managed behavior is set"));
source.confirm_resident_context_revision(cleared.revision);
assert!(
source
.load_changed_resident_context()
.await
.unwrap()
.is_none()
);
source.invalidate_resident_context_representation();
let replay_after_rewrite = source
.load_changed_resident_context()
.await
.unwrap()
.expect("history rewrite must require a durable current representation");
assert_eq!(replay_after_rewrite.revision, 5);
assert!(
replay_after_rewrite
.body
.contains("No user-managed behavior is set")
);
// Initial load and every live update/clear/replay probe retain the
// target-side bounded resident request contract.
assert_eq!(
client.timeouts.lock().unwrap().as_slice(),
&[RESIDENT_SUMMARY_REQUEST_TIMEOUT; 7]
);
}
#[tokio::test]
async fn subject_resident_source_fails_closed_without_trusted_subject_scope() {
let manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::denied());
let source = ordinary_subjektiv_resident_summary_source(&manifest, client.clone())
.unwrap()
.unwrap();
let source =
ordinary_subjektiv_resident_summary_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
let SystemPromptContribution::Ready(failure) = source.load().await else {
panic!("fetch failure must be explicit in resident context");
};
assert!(failure.contains("could not fetch"));
assert_eq!(
client.paths.lock().unwrap().clone(),
vec!["/api/w/workspace/subjektiv/memory".to_string()]
@@ -1557,7 +1874,7 @@ permission = "write"
)
.unwrap();
assert!(
ordinary_subjektiv_resident_summary_source(&disabled, test_client())
ordinary_subjektiv_resident_summary_source(&disabled, test_client(), test_prompts())
.unwrap()
.is_none()
);
@@ -1569,22 +1886,30 @@ permission = "write"
Some("policy-only surface must not load"),
));
assert!(
ordinary_subjektiv_resident_summary_source(&policy_only, unattached_client.clone())
.unwrap()
.is_none()
ordinary_subjektiv_resident_summary_source(
&policy_only,
unattached_client.clone(),
test_prompts()
)
.unwrap()
.is_none()
);
assert!(unattached_client.paths.lock().unwrap().is_empty());
let foreign = subject_manifest("other-workspace");
assert!(ordinary_subjektiv_resident_summary_source(&foreign, test_client()).is_err());
assert!(
ordinary_subjektiv_resident_summary_source(&foreign, test_client(), test_prompts())
.is_err()
);
}
#[test]
fn subject_restore_refresh_is_suppressed_for_internal_hosts() {
let manifest = subject_manifest("workspace");
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
.unwrap()
.unwrap();
let source =
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
.unwrap()
.unwrap();
let prompts = Arc::new(ArcSwap::from(
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
));
@@ -1598,9 +1923,10 @@ permission = "write"
#[test]
fn subject_restore_refresh_feature_declares_only_restore_lifecycle() {
let manifest = subject_manifest("workspace");
let source = ordinary_subjektiv_resident_summary_source(&manifest, test_client())
.unwrap()
.unwrap();
let source =
ordinary_subjektiv_resident_summary_source(&manifest, test_client(), test_prompts())
.unwrap()
.unwrap();
let feature = SubjektivResidentRestoreRefreshFeature::new(
source,
Arc::new(ArcSwap::from(
@@ -1611,8 +1937,13 @@ permission = "write"
let descriptor = feature.descriptor();
assert!(descriptor.tools.is_empty());
assert_eq!(descriptor.hooks.len(), 1);
assert_eq!(descriptor.hooks.len(), 3);
assert_eq!(descriptor.hooks[0].point, FeatureHookPoint::WorkerRestored);
assert_eq!(descriptor.hooks[1].point, FeatureHookPoint::PreLlmRequest);
assert_eq!(
descriptor.hooks[2].point,
FeatureHookPoint::BeforeSessionRewrite
);
}
#[test]
@@ -5,9 +5,9 @@ use crate::feature::{
};
use crate::permission::permission_action_for;
use crate::wip::{
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -529,7 +529,7 @@ fn native_merge_request_tools(
let (meta, tool) = definition(client.clone(), kind)();
jsonschema::validator_for(&meta.input_schema).map_err(|error| {
WipMountError::InvalidProjection {
route: "/features/merge-request/merge-requests".into(),
route: "/merge-requests".into(),
message: format!("Merge Request tool schema cannot be retained: {error}"),
}
})?;
@@ -562,9 +562,9 @@ pub fn mount_workspace_http_merge_request_wip(
client: Arc<dyn WorkspaceClient>,
config: MergeRequestFeatureConfig,
permissions: Option<ToolPermissionConfig>,
feature_route: &WipFeatureRoute,
namespace_route: &WipNamespaceRoute,
) -> Result<(), WipMountError> {
let collection_route = feature_route.child("merge-requests")?;
let collection_route = namespace_route.root().to_string();
let tools = native_merge_request_tools(client, config)?;
let claimed_tools = tools
.iter()
@@ -1049,7 +1049,7 @@ fn merge_request_operation_declaration(
.get("properties")
.and_then(Value::as_object)
.ok_or_else(|| WipMountError::InvalidProjection {
route: "/features/merge-request/merge-requests".into(),
route: "/merge-requests".into(),
message: format!(
"Merge Request tool `{}` schema has no properties",
tool.name
@@ -1474,7 +1474,9 @@ mod tests {
#[test]
fn native_mount_uses_host_route_and_accepts_only_one_canonical_item_segment() {
let mut registry = WipMountRegistry::new();
let feature_route = registry.allocate_feature_route("merge-request").unwrap();
let namespace_route = registry
.allocate_namespace("merge-request", "merge-requests")
.unwrap();
mount_workspace_http_merge_request_wip(
&mut registry,
Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused")),
@@ -1484,17 +1486,14 @@ mod tests {
..Default::default()
},
None,
&feature_route,
&namespace_route,
)
.unwrap();
assert_eq!(
registry.routes().collect::<Vec<_>>(),
["/features/merge-request/merge-requests"]
);
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/merge-requests"]);
let resolver = MergeRequestItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/merge-request/merge-requests".into(),
collection_route: "/merge-requests".into(),
revisions: Arc::new(Mutex::new(MergeRequestRevisionState::default())),
};
assert!(
@@ -1645,7 +1644,7 @@ mod tests {
let resolver = MergeRequestItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/merge-request/merge-requests".into(),
collection_route: "/merge-requests".into(),
revisions: Arc::clone(&revisions),
};
let first = resolver.resolve("MR-1").unwrap().object.validator;
+21 -24
View File
@@ -21,9 +21,9 @@ use wip_protocol::{
use crate::permission::permission_action_for;
use crate::wip::{
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
@@ -551,9 +551,9 @@ pub fn mount_workspace_http_objective_wip(
registry: &mut WipMountRegistry,
client: Arc<dyn WorkspaceClient>,
permissions: Option<ToolPermissionConfig>,
feature_route: &WipFeatureRoute,
namespace_route: &WipNamespaceRoute,
) -> Result<(), WipMountError> {
let collection_route = feature_route.child("objectives")?;
let collection_route = namespace_route.root().to_string();
let backend = WorkspaceHttpObjectiveBackend::new(client);
let revisions = Arc::new(Mutex::new(ObjectiveRevisionState::default()));
let collection_descriptor = objective_collection_descriptor();
@@ -1775,7 +1775,9 @@ mod tests {
}));
let mut registry = WipMountRegistry::new();
let feature_route = registry.allocate_feature_route("objective").unwrap();
let namespace_route = registry
.allocate_namespace("objective", "objectives")
.unwrap();
mount_workspace_http_objective_wip(
&mut registry,
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
@@ -1783,18 +1785,16 @@ mod tests {
"http://backend",
)),
None,
&feature_route,
&namespace_route,
)
.unwrap();
assert_eq!(
registry.routes().collect::<Vec<_>>(),
["/features/objective/objectives"]
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/objectives"]);
assert_eq!(namespace_route.root(), "/objectives");
assert!(
registry
.allocate_namespace("objective", "../tickets")
.is_err()
);
assert_eq!(
feature_route.child("objectives").unwrap(),
"/features/objective/objectives"
);
assert!(feature_route.child("../tickets").is_err());
}
#[test]
@@ -1805,7 +1805,7 @@ mod tests {
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
)),
permissions: None,
collection_route: "/features/objective/objectives".into(),
collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
};
let initial = resolver.resolve("O-3").unwrap();
@@ -1971,7 +1971,7 @@ mod tests {
let collection = ObjectiveCollectionWipHandler {
backend,
permissions: None,
collection_route: "/features/objective/objectives".into(),
collection_route: "/objectives".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
};
let result = collection
@@ -2013,7 +2013,7 @@ mod tests {
let collection = ObjectiveCollectionWipHandler {
backend: backend.clone(),
permissions: None,
collection_route: "/features/objective/objectives".into(),
collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
};
let context = || WipCallContext {
@@ -2170,7 +2170,7 @@ mod tests {
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
)),
permissions: None,
collection_route: "/features/objective/objectives".into(),
collection_route: "/objectives".into(),
revisions: Arc::new(Mutex::new(ObjectiveRevisionState::default())),
};
let output = match handler
@@ -2191,10 +2191,7 @@ mod tests {
let output = crate::wip::wip_to_json(&output.value).unwrap();
assert_eq!(output["next_cursor"], "next-page");
assert_eq!(output["has_more"], true);
assert_eq!(
output["objectives"][0]["path"],
"/features/objective/objectives/O-3"
);
assert_eq!(output["objectives"][0]["path"], "/objectives/O-3");
assert_eq!(output["objectives"][0]["linked_tickets"][0], "T-7");
assert!(!output.to_string().contains("00001OBJECTIVE"));
assert!(!output.to_string().contains("00001TICKET"));
@@ -2243,7 +2240,7 @@ mod tests {
let resolver = ObjectiveItemResolver {
backend: backend.clone(),
permissions: None,
collection_route: "/features/objective/objectives".into(),
collection_route: "/objectives".into(),
revisions: Arc::clone(&revisions),
};
let before = resolver.resolve("O-3").unwrap().object.validator;
+18 -17
View File
@@ -34,9 +34,9 @@ use crate::feature::{
};
use crate::permission::permission_action_for;
use crate::wip::{
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipFeatureRoute,
WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, WipOperationOutput,
WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
WipCallContext, WipDynamicItem, WipDynamicItemResolver, WipDynamicMount, WipMountError,
WipMountRegistry, WipNamespaceRoute, WipOperationError, WipOperationHandler,
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
@@ -1377,7 +1377,7 @@ fn native_ticket_tools(
let (meta, tool) = definition();
let projection = native_ticket_operation(&meta.name).ok_or_else(|| {
WipMountError::InvalidProjection {
route: "/features/ticket/tickets".into(),
route: "/tickets".into(),
message: format!(
"enabled Ticket tool `{}` has no native projection",
meta.name
@@ -1386,7 +1386,7 @@ fn native_ticket_tools(
})?;
jsonschema::validator_for(&meta.input_schema).map_err(|error| {
WipMountError::InvalidProjection {
route: "/features/ticket/tickets".into(),
route: "/tickets".into(),
message: format!("Ticket tool schema cannot be retained: {error}"),
}
})?;
@@ -1418,9 +1418,9 @@ pub fn mount_workspace_http_ticket_wip(
client: Arc<dyn WorkspaceClient>,
access: TicketFeatureAccess,
permissions: Option<ToolPermissionConfig>,
feature_route: &WipFeatureRoute,
namespace_route: &WipNamespaceRoute,
) -> Result<(), WipMountError> {
let collection_route = feature_route.child("tickets")?;
let collection_route = namespace_route.root().to_string();
let tools = native_ticket_tools(client, access)?;
let claimed_tools = tools
.iter()
@@ -1920,7 +1920,7 @@ fn ticket_operation_declaration(
.get("properties")
.and_then(Value::as_object)
.ok_or_else(|| WipMountError::InvalidProjection {
route: "/features/ticket/tickets".into(),
route: "/tickets".into(),
message: format!("Ticket tool `{}` schema has no properties", tool.name),
})?;
let required = tool
@@ -2103,7 +2103,7 @@ mod tests {
#[test]
fn native_ticket_mount_uses_host_route_and_rejects_non_ticket_children() {
let mut registry = WipMountRegistry::new();
let feature_route = registry.allocate_feature_route("ticket").unwrap();
let namespace_route = registry.allocate_namespace("ticket", "tickets").unwrap();
mount_workspace_http_ticket_wip(
&mut registry,
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
@@ -2112,20 +2112,21 @@ mod tests {
)),
TicketFeatureAccess::review(),
None,
&feature_route,
&namespace_route,
)
.unwrap();
assert_eq!(
registry.routes().collect::<Vec<_>>(),
["/features/ticket/tickets"]
assert_eq!(registry.routes().collect::<Vec<_>>(), ["/tickets"]);
assert!(
registry
.allocate_namespace("ticket", "../objectives")
.is_err()
);
assert!(feature_route.child("../objectives").is_err());
let revisions = Arc::new(Mutex::new(TicketRevisionState::default()));
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/ticket/tickets".into(),
collection_route: "/tickets".into(),
revisions,
};
assert!(resolver.resolve("T-42").is_some());
@@ -2189,7 +2190,7 @@ mod tests {
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/ticket/tickets".into(),
collection_route: "/tickets".into(),
revisions: Arc::clone(&revisions),
};
let unobserved_internal = resolver.resolve("00001TICKET").unwrap().object.validator;
@@ -2243,7 +2244,7 @@ mod tests {
let resolver = TicketItemResolver {
tools: HashMap::new(),
permissions: None,
collection_route: "/features/ticket/tickets".into(),
collection_route: "/tickets".into(),
revisions: Arc::clone(&revisions),
};
let target_v1 = resolver.resolve("T-43").unwrap().object.validator;
+14
View File
@@ -281,6 +281,20 @@ impl SystemItemAppendHandle {
.expect("system-item append queue poisoned")
.push(item);
}
/// Queue a revisioned Subject behavior refresh. The caller receives this
/// narrow operation only at a pre-request boundary, and the host commits it
/// before exposing it to the model.
pub fn append_subject_behavior_refresh(&self, body: impl Into<String>, behavior_revision: u64) {
self.pending
.lock()
.expect("system-item append queue poisoned")
.push(SystemItem::SubjectBehaviorRefresh {
body: body.into(),
behavior_revision,
prompt_provenance: None,
});
}
}
/// Host-created narrow append authority exposed only at a restore lifecycle
+221
View File
@@ -0,0 +1,221 @@
//! Recovery tests use the normal Worker execution and append-log commit gate.
use super::*;
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, Request, ResponseStream};
use agen::tool::{Attachment, ImageAttachment};
use async_trait::async_trait;
#[derive(Clone, Default)]
struct RejectImages {
requests: Arc<Mutex<Vec<Request>>>,
cancel_on_retry: Arc<Mutex<Option<tokio::sync::mpsc::Sender<()>>>>,
}
#[async_trait]
impl LlmClient for RejectImages {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
let has_image = request.items.iter().any(
|item| matches!(item, Item::ToolResult { attachments, .. } if !attachments.is_empty()),
);
self.requests.lock().unwrap().push(request);
if has_image {
return Err(ClientError::Api {
status: Some(400),
code: Some("invalid_value".into()),
message: "The image requires 52150 patches, exceeding the limit of 30000.".into(),
retry_after: None,
});
}
if let Some(cancel) = self.cancel_on_retry.lock().unwrap().take() {
cancel.try_send(()).unwrap();
return Ok(Box::pin(futures::stream::pending()));
}
Ok(Box::pin(futures::stream::iter(vec![
Ok(LlmEvent::text_block_start(0)),
Ok(LlmEvent::text_delta(0, "Resize the source image")),
Ok(LlmEvent::text_block_stop(0, None)),
Ok(LlmEvent::Status(StatusEvent {
status: ResponseStatus::Completed,
})),
])))
}
}
async fn image_worker() -> (
tempfile::TempDir,
Worker<RejectImages, session_store::FsStore>,
RejectImages,
) {
let dir = tempfile::tempdir().unwrap();
let store = session_store::FsStore::new(dir.path().join("session")).unwrap();
let manifest = WorkerManifest::from_toml(
r#"
[worker]
name = "image-recovery-test"
[model]
scheme = "anthropic"
model_id = "test"
[engine]
[scope]
allow = []
"#,
)
.unwrap();
let client = RejectImages::default();
let mut worker = Worker::new(
manifest,
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone()),
store,
WorkerWorkspaceContext::local_filesystem(None),
WorkerFilesystemAuthority::None,
Scope::writable(dir.path()).unwrap(),
)
.await
.unwrap();
worker.disable_manifest_lifecycle_features();
worker.set_history_for_test(vec![
Item::user_message("inspect this image"),
Item::tool_call("image-call", "ViewImage", "{}"),
Item::tool_result_item_with_disposition_and_attachments(
"image-call",
"Attached image",
None,
agen::ToolResultDisposition::Success,
vec![Attachment::Image(ImageAttachment::new(
"image/png",
vec![1_u8, 2, 3],
))],
),
]);
worker.wire_history_persistence();
(dir, worker, client)
}
fn rejected(item: &Item) -> bool {
matches!(item, Item::ToolResult { call_id, is_error: true, attachments, .. } if call_id == "image-call" && attachments.is_empty())
}
#[tokio::test]
async fn image_correction_is_committed_before_retry_and_survives_restore_and_reseed() {
let (_dir, mut worker, client) = image_worker().await;
assert_eq!(
worker.run_text("continue").await.unwrap(),
WorkerRunResult::Finished
);
assert_eq!(client.requests.lock().unwrap().len(), 2);
let raw = worker
.store
.read_all(worker.session_id(), worker.segment_id())
.unwrap();
let correction = raw
.iter()
.find(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
.unwrap()
.clone();
let restored = restore_history_entries(worker.session_id(), worker.segment_id(), &raw).unwrap();
assert_eq!(
restored
.iter()
.filter(|entry| rejected(&entry.item))
.count(),
1
);
assert_eq!(
session_store::collect_state(&raw)
.history
.iter()
.filter(|item| rejected(item))
.count(),
1
);
let LogEntry::ToolResultCorrected {
entry: corrected, ..
} = &correction
else {
unreachable!()
};
let original = match &raw[0] {
LogEntry::AnnotatedSegmentStart { history, .. } => history
.iter()
.find(|entry| entry.metadata.entry_id == corrected.metadata.entry_id)
.unwrap(),
_ => panic!("missing seed"),
};
assert!(
matches!(&original.item, session_store::LoggedItem::ToolResult { attachments, is_error: false, .. } if attachments.len() == 1)
);
assert_eq!(
restored
.iter()
.find(|entry| rejected(&entry.item))
.unwrap()
.annotation,
original.metadata
);
let snapshot = session_store::public_snapshot::project_current_session_snapshot(&raw);
let projected = snapshot
.entries
.iter()
.find(|entry| entry.entry_id == corrected.metadata.entry_id.0)
.unwrap();
assert!(
matches!(&projected.data, protocol::SessionSnapshotEntryData::ToolResult { is_error: true, attachments, .. } if attachments.is_empty())
);
assert!(matches!(
crate::ipc::protocol_session::live_log_entry_event(correction),
Some(Event::SessionEntryCommitted { .. })
));
// Fork/compact seed writers serialize the effective annotated history.
let seed = LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id: worker.session_id(),
system_prompt: None,
config: agen::llm_client::RequestConfig::default(),
history: restored.iter().map(to_logged_history_entry).collect(),
forked_from: None,
compacted_from: None,
};
let restored_again =
restore_history_entries(worker.session_id(), worker.segment_id(), &[seed]).unwrap();
assert_eq!(
restored_again
.iter()
.filter(|entry| rejected(&entry.item))
.count(),
1
);
let mut history = History::from_entries(restored_again);
let out = Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone())
.run_with_annotation(&mut history, "continue after restart", &mut |_| {
Ok(SessionHistoryMetadata::legacy_unknown())
})
.await;
assert!(matches!(out.result, EngineRunExit::Finished));
assert_eq!(client.requests.lock().unwrap().len(), 3);
}
#[tokio::test]
async fn cancellation_after_image_correction_does_not_rollback_the_correction() {
let (_dir, mut worker, client) = image_worker().await;
*client.cancel_on_retry.lock().unwrap() = Some(worker.engine().cancel_sender());
let result = worker.run_text("continue").await.unwrap();
assert_eq!(result, WorkerRunResult::Cancelled);
let raw = worker
.store
.read_all(worker.session_id(), worker.segment_id())
.unwrap();
assert!(
raw.iter()
.any(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
);
let restored = restore_history_entries(worker.session_id(), worker.segment_id(), &raw).unwrap();
assert_eq!(
restored
.iter()
.filter(|entry| rejected(&entry.item))
.count(),
1
);
}
+217 -22
View File
@@ -38,7 +38,9 @@ use crate::hook::{
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
use crate::prompt::catalog::PromptCatalog;
use crate::session_history::SessionHistoryMetadata;
use crate::worker::SystemItemCommitter;
use crate::worker::{
DurableNotificationCommitter, SystemItemCommitter, SystemPromptContributionSource,
};
use agen::HistoryEntry;
use agen::token_counter::total_tokens;
@@ -109,6 +111,8 @@ pub(crate) struct WorkerInterceptor {
/// worker. `None` in tests / `Worker::new` paths where no writer is
/// attached.
log_writer: Option<Arc<dyn SystemItemCommitter>>,
notification_committer: Option<Arc<dyn DurableNotificationCommitter>>,
resident_context_source: Option<Arc<dyn SystemPromptContributionSource>>,
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
/// Next turn index assigned by `on_prompt_submit`.
next_turn_index: AtomicUsize,
@@ -163,12 +167,22 @@ impl WorkerInterceptor {
prompts,
prompt_workspace_id: None,
log_writer,
notification_committer: None,
resident_context_source: None,
pending_committed_history,
next_turn_index: AtomicUsize::new(0),
tool_calls_this_turn: AtomicUsize::new(0),
}
}
pub(crate) fn with_notification_committer(
mut self,
committer: Option<Arc<dyn DurableNotificationCommitter>>,
) -> Self {
self.notification_committer = committer;
self
}
pub(crate) fn with_usage_tracker(mut self, usage_tracker: Arc<UsageTracker>) -> Self {
self.usage_tracker = Some(usage_tracker);
self
@@ -179,11 +193,31 @@ impl WorkerInterceptor {
self
}
pub(crate) fn with_resident_context_source(
mut self,
source: Option<Arc<dyn SystemPromptContributionSource>>,
) -> Self {
self.resident_context_source = source;
self
}
pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option<String>) -> Self {
self.prompt_workspace_id = workspace_id;
self
}
fn confirm_resident_context_item(&self, item: &SystemItem) {
let Some(source) = self.resident_context_source.as_ref() else {
return;
};
if let SystemItem::SubjectBehaviorRefresh {
behavior_revision, ..
} = item
{
source.confirm_resident_context_revision(*behavior_revision);
}
}
/// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem`
/// entry through the attached writer (no-op when no writer is
/// wired). Sync — writes complete before the matching
@@ -211,6 +245,7 @@ impl WorkerInterceptor {
.lock()
.expect("pending committed history poisoned")
.push_back(entry);
self.confirm_resident_context_item(item);
}
Ok(())
}
@@ -350,6 +385,11 @@ impl WorkerInterceptor {
} if prompt_provenance.is_none() => {
*prompt_provenance = Some(provenance("internal.task_reminder"));
}
SystemItem::SubjectBehaviorRefresh {
prompt_provenance, ..
} if prompt_provenance.is_none() => {
*prompt_provenance = Some(provenance("internal.subjektiv_resident_context"));
}
SystemItem::Interrupt {
prompt_provenance, ..
} if prompt_provenance.is_none() => {
@@ -452,19 +492,10 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
projection_digest: projection.catalog_digest.clone(),
logical_name: "internal.notify_wrapper".to_string(),
};
let mut system_items: Vec<(
SystemItem,
Vec<session_store::SessionExtension>,
Option<session_store::LoggedSessionHistoryOrigin>,
)> = Vec::with_capacity(drained.len());
let mut items: Vec<Item> = Vec::with_capacity(drained.len());
let mut system_items = Vec::with_capacity(drained.len());
for entry in &drained {
let system_item = match build_system_item_with_provenance(
entry,
&prompts,
Some(provenance.clone()),
) {
Ok(system_item) => system_item,
match build_system_item_with_provenance(entry, &prompts, Some(provenance.clone())) {
Ok(item) => system_items.push(item),
Err(error) => {
self.pending_notifies.requeue_front(drained);
return Err(InterceptorError::new(
@@ -472,16 +503,50 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
format!("failed to render notify_wrapper: {error}"),
));
}
};
items.push(system_item.to_history_item());
system_items.push((system_item, entry.extensions(), entry.history_provenance()));
}
}
if let Err(error) = self.commit_system_items_with_extensions(&system_items) {
self.pending_notifies.requeue_front(drained);
return Err(InterceptorError::new(
InterceptorErrorCategory::Dependency,
format!("session persistence failed: {error}"),
));
let mut items = Vec::with_capacity(drained.len());
let mut remaining = drained.into_iter();
for item in system_items {
let entry = remaining.next().expect("rendered notification must exist");
let committed = if let Some(identity) = entry.notification_commit() {
match self.notification_committer.as_ref() {
Some(committer) => {
committer.commit_notification(identity, item, entry.history_provenance())
}
None => Err(session_store::StoreError::Io(std::io::Error::other(
"durable notification commit authority is missing",
))),
}
} else {
self.commit_system_items_with_extensions(&[(
item.clone(),
Vec::new(),
entry.history_provenance(),
)])
.map(|()| {
items.push(item.to_history_item());
None
})
};
match committed {
Ok(Some(history)) => {
items.push(history.item.clone());
self.pending_committed_history
.lock()
.expect("pending committed history poisoned")
.push_back(history);
}
Ok(None) => {}
Err(error) => {
self.pending_notifies
.requeue_front(std::iter::once(entry).chain(remaining).collect());
return Err(InterceptorError::new(
InterceptorErrorCategory::Dependency,
format!("session persistence failed: {error}"),
));
}
}
}
Ok(items)
}
@@ -781,6 +846,7 @@ mod tests {
HookTurnEndAction, OnTurnEnd, PostToolCall, PreLlmRequest, PreToolCall,
};
use crate::session_history::{WorkerHistoryProvenance, history_entry};
use crate::worker::SystemPromptContribution;
fn test_prompts() -> Arc<ArcSwap<PromptCatalog>> {
Arc::new(ArcSwap::from(PromptCatalog::builtins_only().unwrap()))
@@ -877,6 +943,53 @@ mod tests {
}
}
struct AppendingSubjectBehaviorHook;
#[async_trait]
impl Hook<PreLlmRequest> for AppendingSubjectBehaviorHook {
async fn call(
&self,
input: &PreRequestContext,
) -> Result<HookPreRequestAction, crate::hook::HookError> {
input
.system_items()
.expect("test interceptor has durable append authority")
.append_subject_behavior_refresh("current Subject context", 7);
Ok(HookPreRequestAction::Continue)
}
}
struct ConfirmRecordingResidentSource {
confirmed: Arc<Mutex<Vec<u64>>>,
}
#[async_trait]
impl SystemPromptContributionSource for ConfirmRecordingResidentSource {
async fn load(&self) -> SystemPromptContribution {
SystemPromptContribution::Unavailable
}
fn confirm_resident_context_revision(&self, revision: u64) {
self.confirmed.lock().unwrap().push(revision);
}
}
struct FailingSystemItemCommitter;
impl SystemItemCommitter for FailingSystemItemCommitter {
fn commit_log_entry(
&self,
entry: session_store::LogEntry,
) -> Result<(), session_store::StoreError> {
if matches!(entry, session_store::LogEntry::AnnotatedSystemItem { .. }) {
return Err(session_store::StoreError::Io(std::io::Error::other(
"synthetic system-item failure",
)));
}
Ok(())
}
}
fn task_tool_call_info(
name: &str,
input: serde_json::Value,
@@ -1364,6 +1477,88 @@ mod tests {
}
}
#[tokio::test]
async fn subject_behavior_revision_is_confirmed_only_after_durable_commit() {
let mut builder = HookRegistryBuilder::new();
builder.add_pre_llm_request(AppendingSubjectBehaviorHook);
let committed = Arc::new(Mutex::new(Vec::new()));
let confirmed = Arc::new(Mutex::new(Vec::new()));
let source: Arc<dyn SystemPromptContributionSource> =
Arc::new(ConfirmRecordingResidentSource {
confirmed: Arc::clone(&confirmed),
});
let interceptor = WorkerInterceptor::new(
Arc::new(builder.build()),
None,
None,
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
test_prompts(),
Some(Arc::new(RecordingSystemItemCommitter {
committed: Arc::clone(&committed),
})),
)
.with_resident_context_source(Some(source));
let mut ctx = Vec::new();
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(action, PreRequestAction::ContinueWith(_)));
assert_eq!(*confirmed.lock().unwrap(), [7]);
assert!(matches!(
committed.lock().unwrap().as_slice(),
[SystemItem::SubjectBehaviorRefresh {
behavior_revision: 7,
prompt_provenance: Some(_),
..
}]
));
}
#[tokio::test]
async fn failed_subject_behavior_persistence_does_not_confirm_revision() {
let mut builder = HookRegistryBuilder::new();
builder.add_pre_llm_request(AppendingSubjectBehaviorHook);
let confirmed = Arc::new(Mutex::new(Vec::new()));
let source: Arc<dyn SystemPromptContributionSource> =
Arc::new(ConfirmRecordingResidentSource {
confirmed: Arc::clone(&confirmed),
});
let interceptor = WorkerInterceptor::new(
Arc::new(builder.build()),
None,
None,
NotifyBuffer::new(),
Arc::new(Mutex::new(Vec::new())),
test_prompts(),
Some(Arc::new(FailingSystemItemCommitter)),
)
.with_resident_context_source(Some(source));
let mut ctx = Vec::new();
let action = interceptor
.pre_llm_request(PreLlmRequestContext {
invocation: Default::default(),
items: &mut ctx,
history: &[],
})
.await
.unwrap();
assert!(matches!(
action,
PreRequestAction::Cancel(reason) if reason.contains("session persistence failed")
));
assert!(confirmed.lock().unwrap().is_empty());
}
#[tokio::test]
async fn pre_llm_request_without_log_writer_does_not_expose_system_item_handle() {
let saw_handle = Arc::new(AtomicBool::new(false));
+13 -9
View File
@@ -24,8 +24,9 @@
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use crate::worker::NotificationCommitIdentity;
use protocol::WorkerEvent;
use session_store::{LoggedSessionHistoryOrigin, SessionExtension, SystemItem};
use session_store::{LoggedSessionHistoryOrigin, SystemItem};
use crate::prompt::catalog::{CatalogError, PromptCatalog};
@@ -39,7 +40,7 @@ use crate::prompt::catalog::{CatalogError, PromptCatalog};
pub enum PendingNotify {
Notify {
message: String,
extensions: Vec<SessionExtension>,
notification_commit: Option<NotificationCommitIdentity>,
history_provenance: Option<LoggedSessionHistoryOrigin>,
},
WorkerEvent {
@@ -48,10 +49,13 @@ pub enum PendingNotify {
}
impl PendingNotify {
pub(crate) fn extensions(&self) -> Vec<SessionExtension> {
pub(crate) fn notification_commit(&self) -> Option<&NotificationCommitIdentity> {
match self {
PendingNotify::Notify { extensions, .. } => extensions.clone(),
PendingNotify::WorkerEvent { .. } => Vec::new(),
PendingNotify::Notify {
notification_commit,
..
} => notification_commit.as_ref(),
PendingNotify::WorkerEvent { .. } => None,
}
}
@@ -85,7 +89,7 @@ impl NotifyBuffer {
pub fn push_notify(&self, message: String) {
self.push_entry(PendingNotify::Notify {
message,
extensions: Vec::new(),
notification_commit: None,
history_provenance: None,
});
}
@@ -94,11 +98,11 @@ impl NotifyBuffer {
&self,
message: String,
history_provenance: LoggedSessionHistoryOrigin,
extension: SessionExtension,
identity: NotificationCommitIdentity,
) {
self.push_entry(PendingNotify::Notify {
message,
extensions: vec![extension],
notification_commit: Some(identity),
history_provenance: Some(history_provenance),
});
}
@@ -233,7 +237,7 @@ mod tests {
fn build_system_item_for_notify_carries_wrapper_body() {
let entry = PendingNotify::Notify {
message: "hello".into(),
extensions: Vec::new(),
notification_commit: None,
history_provenance: None,
};
let catalog = PromptCatalog::builtins_only().unwrap();
@@ -76,6 +76,7 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
}
entry @ (LogEntry::AnnotatedAssistantItem { .. }
| LogEntry::AnnotatedToolResult { .. }
| LogEntry::ToolResultCorrected { .. }
| LogEntry::RunYielded { .. }
| LogEntry::RunResumed { .. }
| LogEntry::RunCancelled { .. }
+18
View File
@@ -105,6 +105,7 @@ pub enum WorkerPrompt {
AgentsMdSection,
ResidentMemorySummarySection,
ResidentMemoryRestoreSection,
SubjektivResidentContext,
WorkerOrchestrationGuidanceSection,
SubWorkerSpawnToolDescription,
}
@@ -128,6 +129,7 @@ impl WorkerPrompt {
Self::AgentsMdSection => "internal.agents_md_section",
Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section",
Self::ResidentMemoryRestoreSection => "internal.resident_memory_restore_section",
Self::SubjektivResidentContext => "internal.subjektiv_resident_context",
Self::WorkerOrchestrationGuidanceSection => {
"internal.worker_orchestration_guidance_section"
}
@@ -150,6 +152,7 @@ impl WorkerPrompt {
WorkerPrompt::AgentsMdSection,
WorkerPrompt::ResidentMemorySummarySection,
WorkerPrompt::ResidentMemoryRestoreSection,
WorkerPrompt::SubjektivResidentContext,
WorkerPrompt::WorkerOrchestrationGuidanceSection,
WorkerPrompt::SubWorkerSpawnToolDescription,
];
@@ -518,6 +521,21 @@ impl PromptCatalog {
},
)
}
pub fn subjektiv_resident_context(
&self,
behavior_md: &str,
surface_availability: &str,
surface_body: Option<&str>,
) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::SubjektivResidentContext,
minijinja::context! {
behavior_md => behavior_md,
surface_availability => surface_availability,
surface_body => surface_body.unwrap_or_default(),
},
)
}
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::WorkerOrchestrationGuidanceSection,
+1
View File
@@ -121,6 +121,7 @@ impl SegmentLogSink {
| LogEntry::AnnotatedUserInput { .. }
| LogEntry::AnnotatedAssistantItem { .. }
| LogEntry::AnnotatedToolResult { .. }
| LogEntry::ToolResultCorrected { .. }
| LogEntry::AnnotatedSystemItem { .. }
| LogEntry::Invoke { .. }
| LogEntry::RunYielded { .. }
+11
View File
@@ -116,6 +116,17 @@ pub(crate) fn restore_history_entries(
}
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => history.push(from_logged(entry)),
LogEntry::ToolResultCorrected { entry, .. } => {
let target = history
.iter_mut()
.find(|existing| existing.annotation.entry_id == entry.metadata.entry_id)
.ok_or_else(|| "tool result correction target is missing".to_string())?;
if !matches!((&target.item, &entry.item), (Item::ToolResult { call_id, .. }, session_store::LoggedItem::ToolResult { call_id: replacement, .. }) if call_id == replacement)
{
return Err("tool result correction changed call identity".to_string());
}
*target = from_logged(entry);
}
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
entry.item.to_history_item(),
entry.metadata.clone(),
+2856 -147
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+658 -30
View File
@@ -28,7 +28,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
use crate::{Error, Result};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 77;
const LATEST_SCHEMA_VERSION: i64 = 78;
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
@@ -77,6 +77,7 @@ const ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME: &str =
"preserve explicit observe grants for committed Worker Session archives";
const BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME: &str =
"durable terminal Backend Job Worker cleanup";
const WORKDIR_CONNECTION_ID_MIGRATION_NAME: &str = "durable Workdir attachment connection identity";
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
@@ -259,6 +260,11 @@ const MIGRATIONS: &[Migration] = &[
name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME,
apply: migrate_backend_job_worker_cleanup_v76_to_v77,
},
Migration {
version: 78,
name: WORKDIR_CONNECTION_ID_MIGRATION_NAME,
apply: migrate_workdir_connection_id_v77_to_v78,
},
];
#[derive(Clone, Copy)]
@@ -945,6 +951,23 @@ pub struct WorkdirRegistryRecord {
pub updated_at: String,
}
/// Projection inputs captured together with the catalog revision in one SQLite snapshot.
#[derive(Debug, Clone)]
pub struct WorkdirCatalogEntry {
pub record: WorkdirRegistryRecord,
pub repository_key: Option<String>,
/// None for Repository records and unsupported legacy External grants.
pub external_grant_permissions: Option<workdir::workspace::WorkdirPermissionSummary>,
pub occupied_by: Option<workdir::workspace::WorkingDirectoryOccupancy>,
}
#[derive(Debug, Clone)]
pub struct WorkdirCatalogPage {
pub entries: Vec<WorkdirCatalogEntry>,
pub next_cursor: Option<String>,
pub revision: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExternalWorkdirGrantRecord {
pub grant_id: String,
@@ -963,6 +986,8 @@ pub struct ExternalWorkdirGrantRecord {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerWorkdirLinkRecord {
/// Assigned by the ledger on attach; preserved only by explicit compensation.
pub connection_id: String,
pub workspace_id: String,
pub worker: RuntimeWorkerRef,
pub workdir_id: String,
@@ -1888,6 +1913,12 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
workspace_id: &str,
limit: usize,
) -> Result<Vec<WorkdirRegistryRecord>>;
fn workdir_catalog_page(
&self,
workspace_id: &str,
limit: u32,
cursor: Option<&str>,
) -> Result<WorkdirCatalogPage>;
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool>;
fn create_external_workdir_grant(
@@ -1983,6 +2014,31 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
expected_workdir_id: Option<&str>,
unlinked_at: &str,
) -> Result<Option<WorkerWorkdirLinkRecord>>;
fn detach_worker_workdir_connection(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
alias: &str,
expected_connection_id: &str,
unlinked_at: &str,
) -> Result<WorkerWorkdirLinkRecord>;
fn restore_worker_workdir_connection(&self, record: &WorkerWorkdirLinkRecord) -> Result<()>;
fn list_worker_workdir_links_page(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
limit: u32,
offset: u32,
connection_id: Option<&str>,
) -> Result<Vec<WorkerWorkdirLinkRecord>>;
fn list_worker_workdir_links_page_with_revision(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
limit: u32,
offset: u32,
connection_id: Option<&str>,
) -> Result<(Vec<WorkerWorkdirLinkRecord>, String)>;
fn worker_workdir_link_history_exists(
&self,
workspace_id: &str,
@@ -8876,6 +8932,72 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn workdir_catalog_page(
&self,
workspace_id: &str,
limit: u32,
cursor: Option<&str>,
) -> Result<WorkdirCatalogPage> {
if limit == 0 || limit > 100 {
return Err(Error::InvalidInput(
"catalog page limit must be 1..=100".to_string(),
));
}
if cursor.is_some_and(|id| {
id.trim().is_empty() || id.len() > 128 || id.chars().any(char::is_control)
}) {
return Err(Error::InvalidInput(
"catalog cursor must contain 1..=128 bytes without control characters".to_string(),
));
}
self.with_conn(|conn| {
// Also fence writers using other SQLite connections, not only this mutex.
let tx = conn.unchecked_transaction()?;
let sql = workdir_catalog_select_sql();
let mut hasher = Sha256::new();
update_public_collection_digest(&mut hasher, &("workdir-catalog:v1", workspace_id))?;
{
let mut stmt = tx.prepare(&format!("{sql} WHERE wr.workspace_id = ?1 ORDER BY wr.workdir_id"))?;
let mut rows = stmt.query(params![workspace_id])?;
while let Some(row) = rows.next()? {
let (entry, connection_id) = read_workdir_catalog_entry(row)?;
let record = &entry.record;
// Safe projection fields and timestamps only: no paths, provider identity,
// credentials, Repository UUIDs, or raw internal records.
update_public_collection_digest(&mut hasher, &(
&record.workdir_id, &record.display_name, &entry.repository_key,
record.source.external_grant_id(), &entry.external_grant_permissions,
(&record.creation_selector, &record.creation_ref, &record.creation_tree),
(&record.current_selector, &record.current_ref, &record.current_tree),
record.observed_at_epoch_seconds,
(&record.materialization_status, &record.cleanliness),
(&record.created_at, &record.updated_at),
&entry.occupied_by, connection_id,
))?;
}
}
let mut stmt = tx.prepare(&format!(
"{sql} WHERE wr.workspace_id = ?1 AND (?2 IS NULL OR wr.workdir_id > ?2) ORDER BY wr.workdir_id ASC LIMIT ?3"
))?;
let mut rows = stmt.query(params![workspace_id, cursor, limit + 1])?;
let mut entries = Vec::new();
while let Some(row) = rows.next()? {
entries.push(read_workdir_catalog_entry(row)?.0);
}
let next_cursor = if entries.len() > limit as usize {
entries.truncate(limit as usize);
entries.last().map(|entry| entry.record.workdir_id.clone())
} else {
None
};
Ok(WorkdirCatalogPage {
entries,
next_cursor,
revision: hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
})
})
}
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool> {
self.with_conn(|conn| {
let tx = rusqlite::Transaction::new_unchecked(
@@ -9349,6 +9471,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"reserved attachment finalization requires an active attachment".to_string(),
));
}
let mut record = record.clone();
record.connection_id = uuid::Uuid::now_v7().to_string();
self.with_conn(|conn| {
let tx = rusqlite::Transaction::new_unchecked(
conn,
@@ -9370,11 +9494,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
tx.execute(
r#"INSERT INTO worker_workdir_links (
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL)
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)
ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET
capabilities = excluded.capabilities,
linked_at = excluded.linked_at,
connection_id = excluded.connection_id,
unlinked_at = NULL"#,
params![
record.workspace_id,
@@ -9384,6 +9509,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
record.alias,
encode_workdir_link_capabilities(record.capabilities)?,
record.linked_at,
record.connection_id,
],
)
.map_err(|error| {
@@ -9421,6 +9547,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"a new attachment cannot already be unlinked".to_string(),
));
}
let mut record = record.clone();
record.connection_id = uuid::Uuid::now_v7().to_string();
self.with_conn(|conn| {
let tx = rusqlite::Transaction::new_unchecked(
conn,
@@ -9456,7 +9584,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
let active_for_alias = tx
.query_row(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND alias = ?4 AND unlinked_at IS NULL"#,
@@ -9499,7 +9627,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
let active_for_workdir = tx
.query_row(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL"#,
params![record.workspace_id, record.workdir_id],
@@ -9514,11 +9642,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
let write = tx.execute(
r#"INSERT INTO worker_workdir_links (
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL)
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)
ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET
capabilities = excluded.capabilities,
linked_at = excluded.linked_at,
connection_id = excluded.connection_id,
unlinked_at = NULL"#,
params![
record.workspace_id,
@@ -9528,6 +9657,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
record.alias,
encode_workdir_link_capabilities(record.capabilities)?,
record.linked_at,
record.connection_id,
],
);
if let Err(error) = write {
@@ -9556,7 +9686,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
rusqlite::TransactionBehavior::Immediate,
)?;
let mut stmt = tx.prepare(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND unlinked_at IS NULL"#,
@@ -9571,7 +9701,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
let mut persisted_identity = persisted
.iter()
.map(|link| (link.alias.as_str(), link.workdir_id.as_str()))
.map(|link| (link.alias.as_str(), link.workdir_id.as_str(), link.connection_id.as_str()))
.collect::<Vec<_>>();
persisted_identity.sort_unstable();
let mut requested_identity = active_links
@@ -9592,7 +9722,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
link.alias
))
})?;
Ok((link.alias.as_str(), link.workdir_id.as_str()))
Ok((link.alias.as_str(), link.workdir_id.as_str(), link.connection_id.as_str()))
})
.collect::<Result<Vec<_>>>()?;
requested_identity.sort_unstable();
@@ -9644,7 +9774,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
)?;
let active = tx
.query_row(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND (?4 IS NULL OR workdir_id = ?4) AND unlinked_at IS NULL"#,
@@ -9683,6 +9813,202 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn detach_worker_workdir_connection(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
alias: &str,
expected_connection_id: &str,
unlinked_at: &str,
) -> Result<WorkerWorkdirLinkRecord> {
self.with_conn(|conn| {
let tx = rusqlite::Transaction::new_unchecked(
conn,
rusqlite::TransactionBehavior::Immediate,
)?;
let active = tx
.query_row(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities,
linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND alias = ?4 AND connection_id = ?5 AND unlinked_at IS NULL"#,
params![
workspace_id,
worker.runtime_id,
worker.worker_id,
alias,
expected_connection_id
],
read_worker_workdir_link_record,
)
.optional()?
.ok_or_else(|| {
Error::WorkdirAttachmentConflict(format!(
"Workdir attachment `{alias}` connection changed during detach"
))
})?;
let changed = tx.execute(
r#"UPDATE worker_workdir_links SET unlinked_at = ?6
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND alias = ?4 AND connection_id = ?5 AND unlinked_at IS NULL"#,
params![
workspace_id,
worker.runtime_id,
worker.worker_id,
alias,
expected_connection_id,
unlinked_at
],
)?;
if changed != 1 {
return Err(Error::WorkdirAttachmentConflict(format!(
"Workdir attachment `{alias}` connection changed during detach"
)));
}
tx.commit()?;
Ok(WorkerWorkdirLinkRecord {
unlinked_at: Some(unlinked_at.to_string()),
..active
})
})
}
fn restore_worker_workdir_connection(&self, record: &WorkerWorkdirLinkRecord) -> Result<()> {
self.with_conn(|conn| {
// Compensation reactivates only the same lifetime, never an intervening attach.
let changed = conn.execute(
r#"UPDATE worker_workdir_links SET unlinked_at = NULL
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND workdir_id = ?4 AND alias = ?5 AND connection_id = ?6
AND unlinked_at IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM worker_workdir_attachment_reservations
WHERE workspace_id = ?1 AND workdir_id = ?4
)"#,
params![record.workspace_id, record.worker.runtime_id, record.worker.worker_id,
record.workdir_id, record.alias, record.connection_id],
).map_err(|error| {
if matches!(error, rusqlite::Error::SqliteFailure(ref code, _) if code.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE) {
Error::WorkdirAttachmentConflict("Workdir acquired another active attachment during compensation".to_string())
} else {
error.into()
}
})?;
if changed != 1 {
return Err(Error::WorkdirAttachmentConflict(
"Workdir attachment connection changed during compensation".to_string(),
));
}
Ok(())
})
}
fn list_worker_workdir_links_page(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
limit: u32,
offset: u32,
connection_id: Option<&str>,
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
self.list_worker_workdir_links_page_with_revision(
workspace_id,
worker,
limit,
offset,
connection_id,
)
.map(|(links, _)| links)
}
fn list_worker_workdir_links_page_with_revision(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
limit: u32,
offset: u32,
connection_id: Option<&str>,
) -> Result<(Vec<WorkerWorkdirLinkRecord>, String)> {
if limit == 0 || limit > 101 {
return Err(Error::InvalidInput(
"attachment page limit must be 1..=101".to_string(),
));
}
if connection_id.is_some_and(|id| {
id.trim().is_empty() || id.len() > 128 || id.chars().any(char::is_control)
}) {
return Err(Error::InvalidInput(
"attachment connection_id must contain 1..=128 bytes without control characters"
.to_string(),
));
}
self.with_conn(|conn| {
let tx = conn.unchecked_transaction()?;
let mut hasher = Sha256::new();
update_public_collection_digest(
&mut hasher,
&(
"workdir-connections:v1",
workspace_id,
&worker.runtime_id,
&worker.worker_id,
),
)?;
{
let mut stmt = tx.prepare(
"SELECT alias, connection_id, workdir_id, capabilities FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND unlinked_at IS NULL ORDER BY alias"
)?;
let mut rows =
stmt.query(params![workspace_id, worker.runtime_id, worker.worker_id])?;
while let Some(row) = rows.next()? {
let alias: String = row.get(0)?;
let id: String = row.get(1)?;
let workdir: String = row.get(2)?;
let capabilities =
decode_workdir_link_capabilities(&row.get::<_, String>(3)?, 3).map_err(
|_| Error::Store("invalid public connection capabilities".to_string()),
)?;
update_public_collection_digest(
&mut hasher,
&(alias, id, workdir, capabilities),
)?;
}
}
let mut stmt = tx.prepare(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities,
linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
AND unlinked_at IS NULL
AND (?6 IS NULL OR connection_id = ?6)
ORDER BY alias LIMIT ?4 OFFSET ?5"#,
)?;
let rows = stmt.query_map(
params![
workspace_id,
worker.runtime_id,
worker.worker_id,
limit,
offset,
connection_id
],
read_worker_workdir_link_record,
)?;
let links = rows.collect::<std::result::Result<Vec<_>, _>>()?;
Ok((
links,
hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>(),
))
})
}
fn worker_workdir_link_history_exists(
&self,
workspace_id: &str,
@@ -9708,7 +10034,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND unlinked_at IS NULL
ORDER BY linked_at DESC"#,
@@ -9729,7 +10055,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL
ORDER BY linked_at DESC"#,
@@ -9750,7 +10076,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
) -> Result<Option<WorkerWorkdirLinkRecord>> {
self.with_conn(|conn| {
conn.query_row(
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
FROM worker_workdir_links
WHERE workspace_id = ?1 AND workdir_id = ?2
ORDER BY linked_at DESC, rowid DESC
@@ -10497,6 +10823,7 @@ fn read_worker_workdir_link_record(
capabilities,
linked_at: row.get(6)?,
unlinked_at: row.get(7)?,
connection_id: row.get(8)?,
})
}
@@ -11442,6 +11769,96 @@ fn require_expected_ticket_assignment(
)))
}
// Length-framed canonical JSON prevents ambiguous concatenations and never hashes raw ledgers.
fn update_public_collection_digest(hasher: &mut Sha256, value: &impl Serialize) -> Result<()> {
let bytes = serde_json::to_vec(value)
.map_err(|_| Error::Store("unable to encode public collection metadata".to_string()))?;
hasher.update((bytes.len() as u64).to_be_bytes());
hasher.update(bytes);
Ok(())
}
fn workdir_catalog_select_sql() -> String {
format!(
"SELECT wr.*, r.repository_key, g.permissions,
l.runtime_id, l.worker_id, w.display_name, l.linked_at, l.connection_id
FROM ({}) wr
LEFT JOIN repositories r ON r.workspace_id = wr.workspace_id AND r.repository_id = wr.repository_id
LEFT JOIN external_workdir_grants g ON g.workspace_id = wr.workspace_id AND g.grant_id = wr.external_grant_id
LEFT JOIN worker_workdir_links l ON l.rowid = (
SELECT rowid FROM worker_workdir_links
WHERE workspace_id = wr.workspace_id AND workdir_id = wr.workdir_id AND unlinked_at IS NULL
ORDER BY linked_at DESC, rowid DESC LIMIT 1
)
LEFT JOIN worker_registry w ON w.workspace_id = l.workspace_id AND w.runtime_id = l.runtime_id AND w.worker_id = l.worker_id",
workdir_registry_select_sql("")
)
}
fn read_workdir_catalog_entry(
row: &rusqlite::Row<'_>,
) -> Result<(WorkdirCatalogEntry, Option<String>)> {
let record = read_workdir_registry_record(row)?;
let repository_key: Option<String> = row.get(18)?;
let external_grant_permissions = match &record.source {
WorkdirRegistrySource::Repository { .. } => {
if repository_key.is_none() {
return Err(Error::Store(
"Workdir catalog Repository is unavailable".to_string(),
));
}
None
}
WorkdirRegistrySource::ExternalGrant { .. } => {
let permissions: Option<String> = row.get(19)?;
match permissions.as_deref() {
Some("read_only") => Some(workdir::workspace::WorkdirPermissionSummary {
read: true,
write: false,
command: false,
}),
Some("read_write") => Some(workdir::workspace::WorkdirPermissionSummary {
read: true,
write: true,
command: false,
}),
Some("read_write_command") => Some(workdir::workspace::WorkdirPermissionSummary {
read: true,
write: true,
command: true,
}),
Some("command_only" | "read_command") => None,
_ => {
return Err(Error::Store(
"Workdir catalog External grant permissions are unavailable".to_string(),
));
}
}
}
};
let runtime_id: Option<String> = row.get(20)?;
let occupied_by = match runtime_id {
Some(runtime_id) => Some(workdir::workspace::WorkingDirectoryOccupancy {
runtime_id,
worker_id: row.get(21)?,
display_name: row.get::<_, Option<String>>(22)?.ok_or_else(|| {
Error::Store("Workdir catalog occupant is unavailable".to_string())
})?,
linked_at: row.get(23)?,
}),
None => None,
};
Ok((
WorkdirCatalogEntry {
record,
repository_key,
external_grant_permissions,
occupied_by,
},
row.get(24)?,
))
}
fn workdir_registry_select_sql(where_clause: &str) -> String {
format!(
"SELECT workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, external_grant_id, \
@@ -14486,6 +14903,27 @@ fn migrate_backend_job_worker_cleanup_v76_to_v77(conn: &Connection) -> Result<()
Ok(())
}
fn migrate_workdir_connection_id_v77_to_v78(conn: &Connection) -> Result<()> {
use rusqlite::TransactionBehavior;
let current = current_schema_version(conn)?;
if current != 77 {
return Err(Error::Store(format!(
"expected schema version 77 before {WORKDIR_CONNECTION_ID_MIGRATION_NAME} migration, found {current}"
)));
}
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
tx.execute_batch(
"ALTER TABLE worker_workdir_links ADD COLUMN connection_id TEXT NOT NULL DEFAULT '';
UPDATE worker_workdir_links SET connection_id = lower(hex(randomblob(16)));",
)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![78_i64, WORKDIR_CONNECTION_ID_MIGRATION_NAME],
)?;
tx.commit()?;
Ok(())
}
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
conn.execute_batch(include_str!("latest_schema.sql"))?;
@@ -14546,6 +14984,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
);
CREATE INDEX typed_ticket_targets_workspace_repository
ON typed_ticket_targets(workspace_id, repository_key, ticket_id);
ALTER TABLE worker_workdir_links ADD COLUMN connection_id TEXT NOT NULL DEFAULT '';
ALTER TABLE worker_workdir_links
ADD COLUMN capabilities TEXT NOT NULL
CHECK (capabilities IN (
@@ -15610,6 +16049,46 @@ mod tests {
Ok(())
}
#[test]
fn workdir_connection_migration_backfills_distinct_durable_lifetimes() {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE __yoi_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO __yoi_schema_migrations VALUES (77, 'workspace schema baseline');
CREATE TABLE worker_workdir_links (alias TEXT, linked_at TEXT, unlinked_at TEXT);
INSERT INTO worker_workdir_links VALUES ('checkout', 'same-time', NULL), ('docs', 'same-time', 'removed');",
).unwrap();
migrate_workdir_connection_id_v77_to_v78(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 78);
let rows = || {
let mut stmt = conn.prepare("SELECT alias, linked_at, unlinked_at, connection_id FROM worker_workdir_links ORDER BY alias").unwrap();
stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, String>(3)?,
))
})
.unwrap()
.collect::<std::result::Result<Vec<_>, _>>()
.unwrap()
};
let migrated = rows();
assert_eq!(migrated[0].0, "checkout");
assert_eq!(migrated[0].1, "same-time");
assert_eq!(migrated[0].2, None);
assert_eq!(migrated[1].2.as_deref(), Some("removed"));
assert!(!migrated[0].3.is_empty());
assert_ne!(migrated[0].3, migrated[1].3);
assert!(migrate_workdir_connection_id_v77_to_v78(&conn).is_err());
assert_eq!(
rows(),
migrated,
"a retry cannot replace an already durable connection ID"
);
}
#[test]
fn worker_workdir_link_capability_codec_round_trips_only_canonical_sets() {
for (capabilities, encoded) in WORKDIR_LINK_CAPABILITY_ENCODINGS {
@@ -17052,6 +17531,10 @@ mod tests {
version: 77,
name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
},
WorkspaceSchemaMigrationStep {
version: 78,
name: WORKDIR_CONNECTION_ID_MIGRATION_NAME.to_string(),
},
]
);
@@ -17139,6 +17622,7 @@ mod tests {
77,
BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
),
(78, WORKDIR_CONNECTION_ID_MIGRATION_NAME.to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -17490,7 +17974,7 @@ mod tests {
.collect::<Vec<_>>(),
vec![
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
73, 74, 75, 76, 77
73, 74, 75, 76, 77, 78
]
);
SqliteWorkspaceStore::migrate_database(&path).unwrap();
@@ -17499,7 +17983,7 @@ mod tests {
current_schema_version(&conn).unwrap(),
LATEST_SCHEMA_VERSION
);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 28);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 29);
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
@@ -20855,6 +21339,7 @@ INSERT INTO worker_registry (
store.upsert_workdir_registry(&unmanaged_workdir).unwrap();
let link = WorkerWorkdirLinkRecord {
connection_id: String::new(),
workspace_id: "local-dev".to_string(),
worker: worker.worker.clone(),
workdir_id: workdir.workdir_id.clone(),
@@ -20883,12 +21368,10 @@ INSERT INTO worker_registry (
store.attach_worker_workdir(&link),
Err(Error::WorkdirAttachmentConflict(_))
));
assert_eq!(
store
.finalize_reserved_worker_workdir_attachment(&link, "spawn-1")
.unwrap(),
link
);
let link = store
.finalize_reserved_worker_workdir_attachment(&link, "spawn-1")
.unwrap();
assert!(!link.connection_id.is_empty());
assert_eq!(store.attach_worker_workdir(&link).unwrap(), link);
let downgraded_link = WorkerWorkdirLinkRecord {
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
@@ -20958,10 +21441,7 @@ INSERT INTO worker_registry (
store.attach_worker_workdir(&invalid_capabilities),
Err(Error::InvalidInput(_))
));
assert_eq!(
store.attach_worker_workdir(&second_attachment).unwrap(),
second_attachment
);
let second_attachment = store.attach_worker_workdir(&second_attachment).unwrap();
assert_eq!(
store
.list_worker_workdir_links("local-dev", &worker.worker)
@@ -20991,6 +21471,157 @@ INSERT INTO worker_registry (
store.detach_worker_workdir("local-dev", &worker.worker, Some("wrong-workdir"), "6",),
Err(Error::WorkdirAttachmentConflict(_))
));
let old_connection = downgraded_link.connection_id.clone();
let removed = store
.detach_worker_workdir_connection(
"local-dev",
&worker.worker,
"checkout",
&old_connection,
"5",
)
.unwrap();
store.restore_worker_workdir_connection(&removed).unwrap();
assert_eq!(
store
.list_worker_workdir_links_page("local-dev", &worker.worker, 1, 0, None)
.unwrap()[0],
downgraded_link
);
store
.detach_worker_workdir_connection(
"local-dev",
&worker.worker,
"checkout",
&old_connection,
"5",
)
.unwrap();
// Reusing the exact record, including alias, Workdir ID and timestamp, starts a fresh lifetime.
let replacement = store.attach_worker_workdir(&downgraded_link).unwrap();
assert_ne!(replacement.connection_id, old_connection);
assert!(matches!(
store.detach_worker_workdir_connection(
"local-dev",
&worker.worker,
"checkout",
&old_connection,
"6",
),
Err(Error::WorkdirAttachmentConflict(_))
));
assert!(matches!(
store.restore_worker_workdir_connection(&removed),
Err(Error::WorkdirAttachmentConflict(_))
));
assert!(matches!(
store.replace_worker_workdir_link_capabilities(
"local-dev",
&worker.worker,
&[downgraded_link.clone(), second_attachment.clone()],
),
Err(Error::WorkdirAttachmentConflict(_))
));
assert_eq!(store.attach_worker_workdir(&link).unwrap(), replacement);
assert_eq!(
store
.list_worker_workdir_links_page("local-dev", &worker.worker, 1, 0, None)
.unwrap(),
vec![replacement.clone()]
);
assert_eq!(
store
.list_worker_workdir_links_page("local-dev", &worker.worker, 1, 1, None)
.unwrap(),
vec![second_attachment.clone()]
);
assert!(
store
.list_worker_workdir_links_page("local-dev", &worker.worker, 1, 2, None)
.unwrap()
.is_empty()
);
assert!(
store
.list_worker_workdir_links_page("foreign", &worker.worker, 1, 0, None)
.unwrap()
.is_empty()
);
assert!(
store
.list_worker_workdir_links_page(
"local-dev",
&RuntimeWorkerRef::new("foreign", worker.worker.worker_id.clone()),
1,
0,
None
)
.unwrap()
.is_empty()
);
assert_eq!(
store
.list_worker_workdir_links_page(
"local-dev",
&worker.worker,
1,
0,
Some(&second_attachment.connection_id)
)
.unwrap(),
vec![second_attachment.clone()],
"connection filtering must precede paging"
);
for (workspace_id, identity, connection_id) in [
(
"foreign",
worker.worker.clone(),
second_attachment.connection_id.as_str(),
),
(
"local-dev",
second_worker.worker.clone(),
second_attachment.connection_id.as_str(),
),
(
"local-dev",
RuntimeWorkerRef::new("foreign", worker.worker.worker_id.clone()),
second_attachment.connection_id.as_str(),
),
("local-dev", worker.worker.clone(), old_connection.as_str()),
("local-dev", worker.worker.clone(), "nonexistent"),
] {
assert!(
store
.list_worker_workdir_links_page(
workspace_id,
&identity,
1,
0,
Some(connection_id)
)
.unwrap()
.is_empty()
);
}
drop(store);
let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(
store
.list_worker_workdir_links_page("local-dev", &worker.worker, 1, 0, None)
.unwrap(),
vec![replacement]
);
assert!(matches!(
store.detach_worker_workdir_connection(
"local-dev",
&worker.worker,
"checkout",
&old_connection,
"6",
),
Err(Error::WorkdirAttachmentConflict(_))
));
let detached = store
.detach_worker_workdir("local-dev", &worker.worker, Some(&workdir.workdir_id), "6")
.unwrap()
@@ -21001,10 +21632,7 @@ INSERT INTO worker_registry (
.worker_workdir_link_history_exists("local-dev", &worker.worker)
.unwrap()
);
assert_eq!(
store.attach_worker_workdir(&workdir_conflict).unwrap(),
workdir_conflict
);
let workdir_conflict = store.attach_worker_workdir(&workdir_conflict).unwrap();
drop(store);
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
+310 -64
View File
@@ -11,7 +11,7 @@ use std::collections::HashSet;
use chrono::{SecondsFormat, Utc};
use memory::extract::{CandidateKind, STAGING_SCHEMA_VERSION, StagingEvidence, StagingRecord};
use memory::schema::{SourceEvidenceRef, SourceRef};
use rusqlite::{OptionalExtension, Transaction, params};
use rusqlite::{Connection, OptionalExtension, Transaction, params};
use serde::{Deserialize, Deserializer, Serialize};
use uuid::Uuid;
@@ -24,6 +24,8 @@ pub const SUBJEKTIV_SCHEMA_VERSION: u32 = 1;
pub const SUBJEKTIV_FEATURE_ID: &str = "subjektiv";
pub const SUBJECT_WORKER_SINGLETON_PREFIX: &str = "subjektiv:";
pub const MAX_STAGING_ANCHORS: usize = 10;
/// Maximum UTF-8 size of the user-managed Subject behavior document.
pub const MAX_SUBJECT_BEHAVIOR_BYTES: usize = server_api::SUBJEKTIV_MAX_BEHAVIOR_BYTES;
/// Deterministic surface-generation policy. Input estimates use the repository's
/// provider-independent UTF-8 byte estimate (`ceil(bytes / 4)`). The material
/// payload is capped below the whole-input budget to reserve room for the fixed
@@ -74,6 +76,14 @@ pub enum SubjektivError {
expected: u64,
actual: u64,
},
#[error(
"subject `{subject_id}` behavior revision conflict: expected {expected}, current {actual}"
)]
SubjectBehaviorConflict {
subject_id: String,
expected: u64,
actual: u64,
},
#[error("surface generation conflict: {0}")]
SurfaceGenerationConflict(String),
#[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")]
@@ -136,6 +146,14 @@ pub struct SubjectRecord {
pub schema_version: u32,
pub id: String,
pub role: SubjectRole,
/// User-managed, host-injected behavior document. This is not Memory and
/// Memory lifecycle operations must preserve it byte-for-byte.
#[serde(default)]
pub behavior_md: String,
/// Monotonic CAS generation for behavior updates, independent of Memory's
/// `store_revision`.
#[serde(default)]
pub behavior_revision: u64,
pub state: SubjectState,
/// Monotonic generation incremented exactly once for every committed Memory
/// creation or revision for this subject.
@@ -500,6 +518,12 @@ pub struct ResidentSurface {
pub snapshot: Option<SurfaceSnapshot>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubjectResidentContext {
pub subject: SubjectRecord,
pub surface: ResidentSurface,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MemoryRevisionTarget {
@@ -986,6 +1010,17 @@ END;
Ok(())
}
fn add_subject_behavior(transaction: &Transaction<'_>) -> crate::feature_storage::Result<()> {
transaction.execute_batch(
r#"
ALTER TABLE subjects ADD COLUMN behavior_md TEXT NOT NULL DEFAULT '';
ALTER TABLE subjects ADD COLUMN behavior_revision INTEGER NOT NULL DEFAULT 0
CHECK (behavior_revision >= 0);
"#,
)?;
Ok(())
}
static MIGRATIONS: &[FeatureMigration] = &[
FeatureMigration::new(1, "create subjektiv subject memory store", create_schema),
FeatureMigration::new(
@@ -1003,6 +1038,7 @@ static MIGRATIONS: &[FeatureMigration] = &[
"add bounded surface generation state and runs",
add_surface_generation_state,
),
FeatureMigration::new(5, "add user-managed subject behavior", add_subject_behavior),
];
pub const REGISTRATION: FeatureRegistration =
@@ -1070,11 +1106,23 @@ impl SubjektivStore {
/// Issues a random host-owned subject id. It is never derived from Worker,
/// Profile, Session, or Memory content.
pub fn create_subject(&self, role: SubjectRole) -> Result<SubjectRecord> {
self.create_subject_with_behavior(role, String::new())
}
/// Creates a Subject with its optional user-managed behavior document.
pub fn create_subject_with_behavior(
&self,
role: SubjectRole,
behavior_md: String,
) -> Result<SubjectRecord> {
validate_subject_behavior(&behavior_md)?;
let timestamp = now();
let record = SubjectRecord {
schema_version: SUBJEKTIV_SCHEMA_VERSION,
id: issued_id("subject"),
role,
behavior_md,
behavior_revision: 0,
state: SubjectState::Active,
store_revision: 0,
created_at: timestamp.clone(),
@@ -1084,11 +1132,13 @@ impl SubjektivStore {
self.database.try_transaction(|transaction| {
transaction.execute(
"INSERT INTO subjects (
subject_id, role, state, store_revision, record_json, created_at, updated_at
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
subject_id, role, behavior_md, behavior_revision, state,
store_revision, record_json, created_at, updated_at
) VALUES (?1, ?2, ?3, 0, 'active', 0, ?4, ?5, ?6)",
params![
record.id,
record.role.as_str(),
record.behavior_md,
raw,
record.created_at,
record.updated_at
@@ -1111,6 +1161,51 @@ impl SubjektivStore {
})
}
/// Replaces or clears the user-managed behavior document using its own CAS
/// generation. Memory revisions do not participate in this generation.
pub fn update_subject_behavior(
&self,
subject_id: &str,
expected_behavior_revision: u64,
behavior_md: String,
) -> Result<SubjectRecord> {
validate_subject_behavior(&behavior_md)?;
self.database.try_transaction(|transaction| {
let mut subject = require_subject(transaction, subject_id)?;
if subject.behavior_revision != expected_behavior_revision {
return Err(SubjektivError::SubjectBehaviorConflict {
subject_id: subject_id.to_string(),
expected: expected_behavior_revision,
actual: subject.behavior_revision,
});
}
if subject.behavior_md == behavior_md {
return Ok(subject);
}
subject.behavior_md = behavior_md;
subject.behavior_revision =
subject.behavior_revision.checked_add(1).ok_or_else(|| {
SubjektivError::InvalidRecord("subject behavior revision overflow".to_string())
})?;
subject.updated_at = now();
let raw = serde_json::to_string(&subject)?;
transaction.execute(
"UPDATE subjects
SET behavior_md = ?2, behavior_revision = ?3,
record_json = ?4, updated_at = ?5
WHERE subject_id = ?1",
params![
subject.id,
subject.behavior_md,
to_i64(subject.behavior_revision)?,
raw,
subject.updated_at
],
)?;
Ok(subject)
})
}
/// Validates an explicit product launch connection against this Workspace's
/// subject store and the already-resolved Profile policy. Subject knowledge
/// alone is insufficient: only an active local record produces host launch material.
@@ -2021,60 +2116,17 @@ impl SubjektivStore {
pub fn resident_surface(&self, subject_id: &str) -> Result<ResidentSurface> {
self.database.try_with_connection(|connection| {
let subject = require_subject_in_connection(connection, subject_id)?;
let state = connection
.query_row(
"SELECT store_revision, status, snapshot_id
FROM surface_generation_state WHERE subject_id = ?1",
[subject_id],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
},
)
.optional()?;
let Some((revision, status, snapshot_id)) = state else {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Ungenerated,
snapshot: None,
});
};
let revision = u64::try_from(revision).map_err(|_| {
SubjektivError::InvalidRecord("surface state revision is negative".into())
})?;
if revision != subject.store_revision || status == "dirty" {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Stale,
snapshot: None,
});
}
if status == "failed" {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Failed,
snapshot: None,
});
}
let snapshot_id = snapshot_id.ok_or_else(|| {
SubjektivError::InvalidRecord("ready surface state has no snapshot id".into())
})?;
let raw = connection.query_row(
"SELECT snapshot_json FROM surface_snapshots
WHERE subject_id = ?1 AND snapshot_id = ?2",
params![subject_id, snapshot_id],
|row| row.get::<_, String>(0),
)?;
let snapshot = parse_surface_snapshot(&raw)?;
if snapshot.built_from_store_revision != subject.store_revision {
return Err(SubjektivError::InvalidRecord(
"ready surface snapshot generation does not match subject".into(),
));
}
Ok(ResidentSurface {
availability: SurfaceAvailability::Ready,
snapshot: Some(snapshot),
})
resident_surface_in_connection(connection, &subject)
})
}
/// Atomically reads user-managed behavior and the independently-fresh
/// generated Memory surface from one SQLite connection snapshot.
pub fn resident_context(&self, subject_id: &str) -> Result<SubjectResidentContext> {
self.database.try_transaction(|transaction| {
let subject = require_subject(transaction, subject_id)?;
let surface = resident_surface_in_connection(transaction, &subject)?;
Ok(SubjectResidentContext { subject, surface })
})
}
@@ -3414,6 +3466,87 @@ fn validate_range(kind: &str, range: [u64; 2]) -> Result<()> {
}
}
fn resident_surface_in_connection(
connection: &Connection,
subject: &SubjectRecord,
) -> Result<ResidentSurface> {
let state = connection
.query_row(
"SELECT store_revision, status, snapshot_id
FROM surface_generation_state WHERE subject_id = ?1",
[&subject.id],
|row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
},
)
.optional()?;
let Some((revision, status, snapshot_id)) = state else {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Ungenerated,
snapshot: None,
});
};
let revision = u64::try_from(revision)
.map_err(|_| SubjektivError::InvalidRecord("surface state revision is negative".into()))?;
if revision != subject.store_revision || status == "dirty" {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Stale,
snapshot: None,
});
}
if status == "failed" {
return Ok(ResidentSurface {
availability: SurfaceAvailability::Failed,
snapshot: None,
});
}
let snapshot_id = snapshot_id.ok_or_else(|| {
SubjektivError::InvalidRecord("ready surface state has no snapshot id".into())
})?;
let raw = connection.query_row(
"SELECT snapshot_json FROM surface_snapshots
WHERE subject_id = ?1 AND snapshot_id = ?2",
params![subject.id, snapshot_id],
|row| row.get::<_, String>(0),
)?;
let snapshot = parse_surface_snapshot(&raw)?;
if snapshot.built_from_store_revision != subject.store_revision {
return Err(SubjektivError::InvalidRecord(
"ready surface snapshot generation does not match subject".into(),
));
}
Ok(ResidentSurface {
availability: SurfaceAvailability::Ready,
snapshot: Some(snapshot),
})
}
pub fn validate_subject_behavior(value: &str) -> Result<()> {
if value.len() > MAX_SUBJECT_BEHAVIOR_BYTES {
return Err(SubjektivError::InvalidRecord(format!(
"subject behavior must be at most {MAX_SUBJECT_BEHAVIOR_BYTES} bytes"
)));
}
if !value.is_empty() && value.trim().is_empty() {
return Err(SubjektivError::InvalidRecord(
"subject behavior must be empty or contain non-whitespace text".to_string(),
));
}
if value
.chars()
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
{
return Err(SubjektivError::InvalidRecord(
"subject behavior contains an unsupported control character".to_string(),
));
}
Ok(())
}
fn validate_label(kind: &str, value: &str) -> Result<()> {
validate_nonempty(kind, value)?;
if value.len() > 256 || value.chars().any(char::is_control) {
@@ -3668,6 +3801,67 @@ mod tests {
assert!(!complete.has_more);
}
#[test]
fn subject_behavior_is_bounded_revisioned_and_independent_from_memory_revision() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _workspace, store) = open_store(temp.path(), "workspace-a");
let subject = store
.create_subject_with_behavior(role(), "Prefer explicit evidence.".to_string())
.unwrap();
assert_eq!(subject.behavior_revision, 0);
assert_eq!(subject.store_revision, 0);
let updated = store
.update_subject_behavior(
&subject.id,
0,
"Prefer explicit evidence.\nAsk when uncertain.".to_string(),
)
.unwrap();
assert_eq!(updated.behavior_revision, 1);
assert_eq!(updated.store_revision, 0);
store
.create_memory(
&subject.id,
draft(
"A confirmed observation",
"exercise Memory lifecycle isolation",
),
)
.unwrap();
let after_memory = store.subject(&subject.id).unwrap().unwrap();
assert_eq!(after_memory.behavior_md, updated.behavior_md);
assert_eq!(after_memory.behavior_revision, 1);
assert_eq!(after_memory.store_revision, 1);
let (_reopened_manager, _reopened_workspace, reopened) =
open_store(temp.path(), "workspace-a");
let persisted = reopened.subject(&subject.id).unwrap().unwrap();
assert_eq!(persisted.behavior_md, updated.behavior_md);
assert_eq!(persisted.behavior_revision, 1);
assert!(matches!(
store.update_subject_behavior(&subject.id, 0, String::new()),
Err(SubjektivError::SubjectBehaviorConflict {
expected: 0,
actual: 1,
..
})
));
let unchanged = store
.update_subject_behavior(&subject.id, 1, updated.behavior_md.clone())
.unwrap();
assert_eq!(unchanged.behavior_revision, 1);
let cleared = store
.update_subject_behavior(&subject.id, 1, String::new())
.unwrap();
assert_eq!(cleared.behavior_revision, 2);
assert!(cleared.behavior_md.is_empty());
assert!(validate_subject_behavior(&"x".repeat(MAX_SUBJECT_BEHAVIOR_BYTES + 1)).is_err());
assert!(validate_subject_behavior(" ").is_err());
}
#[test]
fn attributed_candidates_preserve_revision_and_surface_reference_continuity() {
let temp = tempfile::tempdir().unwrap();
@@ -3892,10 +4086,38 @@ mod tests {
))
.unwrap();
let store = SubjektivStore::open(&workspace, &registration).unwrap();
store.create_subject(role()).unwrap().id
let timestamp = now();
let subject_id = issued_id("subject");
let mut raw = serde_json::json!({
"schema_version": SUBJEKTIV_SCHEMA_VERSION,
"id": subject_id,
"role": role(),
"state": SubjectState::Active,
"store_revision": 0,
"created_at": timestamp.clone(),
"updated_at": timestamp.clone(),
});
let raw = serde_json::to_string(&raw.take()).unwrap();
store
.database
.try_transaction(|transaction| -> Result<()> {
transaction.execute(
"INSERT INTO subjects (
subject_id, role, state, store_revision,
record_json, created_at, updated_at
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
params![subject_id, role().as_str(), raw, timestamp, timestamp],
)?;
Ok(())
})
.unwrap();
subject_id
};
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
let migrated_subject = store.subject(&subject_id).unwrap().unwrap();
assert!(migrated_subject.behavior_md.is_empty());
assert_eq!(migrated_subject.behavior_revision, 0);
let lifecycle_attribution = attribution(
&subject_id,
"runtime-1",
@@ -3935,26 +4157,50 @@ mod tests {
))
.unwrap();
let store = SubjektivStore::open(&workspace, &registration).unwrap();
let subject = store.create_subject(role()).unwrap();
let subject_id = issued_id("subject");
let timestamp = now();
let subject_json = serde_json::to_string(&serde_json::json!({
"schema_version": SUBJEKTIV_SCHEMA_VERSION,
"id": subject_id,
"role": role(),
"state": SubjectState::Active,
"store_revision": 0,
"created_at": timestamp,
"updated_at": timestamp,
}))
.unwrap();
let snapshot = SurfaceSnapshot {
schema_version: SUBJEKTIV_SCHEMA_VERSION,
id: "legacy-surface".into(),
subject_id: subject.id.clone(),
subject_id: subject_id.clone(),
body_md: "Legacy summary without generation-policy evidence.".into(),
memory_refs: Vec::new(),
built_from_store_revision: subject.store_revision,
built_from_store_revision: 0,
created_at: now(),
};
store
.database
.try_transaction(|transaction| -> Result<()> {
transaction.execute(
"INSERT INTO subjects (
subject_id, role, state, store_revision,
record_json, created_at, updated_at
) VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5)",
params![
subject_id,
role().as_str(),
subject_json,
timestamp,
timestamp
],
)?;
transaction.execute(
"INSERT INTO surface_snapshots (
subject_id, snapshot_id, built_from_store_revision,
snapshot_json, created_at
) VALUES (?1, ?2, ?3, ?4, ?5)",
params![
subject.id,
subject_id,
snapshot.id,
to_i64(snapshot.built_from_store_revision)?,
serde_json::to_string(&snapshot)?,
@@ -3964,12 +4210,12 @@ mod tests {
transaction.execute(
"INSERT INTO surface_snapshot_seals (subject_id, snapshot_id)
VALUES (?1, ?2)",
params![subject.id, snapshot.id],
params![subject_id, snapshot.id],
)?;
Ok(())
})
.unwrap();
(subject.id, snapshot.id)
(subject_id, snapshot.id)
};
let (_manager, _workspace, store) = open_store(&root, "workspace-a");
+3 -1
View File
@@ -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。
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.
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
+5 -3
View File
@@ -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.
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
/features/merge-request/merge-requests
/features/merge-request/merge-requests/<merge-request-id>
/merge-requests
/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.
## Current Feature inventory
+5 -3
View File
@@ -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.
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
/features/objective/objectives
/features/objective/objectives/<objective-reference>
/objectives
/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.
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`.
+5 -3
View File
@@ -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.
The Host allocates `/features/ticket` and the Feature mounts:
The Host assigns the Ticket provider the root `tickets` namespace through `WipMountRegistry` and mounts:
```text
/features/ticket/tickets
/features/ticket/tickets/<ticket-reference>
/tickets
/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.
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.
+186
View File
@@ -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.
+36 -6
View File
@@ -52,21 +52,47 @@ The interface descriptor uses WIP `Json` for compatibility input and includes th
## 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.
- A projected object exposes exactly its declared interface.
- Unrelated duplicate routes and conflicting interface descriptors or validators fail startup.
- Object existence and resolution have one owner; operation contributors cannot remount or substitute the Object.
- 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 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.
`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
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;
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.
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
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;
- **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.
## 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
View File
@@ -9807,9 +9807,32 @@
],
"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": {
"additionalProperties": false,
"properties": {
"behavior_md": {
"default": "",
"type": "string"
},
"role": {
"type": "string"
}
@@ -9875,6 +9898,15 @@
"SubjektivSubjectResponse": {
"additionalProperties": false,
"properties": {
"behavior_md": {
"type": "string"
},
"behavior_revision": {
"format": "uint64",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
},
"created_at": {
"type": "string"
},
@@ -9911,6 +9943,8 @@
"required": [
"id",
"role",
"behavior_md",
"behavior_revision",
"state",
"store_revision",
"created_at",
@@ -15286,7 +15320,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:eddff23c6405c11e8becfef09473d6833647bcaba9243abae189db7f840c1896"
"x-yoi-source-digest": "sha256:47064776569f7f208533f24d56ac80c0db6055d508ab29fb0a780c2c66387081"
},
"openapi": "3.1.0",
"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": {
"get": {
"operationId": "subjektiv_memory_list",
+2
View File
@@ -34,6 +34,7 @@ internalWorkingBoundariesSection = import "./internal/working_boundaries_section
internalAgentsMdSection = import "./internal/agents_md_section.md";
internalResidentMemorySummarySection = import "./internal/resident_memory_summary_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";
panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md";
in
@@ -74,6 +75,7 @@ in
agents_md_section = internalAgentsMdSection.content;
resident_memory_summary_section = internalResidentMemorySummarySection.content;
resident_memory_restore_section = internalResidentMemoryRestoreSection.content;
subjektiv_resident_context = internalSubjektivResidentContext.content;
worker_orchestration_guidance_section = commonWorkerOrchestration.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.
{% if surface_ready %}
### Current ready surface
### Current resident context
{% if summary %}
{{ summary }}
{% else %}
The current ready resident memory surface is intentionally empty.
The current resident context is intentionally empty.
{% endif %}
{% else %}
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.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
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.
await touchPage.setViewportSize({ width: 1024, height: 600 });
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}`,
);
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 notifications = ["both", "notifications", "legacy"].includes(mode);
assertEquals(
@@ -66,7 +66,15 @@ ${"detail-wide-code-".repeat(18)}
<script>alert("not rendered")</script>`;
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(staleSubjectId, "Stale surface subject", "active", 19),
subject(failedSubjectId, "Failed surface subject", "active", 8),
@@ -75,14 +83,49 @@ const subjects = [
];
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 {
id,
role,
behavior_md: behaviorMd,
behavior_revision: behaviorRevision,
state,
store_revision: storeRevision,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T03:04:05Z",
...(currentWorker ? { current_worker: currentWorker } : {}),
};
}
@@ -125,7 +168,7 @@ function memoriesFor(subjectId: string) {
1,
"working_assumption",
"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",
origin: { kind: "human_input", account_id: "account-fixture" },
evidence_kind: "message",
label: "T-672 product direction",
label: "Memory information design source",
summary: "Bounded source reference retained with the committed revision.",
}],
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`;
if (url.pathname === subjectsPath) {
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);
if (!payload || typeof payload.role !== "string" || payload.role.trim().length === 0) {
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") {
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;
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);
subjects.unshift(created);
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);
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 === 2 && parts[1] === "surface") return json(surfaceFor(subjectId));
if (parts.length === 2 && parts[1] === "memories") {
@@ -16,7 +16,7 @@ const memoryId =
const subjectPageCursor = "fixture-subject-page-2";
const createInteractionArtifacts = join(
repositoryRoot,
"target/web-ux/t-689-browser-interactions",
"target/web-ux/t-692-browser-interactions",
);
async function freePort(): Promise<number> {
@@ -51,7 +51,7 @@ async function markerIsInsideMain(page: Page): Promise<boolean> {
const document = (globalThis as any).document;
const main = document.querySelector("main");
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;
const mainRect = main.getBoundingClientRect();
@@ -111,6 +111,7 @@ async function openSubjectPage(
await hideSidebar.click();
await showSidebar.waitFor();
await page.waitForTimeout(250);
assertEquals(await showSidebar.getAttribute("aria-expanded"), "false");
assertEquals(
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)));
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);
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^="javascript:"]').count(), 0);
assertEquals(
await page.getByRole("article", { name: "Resident surface" }).locator("img, script")
await page.getByRole("article", { name: "Resident context" }).locator("img, script")
.count(),
0,
);
@@ -282,7 +290,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
await page.locator("main").hover();
await page.mouse.wheel(0, 100_000);
} 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");
} else {
await swipeMainToEnd(page);
@@ -313,17 +321,17 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
level: 1,
}).waitFor();
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.getByRole("heading", { name: "Revision history" }).count(), 1);
assertEquals(await detailPage.getByText("Revision 3", { exact: true }).count(), 1);
assertEquals(await detailPage.getByText("Revision 2", { exact: true }).count(), 1);
assertEquals(await detailPage.getByText("Revision 1", { exact: true }).count(), 0);
assertEquals(await detailPage.getByText("Memory revision 3", { exact: true }).count(), 1);
assertEquals(await detailPage.getByText("Memory revision 2", { exact: true }).count(), 1);
assertEquals(await detailPage.getByText("Memory revision 1", { exact: true }).count(), 0);
await detailPage.getByRole("navigation", { name: "Revision history pages" }).getByRole(
"link",
{ name: "Next page →" },
).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"));
assertEquals(
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();
await paginationPage.goto(`${baseUrl}/w/${workspaceId}/memory`);
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);
await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole(
"link",
@@ -425,7 +442,9 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
const createPage = await createContext.newPage();
const mutationPaths: string[] = [];
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.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: "New Subject" }).click();
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.waitForURL("**/memory/created-subject-0001");
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(
await createPage.locator("code.subject-id", { hasText: "created-subject-0001" }).count(),
1,
);
const currentWorker = createPage.locator(".subject-facts div").filter({
hasText: "Current worker",
const currentWorker = createPage.getByText("Worker connection", { exact: true }).locator(
"..",
);
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, [
`/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`))
.json();
assertEquals(createEvidence, {
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 createContext.close();
@@ -472,21 +530,29 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
const stateCases = [
{
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,
},
{ id: "stale-subject", text: "Resident surface is stale.", role: "status" as const },
{
id: "failed-subject",
text: "Resident surface generation failed.",
text: "Resident context generation failed.",
role: "alert" as const,
},
{
id: "ungenerated-subject",
text: "Resident surface has not been generated.",
text: "Resident context has not been generated.",
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) {
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 });
await state.waitFor();
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") {
assertEquals(await page.getByRole("article", { name: "Resident surface" }).count(), 0);
assertEquals(await page.getByRole("article", { name: "Resident context" }).count(), 0);
}
await assertNoPageWideOverflow(page);
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.",
"ready": {
"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",
@@ -150,6 +174,18 @@
},
"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",
"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.",
"ready": {
"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",
@@ -150,6 +174,18 @@
},
"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",
"label": "Stale subject surface",
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// 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
// Output: web/workspace/src/lib/generated/companion-api.ts
// 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,
} as const;
export const SUBJEKTIV_API_LIMITS = {
maxBehaviorBytes: 16384,
} as const;
export type DiagnosticSeverity = "info" | "warning" | "error";
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 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, };
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// 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
// Output: web/workspace/src/lib/generated/repository-api.ts
// 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.
// 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
// Output: web/workspace/src/lib/generated/runtime-api.ts
// 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.
// 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
// Output: web/workspace/src/lib/generated/ticket-api.ts
// 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.
// 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
// Output: web/workspace/src/lib/generated/workdir-api.ts
// 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.
// 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
// 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
@@ -33,6 +33,7 @@ import {
parseWorkerLaunchOptionsResponse,
parseWorkerSummary,
} from "./workers.ts";
import { loadJson } from "./http.ts";
const worker = {
runtime_id: "runtime-a",
@@ -111,6 +112,92 @@ Deno.test("Worker summary parser enforces observation freshness", () => {
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", () => {
const attachment = {
alias: "sessions",
+12 -1
View File
@@ -262,7 +262,16 @@ function workerStateSnapshot(
label: string,
): WorkerStateSnapshot {
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 kind = string(state.kind, `${label}.state.kind`);
if (kind === "idle") {
@@ -272,6 +281,7 @@ function workerStateSnapshot(
snapshot.last_command_id,
`${label}.last_command_id`,
),
last_finished_submission_request_id: lastFinishedSubmissionRequestId,
state: { kind: "idle" },
};
}
@@ -291,6 +301,7 @@ function workerStateSnapshot(
snapshot.last_command_id,
`${label}.last_command_id`,
),
last_finished_submission_request_id: lastFinishedSubmissionRequestId,
state: {
kind: "busy",
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 diagnostics = $state<ConfigDiagnostic[]>([]);
let status = $state("Loading source tree…");
let loadState = $state<"loading" | "error" | "ready">("loading");
let mounted = false;
let busy = $state(false);
let workingChanges = $state<ConfigTreeChange[]>([]);
let baseRevision = $state(0);
@@ -42,9 +44,12 @@
const dirty = $derived(workingChanges.length > 0 || (selected ? source !== selected.content : source.length > 0));
onMount(() => {
toolchain = new ConfigSourceToolchain();
mounted = true;
void reload();
return () => toolchain?.close();
return () => {
mounted = false;
toolchain?.close();
};
});
$effect(() => {
@@ -70,9 +75,20 @@
});
async function reload() {
loadState = "loading";
status = "Loading source tree…";
analysisReady = false;
analysisGeneration += 1;
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]) {
selectedPath = Object.keys(treeState.snapshot.entries).toSorted()[0] ?? "";
}
@@ -80,15 +96,19 @@
baseSnapshot = $state.snapshot(treeState.snapshot);
baseRevision = treeState.snapshot.revision;
baseDigest = treeState.snapshot.digest;
await toolchain?.setSnapshot(treeState.snapshot, treeState.contract.schema_bundle);
analysisReady = true;
workingChanges = [];
renamePath = selectedPath;
diagnostics = [];
conflict = false;
status = `Revision ${treeState.snapshot.revision} · ${treeState.snapshot.digest.slice(0, 20)}…`;
loadState = "ready";
} catch (error) {
if (!mounted) return;
toolchain?.close();
toolchain = null;
status = String(error);
loadState = "error";
}
}
@@ -213,8 +233,6 @@
}
async function discardAndReload() {
workingChanges = [];
source = "";
await reload();
}
@@ -287,7 +305,18 @@
}
</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">
<div class="config-source-tree__header">
<strong>Source tree</strong>
@@ -354,4 +383,12 @@
</ol>
{/if}
</div>
{/if}
</section>
<style>
.config-source-load {
grid-column: 1 / -1;
padding: 1rem;
}
</style>
@@ -11,6 +11,9 @@ import type {
} from "./types.ts";
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_ENTRY_COUNT = 256;
const MAX_ENTRY_BYTES = 256 * 1024;
@@ -193,7 +196,7 @@ function parseContribution(value: unknown): ConfigSchemaContribution {
provider_id: boundedString(item.provider_id),
namespace: boundedString(item.namespace),
version: boundedString(item.version),
source: boundedString(item.source),
source: boundedString(item.source, MAX_SCHEMA_SOURCE_BYTES),
...(projection === undefined ? {} : { projection_validator: projection }),
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"]);
return {
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),
};
}
@@ -6,6 +6,23 @@ import WorkerContextStatus from "./WorkerContextStatus.svelte";
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", () => {
const view = render(WorkerContextStatus, {
metadata: {
@@ -19,13 +36,13 @@ test("renders resolved model, reasoning, and measured context accessibly", () =>
const status = view.getByLabelText("Worker model and context");
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 () => {
const view = render(WorkerContextStatus, { metadata: null });
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%");
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("Context ~12.0k / 64.0k (19%)");
expect(view.container.textContent).toContain("~12.0k / 64.0k (19%)");
await view.rerender({
metadata: {
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ConsoleWorkerMetadata } from "./model";
import {
formatContextSummary,
@@ -7,23 +8,51 @@
type Props = {
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 contextSummary = $derived(formatContextSummary(metadata));
</script>
<div class="worker-context-status" aria-label="Worker model and context">
<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>
{#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>
<style>
.worker-context-status {
display: flex;
flex: 0 0 auto;
flex-wrap: nowrap;
min-width: 0;
overflow: hidden;
white-space: nowrap;
padding-inline: var(--space-3);
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
@@ -50,20 +79,44 @@
color: var(--line-strong);
}
@media (max-width: 640px) {
.worker-context-status {
width: 100%;
flex-wrap: wrap;
row-gap: 0;
}
.worker-context-actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-2);
margin-left: auto;
}
.separator {
display: none;
}
.details-button {
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,
.worker-context {
flex: 1 1 100%;
}
.details-button:hover,
.details-button[aria-expanded="true"] {
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>
@@ -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[] {
const entries = ids.flatMap((id): SessionSnapshotEntry[] => [{
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,
contextSource: "estimated",
}),
"Context ~142.0k / 272.0k (52%)",
"~142.0k / 272.0k (52%)",
);
assertEquals(formatModelSummary(null), "Model unavailable");
assertEquals(
@@ -64,7 +64,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely"
formatReasoning({ kind: "budget_tokens", budget_tokens: -2 }),
"reasoning unavailable",
);
assertEquals(formatContextSummary(null), "Context unavailable");
assertEquals(formatContextSummary(null), "unavailable");
assertEquals(
formatContextSummary({
model: null,
@@ -73,7 +73,7 @@ Deno.test("worker metadata formats effort, budget, estimate, and unknown safely"
contextTokens: 0,
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("parseSubjektivSubjectListResponse") &&
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"'),
"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}/memories`") &&
subjectPage.includes("DocumentMarkdown from") &&
subjectPage.includes("Resident surface") &&
subjectPage.includes("Resident context") &&
subjectPage.includes("Subject store revision") &&
subjectPage.includes("Current Memories") &&
subjectPage.includes("surface?.availability === 'ready'") &&
subjectPage.includes("surface?.availability === 'ungenerated'") &&
subjectPage.includes("surface?.availability === 'stale'") &&
subjectPage.includes("surface?.availability === 'failed'") &&
subjectPage.includes("data-surface-ready-empty") &&
!subjectPage.includes("overflow-y"),
"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 () => {
const files = [
"ComposerInput.svelte",
@@ -1551,14 +1582,10 @@ Deno.test("Composer owns TUI-style completion and replaces Compact/Rewind header
const input = await Deno.readTextFile(
new URL("./ComposerInput.svelte", import.meta.url),
);
const header = page.slice(
page.indexOf('<section class="console-header'),
page.indexOf("{#if rewindTargets.length"),
);
assert(
!header.includes("Compact") && !header.includes("Rewind") &&
!page.includes('class="console-header') &&
!page.includes("requestRewindTargets"),
"Header must defer Compact/Rewind to commands",
"Compact/Rewind must remain commands without a dedicated header",
);
assert(
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(
new URL("./ConsoleTasks.svelte", import.meta.url),
);
const header = page.slice(
page.indexOf('<section class="console-header'),
page.indexOf("{#if rewindTargets.length"),
);
assert(
!header.includes("taskPaneOpen = !taskPaneOpen") &&
!page.includes('class="console-header') &&
page.includes("onTogglePane={() => {"),
"Only the mini summary should own Tasks toggling",
);
@@ -36,11 +36,11 @@ export function formatContextSummary(
used === null || used === undefined || limit === null ||
limit === undefined || !Number.isSafeInteger(used) ||
!Number.isSafeInteger(limit) || used < 0 || limit <= 0
) return "Context unavailable";
) return "unavailable";
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" ? "~" : "";
return `Context ${estimate}${formatRunTokens(used)} / ${
return `${estimate}${formatRunTokens(used)} / ${
formatRunTokens(limit)
} (${percent}%)`;
}
+107 -5
View File
@@ -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 type {
Diagnostic,
@@ -26,6 +29,7 @@ import type {
SubjektivResidentSurfaceAvailability,
SubjektivResidentSurfaceResponse,
SubjektivResidentSurfaceSnapshot,
SubjektivSubjectBehaviorUpdateRequest,
SubjektivSubjectCreateRequest,
SubjektivSubjectListResponse,
SubjektivSubjectResponse,
@@ -84,6 +88,7 @@ export const MEMORY_API_LOAD_POLICY = {
} as const;
const MAX_SUBJECT_ROLE_BYTES = 256;
export const MAX_SUBJECT_BEHAVIOR_BYTES = SUBJEKTIV_API_LIMITS.maxBehaviorBytes;
const MAX_SUBJECT_CREATE_ERROR_BYTES = 1024 * 1024;
export type SubjektivSubjectCreateErrorKind =
@@ -126,7 +131,31 @@ export function validateSubjektivSubjectCreateRequest(
"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(
@@ -170,8 +199,11 @@ export async function createSubjektivSubject(
const subject = parseSubjektivSubjectResponse(
await readBoundedJson(response, MEMORY_API_LIMITS.maxResponseBytes),
);
if (subject.role !== validated.role) {
throw new Error("created Subject role does not match the request");
if (
subject.role !== validated.role ||
subject.behavior_md !== validated.behavior_md
) {
throw new Error("created Subject does not match the request");
}
return subject;
} 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(
response: Response,
): Promise<string> {
@@ -202,7 +296,7 @@ async function subjektivSubjectCreateErrorMessage(
} catch {
// 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(
@@ -454,6 +548,8 @@ export function parseSubjektivSubjectResponse(
[
"id",
"role",
"behavior_md",
"behavior_revision",
"state",
"store_revision",
"created_at",
@@ -472,6 +568,12 @@ export function parseSubjektivSubjectResponse(
const result: SubjektivSubjectResponse = {
id,
role: requiredString(record, "role"),
behavior_md: requiredString(
record,
"behavior_md",
MAX_SUBJECT_BEHAVIOR_BYTES,
),
behavior_revision: requiredNonNegativeInteger(record, "behavior_revision"),
state,
store_revision: requiredNonNegativeInteger(record, "store_revision"),
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 CompletionResult,
} from '@codemirror/autocomplete';
import { indentWithTab, insertNewlineAndIndent } from '@codemirror/commands';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view';
@@ -129,6 +130,8 @@
decodal({ highlight: false }),
syntaxHighlighting(syntaxTheme),
autocompletion({
// Use the explicit keymap below so Enter never accepts a suggestion.
defaultKeymap: false,
activateOnTyping: false,
override: [
async (context: CompletionContext) => {
@@ -139,6 +142,10 @@
],
}),
keymap.of(completionKeymapWithoutEnter),
keymap.of([
{ key: 'Enter', run: insertNewlineAndIndent },
indentWithTab,
]),
fixedSchemaWrapperCompartment.of(
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
),
@@ -1,5 +1,6 @@
<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 SidebarToggleIcon from './SidebarToggleIcon.svelte';
import './sidebar.css';
@@ -77,6 +78,19 @@
focusInside = false;
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);
</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 ?? []);
let createExpanded = $state(false);
let role = $state('');
let behaviorMd = $state('');
let submitting = $state(false);
let createError = $state<string | null>(null);
let roleInvalid = $state(false);
let behaviorInvalid = $state(false);
let createOutcomeUnknown = $state(false);
let createdSubject = $state<SubjektivSubjectResponse | null>(null);
let createButton = $state<HTMLButtonElement>();
let roleInput = $state<HTMLInputElement>();
let behaviorInput = $state<HTMLTextAreaElement>();
function subjectHref(subjectId: string): string {
return workspaceRoute(data.workspaceId, `/memory/${encodeURIComponent(subjectId)}`);
@@ -31,10 +34,15 @@
return cursor ? `${path}?cursor=${encodeURIComponent(cursor)}` : path;
}
function subjectStateLabel(state: SubjektivSubjectResponse['state']): string {
return state === 'active' ? 'Available' : 'Retired';
}
async function openCreateForm(): Promise<void> {
createExpanded = true;
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
await tick();
@@ -45,8 +53,10 @@
if (submitting) return;
createExpanded = false;
role = '';
behaviorMd = '';
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
createdSubject = null;
await tick();
@@ -58,9 +68,13 @@
submitting = true;
createError = null;
roleInvalid = false;
behaviorInvalid = false;
createOutcomeUnknown = false;
try {
const subject = await createSubjektivSubject(fetch, data.workspaceId, { role });
const subject = await createSubjektivSubject(fetch, data.workspaceId, {
role,
behavior_md: behaviorMd,
});
createdSubject = subject;
try {
await goto(subjectHref(subject.id));
@@ -69,14 +83,16 @@
}
} catch (error) {
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';
createError = error.status === 401 || error.status === 403
? 'You do not have permission to create Subjects in this Workspace.'
: error.message;
if (roleInvalid) {
if (roleInvalid || behaviorInvalid) {
await tick();
roleInput?.focus();
if (behaviorInvalid) behaviorInput?.focus();
else roleInput?.focus();
}
} else {
createOutcomeUnknown = true;
@@ -109,7 +125,7 @@
</div>
<div class="memory-header-actions">
{#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}
<button
bind:this={createButton}
@@ -145,9 +161,22 @@
disabled={submitting || createdSubject !== null}
onkeydown={createFormKeydown}
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 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}
<div
id="subject-create-error"
@@ -184,14 +213,19 @@
{#each subjects as subject (subject.id)}
<a class="subject-row" href={subjectHref(subject.id)}>
<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>
<code title={subject.id}>{subject.id}</code>
</div>
<dl class="subject-meta">
<div><dt>Store revision</dt><dd>{subject.store_revision}</dd></div>
<div><dt>Updated</dt><dd><time datetime={subject.updated_at}>{formatDate(subject.updated_at)}</time></dd></div>
<div><dt>Worker</dt><dd>{subject.current_worker?.display_name ?? 'None'}</dd></div>
<div>
<dt>Worker connection</dt>
<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>
</a>
{/each}
@@ -309,20 +343,25 @@
}
.subject-create-actions {
grid-column: 3;
grid-row: 1;
grid-column: 2;
}
.subject-create-panel {
display: grid;
grid-template-columns: minmax(14rem, 0.8fr) minmax(18rem, 1fr) auto;
align-items: end;
grid-template-columns: minmax(14rem, 0.7fr) minmax(18rem, 1.3fr);
align-items: start;
gap: var(--space-4);
padding: var(--space-4);
background: var(--bg-raised);
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 p,
.subject-create-error p {
@@ -344,7 +383,8 @@
font-weight: 700;
}
.subject-role-field input {
.subject-role-field input,
.subject-role-field textarea {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
@@ -354,7 +394,15 @@
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);
}
@@ -426,8 +474,8 @@
.subject-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-3);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
align-self: center;
}
@@ -441,12 +489,19 @@
}
.subject-meta dd {
display: grid;
gap: var(--space-1);
margin-top: var(--space-1);
color: var(--text-muted);
font-size: var(--font-size-compact);
overflow-wrap: anywhere;
}
.subject-meta dd strong {
color: var(--text);
font-weight: 700;
}
.memory-state {
padding: var(--space-5) 0;
color: var(--text-muted);
@@ -480,6 +535,7 @@
align-items: stretch;
}
.subject-create-copy,
.subject-create-error,
.subject-create-actions {
grid-column: 1;
@@ -1,11 +1,91 @@
<script lang="ts">
import { untrack } from 'svelte';
import DocumentMarkdown from '#lib/workspace/markdown/DocumentMarkdown.svelte';
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';
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 snapshot = $derived(surface?.snapshot ?? null);
const memories = $derived(data.memories.data?.items ?? []);
@@ -36,10 +116,28 @@
function kindLabel(kind: string): string {
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>
<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" />
</svelte:head>
@@ -49,20 +147,57 @@
<header class="memory-page-header">
<div>
<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>
</div>
<span class="read-only-label">Read-only</span>
<span class="read-only-label">Behavior editable</span>
</header>
{#if data.subject.data}
<dl class="subject-facts" aria-label="Subject details">
<div><dt>Role</dt><dd>{data.subject.data.role}</dd></div>
<div><dt>State</dt><dd><span class="subject-state is-{data.subject.data.state}">{data.subject.data.state}</span></dd></div>
<div><dt>Store revision</dt><dd>{data.subject.data.store_revision}</dd></div>
<div><dt>Updated</dt><dd><time datetime={data.subject.data.updated_at}>{formatDate(data.subject.data.updated_at)}</time></dd></div>
<div><dt>Current worker</dt><dd>{data.subject.data.current_worker?.display_name ?? 'None'}</dd></div>
</dl>
{#if subject}
<section class="subject-overview" aria-label="Subject status">
<div>
<span>Subject status</span>
<strong class="subject-state is-{subject.state}">{subjectStateLabel(subject.state)}</strong>
<p>{subject.state === 'active' ? 'Available for new Worker connections.' : 'No longer available for new Worker connections.'}</p>
</div>
<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}
<div class="memory-state is-error" role="alert">
<strong>Subject details unavailable.</strong>
@@ -72,41 +207,77 @@
<div class="memory-state" role="status"><p>Subject details are unavailable.</p></div>
{/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">
<header class="section-heading">
<div>
<p class="memory-eyebrow">Resident context</p>
<h2 id="resident-surface-heading">Resident surface</h2>
<p class="memory-eyebrow">Generated from committed Memory</p>
<h2 id="resident-surface-heading">Resident context</h2>
</div>
{#if surface}
<span class="availability is-{surface.availability}">{surface.availability}</span>
<span class="availability is-{surface.availability}">{surfaceLabel(surface.availability)}</span>
{/if}
</header>
{#if surface?.availability === 'ready' && snapshot}
<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>
<time datetime={snapshot.created_at}>{formatDate(snapshot.created_at)}</time>
<span aria-hidden="true">·</span>
<span>{snapshot.memory_refs.length} Memory ref{snapshot.memory_refs.length === 1 ? '' : 's'}</span>
<span>{snapshot.memory_refs.length} referenced Memory {snapshot.memory_refs.length === 1 ? 'record' : 'records'}</span>
</div>
{#if snapshot.body_md.trim().length === 0}
<div class="memory-state" role="status" data-surface-ready-empty>
<strong>Resident surface is ready and empty.</strong>
<p>This subject currently has no resident context to display.</p>
<strong>Resident context is current but empty.</strong>
<p>A generated surface exists, but it contains no context to show. Committed Memories, if any, remain listed below.</p>
</div>
{:else}
<article class="surface-document" aria-label="Resident surface">
<article class="surface-document" aria-label="Resident context">
<DocumentMarkdown text={snapshot.body_md} />
</article>
{/if}
<details class="surface-details">
<summary>Surface provenance</summary>
<summary>Surface sources and diagnostics</summary>
<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>
<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>
{#if snapshot.memory_refs.length === 0}
None
@@ -123,26 +294,26 @@
</details>
{:else if surface?.availability === 'ungenerated'}
<div class="memory-state" role="status">
<strong>Resident surface has not been generated.</strong>
<p>Committed Memories remain available below.</p>
<strong>Resident context has not been generated.</strong>
<p>Committed Memories, if any, remain available below.</p>
</div>
{:else if surface?.availability === 'stale'}
<div class="memory-state is-warning" role="status">
<strong>Resident surface is stale.</strong>
<p>The latest committed Memories are not represented by a publishable snapshot.</p>
<strong>Resident context needs to be refreshed.</strong>
<p>The latest committed Memories are not represented by a current surface, so no generated context is shown.</p>
</div>
{:else if surface?.availability === 'failed'}
<div class="memory-state is-error" role="alert">
<strong>Resident surface generation failed.</strong>
<p>No failed or partial snapshot is displayed.</p>
<strong>Resident context generation failed.</strong>
<p>No failed or partial surface is shown. Committed Memories remain available below.</p>
</div>
{:else if data.surface.error}
<div class="memory-state is-error" role="alert">
<strong>Resident surface unavailable.</strong>
<strong>Resident context status unavailable.</strong>
<p>{data.surface.error}</p>
</div>
{: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}
</section>
@@ -152,14 +323,14 @@
<p class="memory-eyebrow">Committed records</p>
<h2 id="current-memories-heading">Current Memories</h2>
</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>
{#if data.memories.data}
{#if memories.length === 0}
<div class="memory-state" role="status" data-memories-empty>
<strong>No committed Memories.</strong>
<p>This subject has no current Memory revisions.</p>
<strong>{data.cursor ? 'No committed Memories on this page.' : 'No committed Memories yet.'}</strong>
<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}
</div>
{:else}
@@ -175,9 +346,9 @@
<p>{memory.excerpt || 'No excerpt.'}</p>
</div>
<dl>
<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>Updated</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
<div><dt>Memory ID</dt><dd><code title={memory.id}>{memory.id}</code></dd></div>
<div><dt>Memory revision</dt><dd>{memory.revision}</dd></div>
<div><dt>Last changed</dt><dd><time datetime={memory.updated_at}>{formatDate(memory.updated_at)}</time></dd></div>
</dl>
</a>
{/each}
@@ -270,25 +441,79 @@
font-size: var(--font-size-compact);
}
.subject-facts {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-3);
.subject-overview {
display: grid;
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 {
display: block;
.subject-overview > div {
display: grid;
align-content: start;
gap: var(--space-1);
min-width: 0;
}
.subject-facts dt {
white-space: normal;
.subject-overview span,
.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 {
margin-top: var(--space-1);
color: var(--text);
.subject-overview strong {
color: var(--text-strong);
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,
.availability,
.memory-pill,
@@ -320,6 +545,7 @@
color: var(--danger);
}
.behavior-section,
.surface-section,
.memories-section {
display: grid;
@@ -328,6 +554,78 @@
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,
.pill-row {
display: flex;
@@ -455,7 +753,7 @@
}
@media (max-width: 900px) {
.subject-facts {
.subject-overview {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -472,10 +770,16 @@
gap: var(--space-2);
}
.subject-facts {
.subject-overview,
.subject-technical-details dl,
.surface-details dl {
grid-template-columns: minmax(0, 1fr);
}
.subject-overview {
padding: var(--space-3);
}
.memory-row {
padding-inline: 0;
}
@@ -63,6 +63,11 @@
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 {
return range ? `${range[0]}–${range[1]}` : 'None';
}
@@ -98,8 +103,8 @@
<header class="memory-page-header">
<div>
<div class="pill-row">
<span class="memory-pill is-{memory.state}">{memory.state}</span>
<span class="memory-kind">{label(memory.kind)}</span>
<span class="memory-pill is-{memory.state}">{titleLabel(memory.state)}</span>
<span class="memory-kind">{titleLabel(memory.kind)}</span>
{#if memory.revision !== memory.current_revision}<span class="historical-pill">Historical revision</span>{/if}
</div>
<h1 id="memory-detail-heading">{memory.claim}</h1>
@@ -109,11 +114,11 @@
</header>
<dl class="memory-facts" aria-label="Memory details">
<div><dt>Revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
<div><dt>State</dt><dd>{memory.state}</dd></div>
<div><dt>Kind</dt><dd>{label(memory.kind)}</dd></div>
<div><dt>Memory revision</dt><dd>{memory.revision} of {memory.current_revision}</dd></div>
<div><dt>Status</dt><dd>{titleLabel(memory.state)}</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>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>
<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">
<header class="section-heading">
<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>
{#if memory.source_candidates.length === 0}
@@ -226,7 +231,11 @@
</section>
<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 revisions.length === 0}
<div>
@@ -238,7 +247,7 @@
{#each revisions as revision (revision.revision)}
<li class:current={revision.revision === memory.revision}>
<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>
<small>{revision.change_reason} · <time datetime={revision.updated_at}>{formatDate(revision.updated_at)}</time></small>
</a>
@@ -386,6 +395,7 @@
}
.memory-facts {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--space-3);
}
@@ -414,6 +424,7 @@
.why-useful,
.staleness,
.change-reason,
.section-intro,
.empty-copy,
.bounded-note {
margin: 0;
@@ -34,6 +34,8 @@ function subject() {
return {
id: "subject-1",
role: "Release coordinator",
behavior_md: "Prefer explicit evidence.",
behavior_revision: 2,
state: "active" as const,
store_revision: 9,
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
.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"))
.toBe("/w/workspace-1/memory");
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;
await waitFor(() => expect(document.activeElement).toBe(roleInput));
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" });
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({
id: "generated-subject-1",
role: " Review lead ",
behavior_md: "Challenge unsupported assumptions.",
behavior_revision: 0,
state: "active",
store_revision: 0,
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(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 () => {
@@ -146,7 +161,7 @@ test("keeps creation available for an empty list and restores focus on cancel",
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(() =>
Promise.resolve(Response.json(
{
@@ -172,6 +187,20 @@ test("preserves invalid and rejected role drafts with accessible errors", async
expect(fetchMock).not.toHaveBeenCalled();
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 waitFor(() => {
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",
subjectId: "subject-1",
cursor: "memory-current",
subject: result(subject()),
subject: result({
...subject(),
current_worker: { display_name: "Release Worker" },
}),
surface: result({
subject_id: "subject-1",
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 }))
.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();
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,
);
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");
});
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 () => {
const base = {
workspaceId: "workspace-1",
@@ -303,8 +699,25 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
}),
},
} as never);
expect(screen.getByText("Resident surface is ready and empty.")).not
expect(screen.getByText("Resident context is current but empty.")).not
.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(
{
@@ -317,7 +730,8 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
},
} 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(
{
@@ -333,7 +747,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
expect(screen.getByRole("alert").textContent).toContain("generation failed");
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();
await view.rerender(
@@ -342,7 +756,7 @@ test("distinguishes ready-empty, stale, failed, unavailable, and request error s
} as never,
);
expect(screen.getByRole("alert").textContent).toContain(
"Resident surface unavailable",
"Resident context status unavailable",
);
});
@@ -1859,45 +1859,6 @@
</svelte:head>
<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}
<section class="card rewind-targets" aria-label="Rewind targets">
<h3>Rewind targets</h3>
@@ -2324,6 +2285,28 @@
</div>
</div>
</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>
<style>
@@ -2349,14 +2332,6 @@
overscroll-behavior: contain;
}
.console-header {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.console-card {
display: grid;
align-content: start;
@@ -2364,46 +2339,59 @@
min-height: 100%;
}
.console-header-actions {
display: flex;
.console-view-toggle {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-2);
}
.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 {
gap: var(--space-1);
min-height: var(--space-5);
padding: 0;
border: 0;
border-radius: var(--space-1);
background: transparent;
color: var(--text-muted);
padding: var(--space-1) var(--space-2);
font: inherit;
font-family: var(--font-sans);
font-size: var(--font-size-compact);
line-height: var(--line-height-compact);
font-weight: 700;
font-weight: 400;
cursor: pointer;
}
.console-view-modes button + button {
border-left: 1px solid var(--line);
}
.console-view-modes button:hover {
.console-view-toggle:hover {
color: var(--text-strong);
}
.console-view-modes button.active {
background: var(--accent);
color: var(--bg);
.console-view-toggle:focus-visible {
outline: 1px solid var(--text-strong);
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 {
@@ -2907,14 +2895,5 @@
grid-template-columns: 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>
@@ -530,13 +530,28 @@ test.each(["live_protocol", "retained_snapshot"] as const)("overlapping history
await screen.findByText("Start of conversation");
expect(view.container.querySelectorAll(".activity-summary")).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 ids = [...transcript.querySelectorAll("[data-console-line-id]")].map((line) => line.getAttribute("data-console-line-id"));
expect(ids).toHaveLength(3);
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(multiplexer.sendWorkerMethod).not.toHaveBeenCalled();
});
test.each([
@@ -744,7 +759,7 @@ test("mini task summary opens and closes details without a header Tasks button",
latestListener().onFrame(subscribedFrame(session));
const summary = await screen.findByRole("button", { name: /1 task — pending/ });
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" }));
const details = await screen.findByRole("complementary", { name: "Worker detail" });
expect(details.textContent).not.toContain("Capabilities");
+89 -2
View File
@@ -71,18 +71,105 @@ Deno.test("config source API commits directly through the workspace scope", asyn
entrypoints: [],
}, 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/revisions/7",
"/api/w/w%2Fone/config/source-tree/entries/profiles%2Fmain.dcdl",
"/api/w/w%2Fone/config/source-tree/commit",
]);
assertEquals(calls[3].init?.method, "POST");
assertEquals<unknown>(calls[3].init?.method, "POST");
assert(
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 () => {
const fetcher =
(() =>
+125 -11
View File
@@ -12,6 +12,7 @@ import {
parseSubjektivSubjectListResponse,
parseSubjektivSubjectResponse,
SubjektivSubjectCreateError,
updateSubjektivSubjectBehavior,
validateSubjektivSubjectCreateRequest,
} from "../src/lib/workspace/memory/api.ts";
@@ -61,9 +62,7 @@ async function assertSubjectCreateRejects(
assertEquals(error.status, expectedStatus);
if (!error.message.includes(expectedMessage)) {
throw new Error(
`expected ${JSON.stringify(error.message)} to include ${
JSON.stringify(expectedMessage)
}`,
`expected ${JSON.stringify(error.message)} to include ${JSON.stringify(expectedMessage)}`,
);
}
return;
@@ -75,6 +74,8 @@ function subject(id = "subject-1") {
return {
id,
role: "Release coordinator",
behavior_md: "Prefer explicit evidence.",
behavior_revision: 2,
state: "active",
store_revision: 12,
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") {
return {
id,
@@ -187,14 +212,35 @@ Deno.test("Subject parsers enforce identity, exact enums, safe revisions, and bo
() =>
parseSubjektivSubjectListResponse({
limit: 100,
items: Array.from({ length: 101 }, (_, index) =>
subject(`subject-${index}`)),
items: Array.from({ length: 101 }, (_, index) => subject(`subject-${index}`)),
has_more: false,
}),
"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", () => {
const response = { limit: 100, items: [subject()], has_more: false };
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", () => {
assertEquals(
validateSubjektivSubjectCreateRequest({ role: " Release coordinator " }),
{ role: " Release coordinator " },
{ role: " Release coordinator ", behavior_md: "" },
);
assertThrows(
() => validateSubjektivSubjectCreateRequest({ role: " " }),
@@ -242,6 +288,70 @@ Deno.test("Subject create request mirrors Backend role validation without rewrit
() => validateSubjektivSubjectCreateRequest({ role: "界".repeat(86) }),
"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 () => {
@@ -251,20 +361,26 @@ Deno.test("Subject create client sends the exact typed body once and parses the
return Response.json({
...subject("subject-created"),
role: " Release coordinator ",
behavior_md: "Stay concise.",
}, { status: 201 });
}) as typeof fetch;
const created = await createSubjektivSubject(fetchFn, "workspace one", {
role: " Release coordinator ",
behavior_md: "Stay concise.",
});
assertEquals(created, {
...subject("subject-created"),
role: " Release coordinator ",
behavior_md: "Stay concise.",
});
assertEquals(requests.length, 1);
assertEquals(requests[0].path, "/api/w/workspace%20one/subjektiv/subjects");
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 () => {
@@ -328,8 +444,7 @@ Deno.test("Subject create client distinguishes rejected and unknown outcomes wit
throw new TypeError("connection reset");
}) as typeof fetch;
await assertSubjectCreateRejects(
() =>
createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
() => createSubjektivSubject(unknownFetch, "workspace-1", { role: "Reviewer" }),
"unknown_outcome",
"may have been created",
);
@@ -433,8 +548,7 @@ Deno.test("Current Memory list parser rejects unknown variants, unsafe integers,
assertThrows(
() =>
parseSubjektivMemoryQueryResponse({
items: Array.from({ length: 101 }, (_, index) =>
queryItem(`memory-${index}`)),
items: Array.from({ length: 101 }, (_, index) => queryItem(`memory-${index}`)),
has_more: false,
}),
"bounded array",