Compare commits
49
Commits
e5ea7544d4
...
e8828cd1b5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8828cd1b5 | ||
|
|
73ac13e802 | ||
|
|
e7b1aa2a6a | ||
|
|
c550abb11b | ||
|
|
caac1c18e0 | ||
|
|
5831190012 | ||
|
|
b1dc8f9f1c | ||
|
|
4d266420fc | ||
|
|
b02eb7bee3 | ||
|
|
ed50847afe | ||
|
|
ef642b34be | ||
|
|
e4daa4aaa7 | ||
|
|
c889c9a68d | ||
|
|
a91eec295f | ||
|
|
4337f415f9 | ||
|
|
34f9e70ce8 | ||
|
|
e6aa56ff6f | ||
|
|
3a2994cdd7 | ||
|
|
e94af40dab | ||
|
|
b8faa83c47 | ||
|
|
c79506b60f | ||
|
|
e90e2ab391 | ||
|
|
61afdb7773 | ||
|
|
8affa46bce | ||
|
|
cb8784c108 | ||
|
|
e67d75615d | ||
|
|
7bb4096373 | ||
|
|
5244308c0d | ||
|
|
717ff2555f | ||
|
|
d4ed6192ea | ||
|
|
ea1badc0be | ||
|
|
f1287f27e7 | ||
|
|
af655ae632 | ||
|
|
6445999120 | ||
|
|
37a13e3566 | ||
|
|
32c2227e8e | ||
|
|
edf5368e09 | ||
|
|
f9d4c97f1e | ||
|
|
5480421fcc | ||
|
|
c1b3b95c5d | ||
|
|
23cf6c4f88 | ||
|
|
85c9b218dd | ||
|
|
b5ea162f83 | ||
|
|
9da79ab9f0 | ||
|
|
60ec815237 | ||
|
|
3637824c8c | ||
|
|
bb1b05ed20 | ||
|
|
633522278b | ||
|
|
761de69e18 |
Generated
+70
-6
@@ -1967,6 +1967,12 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.10"
|
||||
@@ -3025,6 +3031,32 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object_store"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622acbc9100d3c10e2ee15804b0caa40e55c933d5aa53814cd520805b7958a49"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"humantime",
|
||||
"itertools",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"walkdir",
|
||||
"wasm-bindgen-futures",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.7.1"
|
||||
@@ -6308,9 +6340,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wip-client"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73a82416411af69b7eb4ad0ed9a954df062769d3a03b3a1c59b0b4bf617f3534"
|
||||
checksum = "def7dc8d056477bf8cb7c12f2dc70cabdb3eee3527cf95ba6131a8a44519ed7b"
|
||||
dependencies = [
|
||||
"wip-http",
|
||||
"wip-protocol",
|
||||
@@ -6318,9 +6350,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wip-http"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec0d32d1e56e52fb9ce925db808354a3432b3ebbd112fab8c443c9b865c58c6"
|
||||
checksum = "da1f6a0023c0c599cbdc5571e9b131c8bb6b113ef04c20467d77634d66b44a5a"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"http",
|
||||
@@ -6332,9 +6364,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wip-protocol"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "275ffaebf544a077de7578fd3674c062a098a72c5a904cf66e934f76bd9efbab"
|
||||
checksum = "5a23ee0fe02badd2ef77847652cff241c6d868c8f162a911475500831ff348fe"
|
||||
|
||||
[[package]]
|
||||
name = "wip-text-view"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c95fd8e3fba673de44084c2f89c11b50d744c6db73726c50e04975c974d2759c"
|
||||
dependencies = [
|
||||
"wip-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wiremock"
|
||||
@@ -6518,6 +6559,7 @@ dependencies = [
|
||||
"wip-client",
|
||||
"wip-http",
|
||||
"wip-protocol",
|
||||
"wip-text-view",
|
||||
"workdir",
|
||||
]
|
||||
|
||||
@@ -6533,6 +6575,7 @@ dependencies = [
|
||||
"flow",
|
||||
"fs4",
|
||||
"futures",
|
||||
"libc",
|
||||
"manifest",
|
||||
"protocol",
|
||||
"reqwest",
|
||||
@@ -6560,6 +6603,24 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "workspace-drive"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"feature-storage",
|
||||
"futures",
|
||||
"object_store",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.3"
|
||||
@@ -6666,6 +6727,7 @@ name = "yoi-workspace-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"agen",
|
||||
"api-macros",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -6706,9 +6768,11 @@ dependencies = [
|
||||
"uuid",
|
||||
"webauthn-authenticator-rs",
|
||||
"webauthn-rs",
|
||||
"wip-protocol",
|
||||
"workdir",
|
||||
"worker",
|
||||
"worker-runtime",
|
||||
"workspace-drive",
|
||||
"yoi",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
+14
-3
@@ -32,6 +32,7 @@ members = [
|
||||
"crates/tui",
|
||||
"crates/memory",
|
||||
"crates/feature-storage",
|
||||
"crates/workspace-drive",
|
||||
"crates/subjektiv",
|
||||
"crates/ticket",
|
||||
"crates/merge-request",
|
||||
@@ -71,6 +72,7 @@ default-members = [
|
||||
"crates/tui",
|
||||
"crates/memory",
|
||||
"crates/feature-storage",
|
||||
"crates/workspace-drive",
|
||||
"crates/subjektiv",
|
||||
"crates/ticket",
|
||||
"crates/merge-request",
|
||||
@@ -100,6 +102,7 @@ mcp = { path = "crates/mcp" }
|
||||
lint-common = { path = "crates/lint-common" }
|
||||
memory = { path = "crates/memory" }
|
||||
feature-storage = { path = "crates/feature-storage" }
|
||||
workspace-drive = { path = "crates/workspace-drive" }
|
||||
subjektiv = { path = "crates/subjektiv" }
|
||||
merge-request = { path = "crates/merge-request" }
|
||||
ticket = { path = "crates/ticket" }
|
||||
@@ -158,8 +161,16 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
url = "2.5"
|
||||
uuid = "1.23"
|
||||
wip-client = "0.1.0"
|
||||
wip-http = "0.1.0"
|
||||
wip-protocol = "0.1.0"
|
||||
# Released structured scope/lifetime contract and canonical Text View, one registry source.
|
||||
wip-client = "=0.2.0"
|
||||
wip-http = "=0.2.0"
|
||||
wip-protocol = "=0.2.0"
|
||||
wip-text-view = "=0.2.0"
|
||||
zeroize = "1"
|
||||
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
|
||||
|
||||
[workspace.metadata.wip-upstream]
|
||||
repository = "https://gitea.hareworks.net/Hare/wip-rs"
|
||||
version = "0.2.0"
|
||||
reference-revision = "6086f3c5ef10aa1464ce9c824750d7f667217bfd"
|
||||
source = "crates.io"
|
||||
|
||||
+82
-28
@@ -3199,6 +3199,55 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Correct one candidate actually sent in the rejected request. Commit the
|
||||
/// durable correction before changing either in-memory projection or retrying.
|
||||
fn recover_rejected_image(
|
||||
&self,
|
||||
history: &mut History<A>,
|
||||
request: &mut Request,
|
||||
) -> Result<bool, EngineError> {
|
||||
let Some((history_index, request_index)) =
|
||||
crate::image_recovery::largest_candidate(history, request)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
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();
|
||||
// Retain the original request's prune/interceptor decisions.
|
||||
request.items[request_index] = replacement;
|
||||
self.emit_warning(crate::image_recovery::REJECTION_MESSAGE);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Opening a corrected request can itself yield an HTTP image rejection.
|
||||
/// Each successful correction removes one candidate, bounding this loop.
|
||||
async fn open_stream_with_image_recovery(
|
||||
&mut self,
|
||||
request: &mut Request,
|
||||
turn: usize,
|
||||
llm_call: usize,
|
||||
history: &mut History<A>,
|
||||
) -> Result<ResponseStream, EngineError> {
|
||||
loop {
|
||||
match self
|
||||
.open_stream_with_retry(request.clone(), turn, llm_call)
|
||||
.await
|
||||
{
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(EngineError::Client(error)) if error.is_image_size_rejection() => {
|
||||
if !self.recover_rejected_image(history, request)? {
|
||||
return Err(EngineError::Client(error));
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a stream, dispatch all events to the timeline, handle cancellation.
|
||||
async fn stream_response(
|
||||
&mut self,
|
||||
@@ -3217,37 +3266,14 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
);
|
||||
|
||||
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),
|
||||
}
|
||||
};
|
||||
let stream = self
|
||||
.open_stream_with_image_recovery(&mut request, turn, llm_call, history)
|
||||
.await?;
|
||||
self.tool_call_collector.begin_response();
|
||||
let mut stream = ResponseStreamPump::start(stream);
|
||||
let mut early_tools: Option<EarlyToolExecutionBatch> = None;
|
||||
let mut response_completed = false;
|
||||
let mut response_progress = false;
|
||||
|
||||
let mut event_count: usize = 0;
|
||||
loop {
|
||||
@@ -3285,6 +3311,35 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
json!({}),
|
||||
);
|
||||
}
|
||||
self.emit_stream_event(turn, llm_call, &event);
|
||||
// Only pre-output provider rejection can safely replay the
|
||||
// original request. Even an empty block start closes this
|
||||
// window, before collectors or early tool admission run.
|
||||
// Unknown events are conservatively not replayable either.
|
||||
match &event {
|
||||
Event::Ping(_) | Event::Usage(_) | Event::Error(_) => {}
|
||||
Event::Status(StatusEvent {
|
||||
status: crate::llm_client::event::ResponseStatus::Started,
|
||||
}) => {}
|
||||
_ => response_progress = true,
|
||||
}
|
||||
if let Event::Error(error) = &event {
|
||||
if !response_progress && error.is_image_size_rejection() {
|
||||
self.timeline.flush_usage();
|
||||
if self.recover_rejected_image(history, &mut request)? {
|
||||
// Drop the old pump (including queued failed status)
|
||||
// before opening the corrected request. No collector or
|
||||
// tool state exists for this pre-output attempt.
|
||||
drop(stream);
|
||||
stream = ResponseStreamPump::start(self.open_stream_with_image_recovery(
|
||||
&mut request, turn, llm_call, history,
|
||||
).await?);
|
||||
self.tool_call_collector.begin_response();
|
||||
event_count = 0;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
&event,
|
||||
Event::Status(StatusEvent {
|
||||
@@ -3293,7 +3348,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
) {
|
||||
response_completed = true;
|
||||
}
|
||||
self.emit_stream_event(turn, llm_call, &event);
|
||||
self.timeline.dispatch(&event);
|
||||
if early_dispatch {
|
||||
let collected = self.tool_call_collector.take_collected_with_index();
|
||||
|
||||
@@ -103,20 +103,7 @@ impl ClientError {
|
||||
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")))
|
||||
is_image_size_diagnostic(code.as_deref(), message)
|
||||
}
|
||||
|
||||
pub fn retry_after(&self) -> Option<Duration> {
|
||||
@@ -127,6 +114,29 @@ impl ClientError {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared diagnostic policy for HTTP rejections and provider Error events.
|
||||
/// The caller must establish the rejection path and whether replay is safe;
|
||||
/// this predicate alone never authorizes a retry.
|
||||
pub(crate) fn is_image_size_diagnostic(code: Option<&str>, message: &str) -> bool {
|
||||
if matches!(code, Some("image_too_large" | "image_size_exceeded")) {
|
||||
return true;
|
||||
}
|
||||
// OpenAI Responses appends structured diagnostic details for observation.
|
||||
// Extra fields (including echoed input) are not the error message itself.
|
||||
let message = message
|
||||
.split(" | diagnostic=")
|
||||
.next()
|
||||
.unwrap_or(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")))
|
||||
}
|
||||
|
||||
/// transient な失敗としてリトライ対象になるかを判定する。
|
||||
///
|
||||
/// 対象:
|
||||
|
||||
@@ -109,6 +109,15 @@ pub struct ErrorEvent {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ErrorEvent {
|
||||
/// A provider Error event has no HTTP status. Classify its diagnostic
|
||||
/// explicitly, rather than making every status-less ClientError eligible.
|
||||
/// The engine must separately establish that no response output was seen.
|
||||
pub(crate) fn is_image_size_rejection(&self) -> bool {
|
||||
super::error::is_image_size_diagnostic(self.code.as_deref(), &self.message)
|
||||
}
|
||||
}
|
||||
|
||||
/// 未対応 SSE イベントの観測用メタイベント。
|
||||
///
|
||||
/// `data_preview` は provider から受け取った raw SSE data の bounded preview、
|
||||
|
||||
@@ -753,17 +753,31 @@ pub(crate) fn parse_sse(
|
||||
}
|
||||
|
||||
"error" => {
|
||||
let ev = from_json::<TopLevelErrorEnvelope>(data).unwrap_or_else(|_| {
|
||||
TopLevelErrorEnvelope {
|
||||
// Responses documents flat error frames (code/message/param),
|
||||
// while providers also send nested error envelopes. Normalize both
|
||||
// structurally so classification never scans arbitrary raw JSON.
|
||||
let ev = from_json::<TopLevelErrorEnvelope>(data)
|
||||
.or_else(|_| {
|
||||
from_json::<TopLevelError>(data).map(|error| TopLevelErrorEnvelope {
|
||||
error,
|
||||
extra: BTreeMap::new(),
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|_| TopLevelErrorEnvelope {
|
||||
error: TopLevelError {
|
||||
message: Some(data.to_string()),
|
||||
// Raw data is observation, not a provider diagnostic that
|
||||
// can authorize request recovery. Keep it out of the base
|
||||
// message even when it contains image-size wording.
|
||||
message: Some("OpenAI Responses error with unparseable data".to_string()),
|
||||
error_type: None,
|
||||
code: None,
|
||||
extra: BTreeMap::new(),
|
||||
extra: BTreeMap::from([(
|
||||
"raw_data".to_string(),
|
||||
Value::String(data.to_string()),
|
||||
)]),
|
||||
},
|
||||
extra: BTreeMap::new(),
|
||||
}
|
||||
});
|
||||
});
|
||||
let (code, message) = top_level_error_diagnostic(ev);
|
||||
Ok(vec![Event::Error(ErrorEvent { code, message })])
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
||||
use agen::llm_client::scheme::{Scheme, openai_responses::OpenAIResponsesScheme};
|
||||
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 futures::StreamExt;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn rejection() -> ClientError {
|
||||
@@ -48,11 +51,28 @@ fn images(request: &Request) -> Vec<String> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum RejectionPath {
|
||||
Http,
|
||||
SseError,
|
||||
SseNestedError,
|
||||
ResponseFailed,
|
||||
}
|
||||
const STREAM_PATHS: [RejectionPath; 3] = [
|
||||
RejectionPath::SseError,
|
||||
RejectionPath::SseNestedError,
|
||||
RejectionPath::ResponseFailed,
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Client {
|
||||
requests: Arc<Mutex<Vec<Request>>>,
|
||||
failures: usize,
|
||||
other_error: bool,
|
||||
path: RejectionPath,
|
||||
prefix: Vec<(&'static str, Value)>,
|
||||
wait_before_error: Option<Arc<tokio::sync::Notify>>,
|
||||
rejection_data: Option<&'static str>,
|
||||
}
|
||||
impl Client {
|
||||
fn new(failures: usize) -> Self {
|
||||
@@ -60,11 +80,69 @@ impl Client {
|
||||
requests: Arc::default(),
|
||||
failures,
|
||||
other_error: false,
|
||||
path: RejectionPath::Http,
|
||||
prefix: Vec::new(),
|
||||
wait_before_error: None,
|
||||
rejection_data: None,
|
||||
}
|
||||
}
|
||||
fn streamed(failures: usize, path: RejectionPath) -> Self {
|
||||
Self {
|
||||
path,
|
||||
..Self::new(failures)
|
||||
}
|
||||
}
|
||||
fn reject(&self, error: ClientError) -> Result<ResponseStream, ClientError> {
|
||||
if matches!(self.path, RejectionPath::Http) {
|
||||
return Err(error);
|
||||
}
|
||||
let ClientError::Api { code, message, .. } = error else {
|
||||
unreachable!()
|
||||
};
|
||||
let error = json!({"type":"invalid_request_error", "code":code, "message":message, "param":"input"});
|
||||
let (kind, data) = match self.path {
|
||||
RejectionPath::SseError => (
|
||||
"error",
|
||||
json!({"type":"error", "code":code, "message":message, "param":"input", "error_type":"invalid_request_error"}),
|
||||
),
|
||||
RejectionPath::SseNestedError => ("error", json!({"type":"error", "error":error})),
|
||||
RejectionPath::ResponseFailed => (
|
||||
"response.failed",
|
||||
json!({"type":"response.failed", "response":{"id":"rejected", "error":error}}),
|
||||
),
|
||||
RejectionPath::Http => unreachable!(),
|
||||
};
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut state = Default::default();
|
||||
let mut events = scheme.parse_sse("response.created", r#"{"response":{}}"#, &mut state)?;
|
||||
for (kind, data) in &self.prefix {
|
||||
events.extend(scheme.parse_sse(kind, &data.to_string(), &mut state)?);
|
||||
}
|
||||
let encoded = data.to_string();
|
||||
events.extend(scheme.parse_sse(
|
||||
kind,
|
||||
self.rejection_data.unwrap_or(&encoded),
|
||||
&mut state,
|
||||
)?);
|
||||
let wait = self.wait_before_error.clone();
|
||||
Ok(Box::pin(futures::stream::iter(events).then(move |event| {
|
||||
let wait = wait.clone();
|
||||
async move {
|
||||
if matches!(event, Event::Error(_))
|
||||
&& let Some(wait) = wait
|
||||
{
|
||||
wait.notified().await;
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
})))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl LlmClient for Client {
|
||||
fn tool_call_completion_support(&self) -> agen::llm_client::ToolCallCompletionSupport {
|
||||
agen::llm_client::ToolCallCompletionSupport::PerBlock
|
||||
}
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
@@ -72,10 +150,10 @@ impl LlmClient for Client {
|
||||
let mut requests = self.requests.lock().unwrap();
|
||||
requests.push(request);
|
||||
if requests.len() <= self.failures {
|
||||
return Err(rejection());
|
||||
return self.reject(rejection());
|
||||
}
|
||||
if self.other_error {
|
||||
return Err(ClientError::Api {
|
||||
return self.reject(ClientError::Api {
|
||||
status: Some(400),
|
||||
code: Some("invalid_value".into()),
|
||||
message: "invalid tool arguments".into(),
|
||||
@@ -226,6 +304,7 @@ async fn early_results_remain_candidates_despite_later_text_in_same_response() {
|
||||
struct CountingTool {
|
||||
image: bool,
|
||||
count: Arc<std::sync::atomic::AtomicUsize>,
|
||||
executed: Option<Arc<tokio::sync::Notify>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl agen::tool::Tool for CountingTool {
|
||||
@@ -235,6 +314,9 @@ impl agen::tool::Tool for CountingTool {
|
||||
_: agen::tool::ToolExecutionContext,
|
||||
) -> Result<agen::tool::ToolOutput, agen::tool::ToolError> {
|
||||
self.count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
if let Some(executed) = &self.executed {
|
||||
executed.notify_one();
|
||||
}
|
||||
let attachments = if self.image {
|
||||
let Item::ToolResult { attachments, .. } = image_result("image", 1600, 33349, 0) else {
|
||||
unreachable!()
|
||||
@@ -297,6 +379,7 @@ async fn fresh_image_recovery_does_not_repeat_tools_in_either_dispatch_mode() {
|
||||
let tool = CountingTool {
|
||||
image,
|
||||
count: Arc::default(),
|
||||
executed: None,
|
||||
};
|
||||
counts.push(tool.count.clone());
|
||||
engine.register_tool(Arc::new(move || {
|
||||
@@ -348,3 +431,308 @@ fn image_error_classifier_is_not_a_generic_400_retry() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_image_rejections_correct_largest_candidate_before_next_request() {
|
||||
for path in STREAM_PATHS {
|
||||
let client = Client::streamed(1, path);
|
||||
let mut history = history();
|
||||
let mut engine = Engine::new(client.clone());
|
||||
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 out = engine.run(&mut history, "continue").await;
|
||||
assert!(
|
||||
matches!(out.result, EngineRunExit::Finished),
|
||||
"{:?}",
|
||||
out.result
|
||||
);
|
||||
let requests = client.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("Resize or crop") && summary.contains("retry ViewImage"))));
|
||||
assert!(requests[1].items.iter().any(|item| matches!(item, Item::ToolCall { call_id, name, .. } if call_id == "large" && name == "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 streamed_rejections_stop_when_images_are_absent_or_candidates_exhausted() {
|
||||
for path in STREAM_PATHS {
|
||||
for (mut history, expected_requests) in [(History::new(), 1), (history(), 3)] {
|
||||
let client = Client::streamed(99, path);
|
||||
let out = Engine::new(client.clone())
|
||||
.run(&mut history, "continue")
|
||||
.await;
|
||||
assert!(
|
||||
matches!(out.result, EngineRunExit::Interrupted(_)),
|
||||
"{:?}",
|
||||
out.result
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), expected_requests);
|
||||
assert!(images(requests.last().unwrap()).is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unrelated_streamed_invalid_value_preserves_images_and_diagnostic() {
|
||||
for path in STREAM_PATHS {
|
||||
let mut client = Client::streamed(0, path);
|
||||
client.other_error = true;
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let out = Engine::new(client.clone())
|
||||
.run(&mut history, "continue")
|
||||
.await;
|
||||
let EngineRunExit::Interrupted(reason) = out.result else {
|
||||
panic!("{:?}", out.result)
|
||||
};
|
||||
let reason = format!("{reason:?}");
|
||||
assert!(
|
||||
reason.contains("invalid_value")
|
||||
&& reason.contains("invalid tool arguments")
|
||||
&& reason.contains("diagnostic=")
|
||||
&& reason.contains("input"),
|
||||
"{reason}"
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
&history.items_cloned()[..original.len()],
|
||||
original.as_slice()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_correction_commit_failure_does_not_mutate_or_resend() {
|
||||
for path in STREAM_PATHS {
|
||||
let client = Client::streamed(99, path);
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let mut engine = Engine::new(client.clone());
|
||||
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!(client.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_rejection_after_output_never_replays_or_corrects_input() {
|
||||
for path in STREAM_PATHS {
|
||||
// Start alone closes the replay window, as do partial/completed text,
|
||||
// reasoning and unrecognized events. None may change accepted input.
|
||||
for prefix in [
|
||||
vec![(
|
||||
"response.content_part.added",
|
||||
json!({"output_index":0,"content_index":0,"part":{"type":"output_text"}}),
|
||||
)],
|
||||
vec![(
|
||||
"response.output_text.delta",
|
||||
json!({"output_index":0,"content_index":0,"delta":"partial"}),
|
||||
)],
|
||||
vec![
|
||||
(
|
||||
"response.output_text.delta",
|
||||
json!({"output_index":0,"content_index":0,"delta":"completed"}),
|
||||
),
|
||||
(
|
||||
"response.content_part.done",
|
||||
json!({"output_index":0,"content_index":0,"part":{"type":"output_text"}}),
|
||||
),
|
||||
],
|
||||
vec![(
|
||||
"response.reasoning_text.delta",
|
||||
json!({"output_index":0,"content_index":0,"delta":"thinking"}),
|
||||
)],
|
||||
vec![("future.unknown", json!({"value":"unknown semantics"}))],
|
||||
] {
|
||||
let mut client = Client::streamed(1, path);
|
||||
client.prefix = prefix;
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let out = Engine::new(client.clone())
|
||||
.run(&mut history, "continue")
|
||||
.await;
|
||||
assert!(
|
||||
matches!(out.result, EngineRunExit::Interrupted(_)),
|
||||
"{:?}",
|
||||
out.result
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
&history.items_cloned()[..original.len()],
|
||||
original.as_slice()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn statusless_api_errors_are_not_image_retry_authority() {
|
||||
let ClientError::Api { code, message, .. } = rejection() else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(
|
||||
!ClientError::Api {
|
||||
status: None,
|
||||
code,
|
||||
message,
|
||||
retry_after: None
|
||||
}
|
||||
.is_image_size_rejection()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_rejection_after_tool_completion_never_reexecutes_side_effects() {
|
||||
for path in STREAM_PATHS {
|
||||
for mode in [
|
||||
agen::ToolCallDispatchMode::AfterResponse,
|
||||
agen::ToolCallDispatchMode::OnToolCallComplete,
|
||||
] {
|
||||
let executed = Arc::new(tokio::sync::Notify::new());
|
||||
let mut client = Client::streamed(1, path);
|
||||
client.prefix = vec![
|
||||
(
|
||||
"response.output_item.added",
|
||||
json!({"output_index":0,"item":{"type":"function_call","call_id":"side-effect","name":"Bash","arguments":"{}"}}),
|
||||
),
|
||||
(
|
||||
"response.output_item.done",
|
||||
json!({"output_index":0,"item":{"type":"function_call","call_id":"side-effect","name":"Bash","arguments":"{}"}}),
|
||||
),
|
||||
];
|
||||
if mode == agen::ToolCallDispatchMode::OnToolCallComplete {
|
||||
client.wait_before_error = Some(executed.clone());
|
||||
}
|
||||
let tool = CountingTool {
|
||||
image: false,
|
||||
count: Arc::default(),
|
||||
executed: Some(executed),
|
||||
};
|
||||
let count = tool.count.clone();
|
||||
let mut engine = Engine::new(client.clone());
|
||||
engine.set_tool_call_dispatch_mode(mode);
|
||||
engine.register_tool(Arc::new(move || {
|
||||
(
|
||||
agen::tool::ToolMeta::new("Bash")
|
||||
.description("side effect")
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(tool.clone()),
|
||||
)
|
||||
}));
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let out = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
engine.run(&mut history, "continue"),
|
||||
)
|
||||
.await
|
||||
.expect("tool admission/error cleanup must terminate");
|
||||
assert!(
|
||||
matches!(out.result, EngineRunExit::Interrupted(_)),
|
||||
"{:?}",
|
||||
out.result
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
&history.items_cloned()[..original.len()],
|
||||
original.as_slice()
|
||||
);
|
||||
if mode == agen::ToolCallDispatchMode::OnToolCallComplete {
|
||||
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
assert_eq!(history.items().filter(|item| matches!(item, Item::ToolCall { call_id, .. } if call_id == "side-effect")).count(), 1);
|
||||
assert_eq!(history.items().filter(|item| matches!(item, Item::ToolResult { call_id, .. } if call_id == "side-effect")).count(), 1);
|
||||
} else {
|
||||
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
assert!(!history.items().any(|item| matches!(item, Item::ToolCall { call_id, .. } | Item::ToolResult { call_id, .. } if call_id == "side-effect")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streamed_error_extra_fields_do_not_turn_unrelated_message_into_image_rejection() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut state = Default::default();
|
||||
let events = scheme.parse_sse("error", r#"{"type":"error","code":"invalid_value","message":"invalid tool arguments","param":"input","input":"image too large"}"#, &mut state).unwrap();
|
||||
let Event::Error(error) = &events[0] else {
|
||||
panic!("{events:?}")
|
||||
};
|
||||
assert_eq!(error.code.as_deref(), Some("invalid_value"));
|
||||
assert!(
|
||||
error
|
||||
.message
|
||||
.starts_with("invalid tool arguments | diagnostic=")
|
||||
);
|
||||
// HTTP uses the same diagnostic policy, but still requires status authority.
|
||||
assert!(
|
||||
!ClientError::Api {
|
||||
status: Some(400),
|
||||
code: error.code.clone(),
|
||||
message: error.message.clone(),
|
||||
retry_after: None
|
||||
}
|
||||
.is_image_size_rejection()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unparseable_stream_error_data_preserves_images_and_terminates_with_diagnostic() {
|
||||
for data in [
|
||||
r#"{"type":"error","code":"invalid_value","message":123,"input":"image too large"}"#,
|
||||
"unparseable provider error with echoed input: image too large",
|
||||
] {
|
||||
let mut client = Client::streamed(1, RejectionPath::SseError);
|
||||
client.rejection_data = Some(data);
|
||||
let mut history = history();
|
||||
let original = history.items_cloned();
|
||||
let out = Engine::new(client.clone())
|
||||
.run(&mut history, "continue")
|
||||
.await;
|
||||
let EngineRunExit::Interrupted(agen::RunInterruptionReason::Unexpected(
|
||||
agen::EngineError::Client(ClientError::Api { code, message, .. }),
|
||||
)) = out.result
|
||||
else {
|
||||
panic!(
|
||||
"malformed provider error must terminate, got {:?}",
|
||||
out.result
|
||||
);
|
||||
};
|
||||
assert_eq!(code, None);
|
||||
let (base, diagnostic) = message
|
||||
.split_once(" | diagnostic=")
|
||||
.expect("raw data remains diagnostic-only");
|
||||
assert!(!base.contains("image"));
|
||||
let diagnostic: Value = serde_json::from_str(diagnostic).unwrap();
|
||||
assert_eq!(diagnostic["error_extra"]["raw_data"], data);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
&history.items_cloned()[..original.len()],
|
||||
original.as_slice()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1534,6 +1534,13 @@ fn validate_named_body_type(ty: &Type, kind: &str) -> syn::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_binary_body_type(ty: &Type) -> bool {
|
||||
matches!(ty, Type::Path(path)
|
||||
if path.qself.is_none()
|
||||
&& path.path.segments.last().is_some_and(|last|
|
||||
last.ident == "BinaryBody" && matches!(last.arguments, PathArguments::None)))
|
||||
}
|
||||
|
||||
fn validate_binary_body_type(ty: &Type) -> syn::Result<()> {
|
||||
let Type::Path(path) = ty else {
|
||||
return Err(syn::Error::new_spanned(
|
||||
@@ -1738,6 +1745,9 @@ fn reqwest_adapter_tokens(
|
||||
}
|
||||
});
|
||||
let body_parser = match response.body.as_ref() {
|
||||
Some(ty) if is_binary_body_type(ty) => quote! {
|
||||
let body = __response.decode_binary();
|
||||
},
|
||||
Some(ty) => quote! {
|
||||
let body = __response
|
||||
.decode_json::<#ty>(#api_crate::reqwest::DecodeKind::Success)
|
||||
@@ -1775,7 +1785,11 @@ fn reqwest_adapter_tokens(
|
||||
.alternate_status
|
||||
.map(|alternate| quote!(__actual == #response_status || __actual == #alternate))
|
||||
.unwrap_or_else(|| quote!(__actual == #response_status));
|
||||
let success = if let Some(response) = operation.response_body.as_ref() {
|
||||
let success = if operation.response_body.as_ref().is_some_and(is_binary_body_type) {
|
||||
quote! {
|
||||
return ::core::result::Result::Ok(__response.decode_binary());
|
||||
}
|
||||
} else if let Some(response) = operation.response_body.as_ref() {
|
||||
quote! {
|
||||
return __response
|
||||
.decode_json::<#response>(#api_crate::reqwest::DecodeKind::Success)
|
||||
@@ -2133,7 +2147,9 @@ fn axum_adapter_tokens(
|
||||
let variant_ident = format_ident!("Status{}", status);
|
||||
let body_pattern = response.body.as_ref().map(|_| quote!(body,));
|
||||
let header_fields = response.headers.iter().map(|header| &header.field_ident);
|
||||
let base_response = if response.body.is_some() {
|
||||
let base_response = if response.body.as_ref().is_some_and(is_binary_body_type) {
|
||||
quote!(#api_crate::axum::binary_response(#api_crate::axum::status(#status), body))
|
||||
} else if response.body.is_some() {
|
||||
quote!(#api_crate::axum::json_response(#api_crate::axum::status(#status), body))
|
||||
} else {
|
||||
quote!(#api_crate::axum::empty_response(#api_crate::axum::status(#status)))
|
||||
@@ -2170,14 +2186,21 @@ fn axum_adapter_tokens(
|
||||
}
|
||||
}
|
||||
} else if let Some(alternate_status) = operation.alternate_status {
|
||||
let response_encoder = if operation.response_body.as_ref().is_some_and(is_binary_body_type) {
|
||||
quote!(#api_crate::axum::binary_response)
|
||||
} else {
|
||||
quote!(#api_crate::axum::json_response)
|
||||
};
|
||||
quote!({
|
||||
let status = #api_crate::HttpSuccess::status_code(&value);
|
||||
if status == #response_status || status == #alternate_status {
|
||||
#api_crate::axum::json_response(#api_crate::axum::status(status), value)
|
||||
#response_encoder(#api_crate::axum::status(status), value)
|
||||
} else {
|
||||
#api_crate::axum::empty_response(#api_crate::axum::status(500))
|
||||
}
|
||||
})
|
||||
} else if operation.response_body.as_ref().is_some_and(is_binary_body_type) {
|
||||
quote!(#api_crate::axum::binary_response(#api_crate::axum::status(#response_status), value))
|
||||
} else if operation.response_body.is_some() {
|
||||
quote!(#api_crate::axum::json_response(#api_crate::axum::status(#response_status), value))
|
||||
} else {
|
||||
@@ -2323,6 +2346,9 @@ fn openapi_adapter_tokens(
|
||||
"Alternate successful response"
|
||||
};
|
||||
let body = match response.body.as_ref() {
|
||||
Some(ty) if is_binary_body_type(ty) => quote! {
|
||||
operation.binary_response(#status, #description)?;
|
||||
},
|
||||
Some(ty) => quote! {
|
||||
operation.response::<#ty>(#status, "application/json", #description)?;
|
||||
},
|
||||
@@ -2620,7 +2646,9 @@ fn metadata_tokens(
|
||||
};
|
||||
let success_responses = operation.success_responses.iter().map(|response| {
|
||||
let status = response.status;
|
||||
let wire_kind = if response.body.is_some() {
|
||||
let wire_kind = if response.body.as_ref().is_some_and(is_binary_body_type) {
|
||||
quote!(#api_crate::WireKind::Binary)
|
||||
} else if response.body.is_some() {
|
||||
quote!(#api_crate::WireKind::Json)
|
||||
} else {
|
||||
quote!(#api_crate::WireKind::Empty)
|
||||
|
||||
@@ -97,6 +97,16 @@ pub fn error_response<T: Serialize + Send + Sync + 'static>(
|
||||
response
|
||||
}
|
||||
|
||||
/// Emit an exact binary response with the contract status and octet-stream content type.
|
||||
pub fn binary_response(status: StatusCode, value: crate::BinaryBody) -> Response {
|
||||
(
|
||||
status,
|
||||
[(axum::http::header::CONTENT_TYPE, "application/octet-stream")],
|
||||
value.into_bytes(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Emit an empty response with the contract status.
|
||||
pub fn empty_response(status: StatusCode) -> Response {
|
||||
status.into_response()
|
||||
|
||||
@@ -56,6 +56,9 @@
|
||||
//! `#[header("x-request-id")]`. Exactly one JSON or binary body is allowed. JSON request, response,
|
||||
//! and error bodies must be named Rust types; tuples, references, arrays, and other anonymous
|
||||
//! structural types are rejected. Binary request bodies use [`BinaryBody`] and remain byte-exact.
|
||||
//! A named success response whose final type identifier is `BinaryBody` is likewise byte-exact,
|
||||
//! uses `application/octet-stream`, and may include declared response headers. Generated clients
|
||||
//! collect binary responses under the same configured response byte limit as JSON responses.
|
||||
//! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the
|
||||
//! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated.
|
||||
//!
|
||||
@@ -96,7 +99,7 @@
|
||||
//! deterministic `Operation<utf8-hex>` fallback. The `ApiContract::OPERATIONS` inventory is
|
||||
//! sorted by operation ID, so its ordering is independent of source method order.
|
||||
//!
|
||||
//! Empty, JSON, and bounded binary request bodies are supported. [`WireKind`] reserves explicit
|
||||
//! Empty, JSON, and bounded binary request and success response bodies are supported. [`WireKind`] reserves explicit
|
||||
//! variants for other future transport work; accepting one requires a deliberate macro and adapter
|
||||
//! change rather than silently treating it as JSON.
|
||||
//!
|
||||
@@ -148,7 +151,7 @@ pub mod openapi;
|
||||
#[cfg(feature = "reqwest")]
|
||||
pub mod reqwest;
|
||||
|
||||
/// Framework-neutral owned bytes used by explicit `#[binary]` request bodies.
|
||||
/// Framework-neutral owned bytes used by explicit `#[binary]` requests and binary success responses.
|
||||
///
|
||||
/// The wrapper preserves the exact byte sequence while keeping payload content out of `Debug`
|
||||
/// output. Its `bytes::Bytes` storage lets generated Axum adapters transfer an extracted body
|
||||
@@ -314,7 +317,7 @@ pub trait Operation {
|
||||
type Parameters;
|
||||
/// Typed JSON or binary request body, or [`NoBody`].
|
||||
type RequestBody;
|
||||
/// JSON success response body type, generated declared-response result, or [`NoBody`].
|
||||
/// JSON or binary success body type, generated declared-response result, or [`NoBody`].
|
||||
type ResponseBody;
|
||||
/// JSON public error body type, or [`NoBody`].
|
||||
type ErrorBody;
|
||||
|
||||
@@ -419,6 +419,25 @@ impl OpenApiOperation<'_> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Describe an octet-stream response inline, without registering a JSON schema component.
|
||||
pub fn binary_response(
|
||||
&mut self,
|
||||
status: u16,
|
||||
description: &'static str,
|
||||
) -> Result<(), OpenApiError> {
|
||||
self.insert_response(
|
||||
status,
|
||||
json!({
|
||||
"description": description,
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": { "type": "string", "format": "binary" }
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn empty_response(
|
||||
&mut self,
|
||||
status: u16,
|
||||
|
||||
@@ -516,6 +516,11 @@ impl ReceivedResponse {
|
||||
serde_json::from_slice(&self.bytes).map_err(|_| ClientFailure::Decode { kind })
|
||||
}
|
||||
|
||||
/// Move the exact response bytes, already collected under the configured limit, into a body.
|
||||
pub fn decode_binary(self) -> crate::BinaryBody {
|
||||
self.bytes.into()
|
||||
}
|
||||
|
||||
pub fn require_empty(&self) -> Result<(), ClientFailure> {
|
||||
if self.bytes.is_empty() {
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
#![cfg(all(feature = "reqwest", feature = "axum"))]
|
||||
|
||||
use api_macros::{
|
||||
BinaryBody, Operation, api,
|
||||
axum::framework::{Body, Response, Router, get},
|
||||
reqwest::{ClientError, ClientFailure},
|
||||
};
|
||||
#[cfg(feature = "openapi")]
|
||||
use api_macros::{
|
||||
WireKind,
|
||||
openapi::{OpenApiInfo, OpenApiSchema},
|
||||
};
|
||||
#[cfg(feature = "openapi")]
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "openapi")]
|
||||
use serde_json::json;
|
||||
|
||||
const BYTES: &[u8] = b"\0\xff\x80\r\n{\"not\":\"a JSON response\"}";
|
||||
const ETAG: &str = "\"binary-v1\"";
|
||||
const DISPOSITION: &str = "attachment; filename=fixture.bin";
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(JsonSchema))]
|
||||
pub struct PublicError {
|
||||
message: String,
|
||||
}
|
||||
#[cfg(feature = "openapi")]
|
||||
impl OpenApiSchema for PublicError {}
|
||||
impl api_macros::HttpError for PublicError {
|
||||
fn status_code(&self) -> u16 {
|
||||
404
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "openapi", api(reqwest, axum, openapi))]
|
||||
#[cfg_attr(not(feature = "openapi"), api(reqwest, axum))]
|
||||
pub trait DownloadApi {
|
||||
#[get("/plain/{mode}", error_status = 404)]
|
||||
async fn plain(&self, mode: String) -> Result<api_macros::BinaryBody, PublicError>;
|
||||
|
||||
#[get(
|
||||
"/download/{mode}",
|
||||
error_status = 404,
|
||||
responses = [
|
||||
(status = 200, body = BinaryBody, headers = [
|
||||
("x-byte-length", u32), ("content-disposition", String), ("etag", String)
|
||||
]),
|
||||
(status = 304, headers = [("etag", String)])
|
||||
]
|
||||
)]
|
||||
async fn download(&self, mode: String)
|
||||
-> Result<download_api_responses::Download, PublicError>;
|
||||
}
|
||||
|
||||
struct DownloadService;
|
||||
impl DownloadApi for DownloadService {
|
||||
async fn plain(&self, mode: String) -> Result<BinaryBody, PublicError> {
|
||||
if mode == "missing" {
|
||||
return Err(PublicError {
|
||||
message: "not found".to_owned(),
|
||||
});
|
||||
}
|
||||
Ok(if mode == "empty" {
|
||||
b"".as_slice()
|
||||
} else {
|
||||
BYTES
|
||||
}
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn download(
|
||||
&self,
|
||||
mode: String,
|
||||
) -> Result<download_api_responses::Download, PublicError> {
|
||||
if mode == "cached" {
|
||||
return Ok(download_api_responses::Download::Status304 {
|
||||
header_etag: ETAG.to_owned(),
|
||||
});
|
||||
}
|
||||
let body = self.plain(mode).await?;
|
||||
Ok(download_api_responses::Download::Status200 {
|
||||
header_x_byte_length: body.len() as u32,
|
||||
header_content_disposition: DISPOSITION.to_owned(),
|
||||
header_etag: ETAG.to_owned(),
|
||||
body,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Server {
|
||||
base_url: String,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Server {
|
||||
async fn start(router: Router) -> Self {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base_url = format!("http://{}/", listener.local_addr().unwrap());
|
||||
let task = tokio::spawn(async move {
|
||||
api_macros::axum::framework::serve(listener, router)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
Self { base_url, task }
|
||||
}
|
||||
|
||||
fn client(&self, limit: usize) -> DownloadApiClient {
|
||||
DownloadApiClient::builder(&self.base_url)
|
||||
.unwrap()
|
||||
.response_body_limit(limit)
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_success_preserves_exact_bytes_content_type_and_declared_headers() {
|
||||
let server = Server::start(DownloadApiAxum::router(DownloadService)).await;
|
||||
let client = server.client(BYTES.len());
|
||||
for (mode, expected) in [("full", BYTES), ("empty", b"".as_slice())] {
|
||||
let response = reqwest::get(format!("{}plain/{mode}", server.base_url))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status().as_u16(), 200);
|
||||
assert_eq!(
|
||||
response.headers()["content-type"],
|
||||
"application/octet-stream"
|
||||
);
|
||||
assert_eq!(response.bytes().await.unwrap().as_ref(), expected);
|
||||
assert_eq!(
|
||||
client.plain(mode.to_owned()).await.unwrap().as_ref(),
|
||||
expected
|
||||
);
|
||||
|
||||
let response = client.download(mode.to_owned()).await.unwrap();
|
||||
match response {
|
||||
download_api_responses::Download::Status200 {
|
||||
body,
|
||||
header_x_byte_length,
|
||||
header_content_disposition,
|
||||
header_etag,
|
||||
} => {
|
||||
assert_eq!(body.as_ref(), expected);
|
||||
assert_eq!(header_x_byte_length as usize, expected.len());
|
||||
assert_eq!(header_content_disposition, DISPOSITION);
|
||||
assert_eq!(header_etag, ETAG);
|
||||
}
|
||||
other => panic!("expected binary success, received {other:?}"),
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
matches!(client.download("cached".to_owned()).await.unwrap(),
|
||||
download_api_responses::Download::Status304 { header_etag } if header_etag == ETAG)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "openapi")]
|
||||
#[test]
|
||||
fn binary_success_metadata_and_openapi_use_inline_binary_schema_with_headers() {
|
||||
let document = download_api_openapi(OpenApiInfo {
|
||||
title: "Download fixture",
|
||||
version: "1",
|
||||
source_digest: "fixture",
|
||||
})
|
||||
.unwrap();
|
||||
let value = document.as_value();
|
||||
for (path, metadata) in [
|
||||
("/plain/{mode}", download_api_operations::Plain::METADATA),
|
||||
(
|
||||
"/download/{mode}",
|
||||
download_api_operations::Download::METADATA,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
metadata.success_responses[0].body.wire_kind,
|
||||
WireKind::Binary
|
||||
);
|
||||
assert_eq!(metadata.error_responses[0].body.wire_kind, WireKind::Json);
|
||||
let responses = &value["paths"][path]["get"]["responses"];
|
||||
assert_eq!(
|
||||
responses["200"]["content"],
|
||||
json!({
|
||||
"application/octet-stream": { "schema": { "type": "string", "format": "binary" } }
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
responses["404"]["content"]["application/json"]["schema"]["$ref"],
|
||||
"#/components/schemas/PublicError"
|
||||
);
|
||||
}
|
||||
let metadata = download_api_operations::Download::METADATA;
|
||||
assert_eq!(
|
||||
metadata.success_responses[1].body.wire_kind,
|
||||
WireKind::Empty
|
||||
);
|
||||
let responses = &value["paths"]["/download/{mode}"]["get"]["responses"];
|
||||
for header in metadata.success_responses[0].headers {
|
||||
assert!(responses["200"]["headers"][header.wire_name]["schema"].is_object());
|
||||
}
|
||||
assert!(responses["304"].get("content").is_none());
|
||||
assert!(responses["304"]["headers"]["etag"]["schema"].is_object());
|
||||
assert!(
|
||||
value["components"]["schemas"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.all(|name| !name.contains("BinaryBody"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_success_keeps_json_public_errors_and_rejects_unexpected_statuses() {
|
||||
let server = Server::start(DownloadApiAxum::router(DownloadService)).await;
|
||||
let client = server.client(1024);
|
||||
for result in [
|
||||
client.plain("missing".to_owned()).await.map(|_| ()),
|
||||
client.download("missing".to_owned()).await.map(|_| ()),
|
||||
] {
|
||||
match result.unwrap_err() {
|
||||
ClientError::Public { status, error } => {
|
||||
assert_eq!(status.as_u16(), 404);
|
||||
assert_eq!(error.message, "not found");
|
||||
}
|
||||
other => panic!("expected JSON public error, received {other:?}"),
|
||||
}
|
||||
}
|
||||
for (status, expected) in [
|
||||
(
|
||||
201,
|
||||
ClientFailure::UnexpectedStatus {
|
||||
expected: 200,
|
||||
actual: 201,
|
||||
},
|
||||
),
|
||||
(404, ClientFailure::ErrorResponseDecode { status: 404 }),
|
||||
] {
|
||||
let server = Server::start(Router::new().route(
|
||||
download_api_operations::Plain::METADATA.path,
|
||||
get(move || async move {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.body(Body::from(BYTES))
|
||||
.unwrap()
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
assert!(matches!(server.client(1024).plain("full".to_owned()).await,
|
||||
Err(ClientError::Failure(actual)) if actual == expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_declared_headers_reject_missing_invalid_and_duplicate_values() {
|
||||
for (values, expected) in [
|
||||
(
|
||||
vec![],
|
||||
ClientFailure::MissingResponseHeader { name: "etag" },
|
||||
),
|
||||
(
|
||||
vec![b"\xff".as_slice()],
|
||||
ClientFailure::InvalidResponseHeader { name: "etag" },
|
||||
),
|
||||
(
|
||||
vec![b"one".as_slice(), b"two".as_slice()],
|
||||
ClientFailure::DuplicateResponseHeader { name: "etag" },
|
||||
),
|
||||
] {
|
||||
let server = Server::start(Router::new().route(
|
||||
download_api_operations::Download::METADATA.path,
|
||||
get(move || {
|
||||
let values = values.clone();
|
||||
async move {
|
||||
let mut response = Response::builder()
|
||||
.status(200)
|
||||
.header("content-type", "application/octet-stream")
|
||||
.header("x-byte-length", BYTES.len())
|
||||
.header("content-disposition", DISPOSITION);
|
||||
for value in values {
|
||||
response = response.header("etag", value);
|
||||
}
|
||||
response.body(Body::from(BYTES)).unwrap()
|
||||
}
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
assert!(
|
||||
matches!(server.client(1024).download("full".to_owned()).await,
|
||||
Err(ClientError::Failure(actual)) if actual == expected)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn binary_responses_enforce_the_same_limit_with_and_without_content_length() {
|
||||
for chunked in [false, true] {
|
||||
let server = Server::start(Router::new().route(
|
||||
download_api_operations::Plain::METADATA.path,
|
||||
get(move || async move {
|
||||
if chunked {
|
||||
let chunks = [BYTES[..3].to_vec(), BYTES[3..].to_vec()];
|
||||
let stream = futures::stream::iter(chunks.map(Ok::<_, std::io::Error>));
|
||||
Response::builder()
|
||||
.status(200)
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap()
|
||||
} else {
|
||||
api_macros::axum::binary_response(api_macros::axum::status(200), BYTES.into())
|
||||
}
|
||||
}),
|
||||
))
|
||||
.await;
|
||||
assert_eq!(
|
||||
server
|
||||
.client(BYTES.len())
|
||||
.plain("full".to_owned())
|
||||
.await
|
||||
.unwrap()
|
||||
.as_ref(),
|
||||
BYTES
|
||||
);
|
||||
assert!(
|
||||
matches!(server.client(BYTES.len() - 1).plain("full".to_owned()).await,
|
||||
Err(ClientError::Failure(ClientFailure::ResponseTooLarge { limit })) if limit == BYTES.len() - 1)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -75,11 +75,21 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
resolved_path: "profiles/job.dcdl",
|
||||
}],
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:ticket-worker"),
|
||||
path: "profiles/ticket-worker.dcdl",
|
||||
source: include_str!("../../../resources/profiles/ticket-worker.dcdl"),
|
||||
description: "General intent-led Ticket work with optional result recording and authorized conclusion decisions.",
|
||||
imports: &[BuiltinProfileImport {
|
||||
specifier: "./default.dcdl",
|
||||
resolved_path: DEFAULT_PATH,
|
||||
}],
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:coder"),
|
||||
path: "profiles/coder.dcdl",
|
||||
source: include_str!("../../../resources/profiles/coder.dcdl"),
|
||||
description: "Ticket implementation with direct Reviewer SubWorkers.",
|
||||
description: "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
@@ -107,7 +117,7 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
selector: Some("builtin:orchestrator"),
|
||||
path: "profiles/orchestrator.dcdl",
|
||||
source: include_str!("../../../resources/profiles/orchestrator.dcdl"),
|
||||
description: "Workspace orchestration and Worker control.",
|
||||
description: "Intent-led Ticket orchestration with separate guarded MR integration authority.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
@@ -212,3 +222,48 @@ pub fn builtin_profile_entrypoints() -> impl Iterator<Item = &'static BuiltinPro
|
||||
.iter()
|
||||
.filter(|resource| resource.selector.is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[test]
|
||||
fn ticket_worker_catalog_import_graph_includes_default_and_base() {
|
||||
// This snapshot supplies the Runtime profile source archive. Follow its
|
||||
// declared edges rather than resolving against every catalog source.
|
||||
let catalog = builtin_profile_catalog_snapshot();
|
||||
let entrypoint = catalog.entrypoints["builtin:ticket-worker"].clone();
|
||||
assert_eq!(
|
||||
catalog
|
||||
.imports
|
||||
.get(&format!("{entrypoint}\0./default.dcdl")),
|
||||
Some(&DEFAULT_PATH.to_owned())
|
||||
);
|
||||
assert_eq!(
|
||||
catalog.imports.get(&format!("{DEFAULT_PATH}\0./base.dcdl")),
|
||||
Some(&BASE_PATH.to_owned())
|
||||
);
|
||||
|
||||
let mut pending = vec![entrypoint.clone()];
|
||||
let mut reachable = BTreeSet::new();
|
||||
while let Some(path) = pending.pop() {
|
||||
if !reachable.insert(path.clone()) {
|
||||
continue;
|
||||
}
|
||||
assert!(catalog.sources.contains_key(&path), "missing source {path}");
|
||||
let prefix = format!("{path}\0");
|
||||
pending.extend(
|
||||
catalog
|
||||
.imports
|
||||
.iter()
|
||||
.filter(|(request, _)| request.starts_with(&prefix))
|
||||
.map(|(_, resolved)| resolved.clone()),
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
reachable,
|
||||
BTreeSet::from([entrypoint, DEFAULT_PATH.to_owned(), BASE_PATH.to_owned()])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,7 +823,7 @@ mod tests {
|
||||
"display_name": "Embedded",
|
||||
"built_in": true,
|
||||
"worker_creation_available": true,
|
||||
"working_directory_required": false,
|
||||
"supports_workdir_attachments": false,
|
||||
"status": "online",
|
||||
"diagnostics": []
|
||||
}],
|
||||
|
||||
@@ -549,7 +549,7 @@ impl BackendWorkspaceProductClient {
|
||||
let runtime = options
|
||||
.runtimes
|
||||
.iter()
|
||||
.find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required)
|
||||
.find(|runtime| runtime.worker_creation_available)
|
||||
.ok_or_else(|| {
|
||||
BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker"
|
||||
@@ -1340,7 +1340,7 @@ mod tests {
|
||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||
(
|
||||
"200 OK",
|
||||
r#"{"workspace_id":"workspace-a","runtimes":[{"runtime_id":"embedded","display_name":"Embedded","built_in":true,"worker_creation_available":true,"working_directory_required":false,"status":"connected","diagnostics":[]}],"default_profile":null,"profiles":[],"repositories":[],"working_directories":[],"diagnostics":[]}"#,
|
||||
r#"{"workspace_id":"workspace-a","runtimes":[{"runtime_id":"embedded","display_name":"Embedded","built_in":true,"worker_creation_available":true,"supports_workdir_attachments":false,"status":"connected","diagnostics":[]}],"default_profile":null,"profiles":[],"repositories":[],"working_directories":[],"diagnostics":[]}"#,
|
||||
),
|
||||
(
|
||||
"200 OK",
|
||||
|
||||
@@ -242,6 +242,15 @@ impl FeatureStorage {
|
||||
}
|
||||
|
||||
fn open(&self, scope_id: &str, registration: &RegisteredFeature) -> Result<FeatureDatabase> {
|
||||
self.open_with_directory_sync(scope_id, registration, sync_directory)
|
||||
}
|
||||
|
||||
fn open_with_directory_sync(
|
||||
&self,
|
||||
scope_id: &str,
|
||||
registration: &RegisteredFeature,
|
||||
mut sync: impl FnMut(&Path) -> std::io::Result<()>,
|
||||
) -> Result<FeatureDatabase> {
|
||||
if registration.manager_id != self.inner.id {
|
||||
return Err(FeatureStorageError::ForeignRegistration);
|
||||
}
|
||||
@@ -263,7 +272,7 @@ impl FeatureStorage {
|
||||
}
|
||||
|
||||
let feature_dir = self.feature_dir(scope_id);
|
||||
fs::create_dir_all(&feature_dir)?;
|
||||
durable_create_directory(&feature_dir, &mut sync)?;
|
||||
let path = feature_dir.join(format!("{}.sqlite", registration.feature_id));
|
||||
let connection = Connection::open_with_flags(
|
||||
path,
|
||||
@@ -702,6 +711,37 @@ fn ensure_running(state: &FeatureStorageState) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Establish every link to a metadata directory before opening SQLite. FULL
|
||||
/// commits cover DB/WAL and the immediate directory, not newly created ancestors.
|
||||
/// Re-sync existing directories too: a failed earlier attempt may have created
|
||||
/// them without persisting their links. Host paths (including symlink targets)
|
||||
/// are trusted and must not be concurrently replaced by an untrusted actor.
|
||||
fn durable_create_directory(
|
||||
path: &Path,
|
||||
sync: &mut impl FnMut(&Path) -> std::io::Result<()>,
|
||||
) -> Result<()> {
|
||||
fs::create_dir_all(path)?;
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()?.join(path)
|
||||
};
|
||||
let resolved = fs::canonicalize(&absolute)?;
|
||||
let mut synced = HashSet::new();
|
||||
// Sync both the configured link chain and its resolved target chain. For a
|
||||
// symlinked Host root these need not have the same parent directories.
|
||||
for directory in absolute.ancestors().chain(resolved.ancestors()) {
|
||||
if synced.insert(directory.to_path_buf()) {
|
||||
sync(directory)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_directory(path: &Path) -> std::io::Result<()> {
|
||||
fs::File::open(path)?.sync_all()
|
||||
}
|
||||
|
||||
/// Applies the common durable Host SQLite connection policy: five-second busy
|
||||
/// timeout, foreign keys, WAL, and FULL synchronous commits. Call immediately
|
||||
/// after opening a file-backed database, before transactions or migrations.
|
||||
@@ -1023,6 +1063,125 @@ mod tests {
|
||||
FeatureRegistration::new(feature_id, MIGRATIONS)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_ancestor_links_are_synced_before_sqlite_initialization() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("server/feature-storage");
|
||||
let manager = FeatureStorage::new(&root);
|
||||
let registration = manager.register(registration("test-feature")).unwrap();
|
||||
let directory = root.join("workspace/features");
|
||||
let database_path = directory.join("test-feature.sqlite");
|
||||
let mut observed = Vec::new();
|
||||
let database = manager
|
||||
.open_with_directory_sync("workspace", ®istration, |path| {
|
||||
assert!(
|
||||
!database_path.exists(),
|
||||
"SQLite must not start before directory durability"
|
||||
);
|
||||
assert!(path.is_dir());
|
||||
observed.push(path.to_path_buf());
|
||||
sync_directory(path)
|
||||
})
|
||||
.unwrap();
|
||||
for ancestor in directory.ancestors() {
|
||||
assert!(
|
||||
observed.iter().any(|path| path == ancestor),
|
||||
"missing parent-link sync: {}",
|
||||
ancestor.display()
|
||||
);
|
||||
}
|
||||
assert_eq!(database.schema_version().unwrap(), 2);
|
||||
insert(&database, "acknowledged").unwrap();
|
||||
assert_eq!(values(&database).unwrap(), vec!["acknowledged"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_directory_sync_failure_rejects_admission_and_retry_resyncs_existing_links() {
|
||||
for fail_component in [
|
||||
"server",
|
||||
"server/feature-storage",
|
||||
"server/feature-storage/workspace",
|
||||
"server/feature-storage/workspace/features",
|
||||
] {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("server/feature-storage");
|
||||
let manager = FeatureStorage::new(&root);
|
||||
let registration = manager.register(registration("test-feature")).unwrap();
|
||||
let directory = root.join("workspace/features");
|
||||
let database_path = directory.join("test-feature.sqlite");
|
||||
let fail_at = temp.path().join(fail_component);
|
||||
let result = manager.open_with_directory_sync("workspace", ®istration, |path| {
|
||||
if path == fail_at {
|
||||
return Err(std::io::Error::other("injected directory flush failure"));
|
||||
}
|
||||
sync_directory(path)
|
||||
});
|
||||
assert!(matches!(result, Err(FeatureStorageError::Io(_))));
|
||||
assert!(
|
||||
directory.is_dir(),
|
||||
"failure leaves created directories to retry"
|
||||
);
|
||||
assert!(
|
||||
!database_path.exists(),
|
||||
"failure must precede SQLite initialization"
|
||||
);
|
||||
assert!(manager.lock_state().unwrap().connections.is_empty());
|
||||
let mut resynced = HashSet::new();
|
||||
let database = manager
|
||||
.open_with_directory_sync("workspace", ®istration, |path| {
|
||||
resynced.insert(path.to_path_buf());
|
||||
sync_directory(path)
|
||||
})
|
||||
.unwrap();
|
||||
for ancestor in directory.ancestors() {
|
||||
assert!(
|
||||
resynced.contains(ancestor),
|
||||
"retry omitted existing link: {}",
|
||||
ancestor.display()
|
||||
);
|
||||
}
|
||||
insert(&database, "retry committed").unwrap();
|
||||
drop(database);
|
||||
let reopened = FeatureStorage::new(&root);
|
||||
let registered = reopened
|
||||
.register(FeatureRegistration::new("test-feature", MIGRATIONS))
|
||||
.unwrap();
|
||||
let database = reopened
|
||||
.scope("workspace")
|
||||
.unwrap()
|
||||
.open(®istered)
|
||||
.unwrap();
|
||||
assert_eq!(values(&database).unwrap(), vec!["retry committed"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn metadata_host_symlink_paths_sync_configured_and_resolved_parent_links() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let target = temp.path().join("separate-target/nested");
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
let alias = temp.path().join("configured-alias");
|
||||
std::os::unix::fs::symlink(&target, &alias).unwrap();
|
||||
let directory = alias.join("feature-storage/workspace/features");
|
||||
let mut observed = HashSet::new();
|
||||
durable_create_directory(&directory, &mut |path| {
|
||||
observed.insert(path.to_path_buf());
|
||||
sync_directory(path)
|
||||
})
|
||||
.unwrap();
|
||||
for ancestor in directory
|
||||
.ancestors()
|
||||
.chain(fs::canonicalize(&directory).unwrap().ancestors())
|
||||
{
|
||||
assert!(
|
||||
observed.contains(ancestor),
|
||||
"missing configured/target link: {}",
|
||||
ancestor.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reusable_connection_policy_and_manager_require_durable_commits() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
match slug {
|
||||
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 4,
|
||||
revision: 5,
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
content: CODER_REVIEW_FLOW_SOURCE,
|
||||
}),
|
||||
@@ -35,7 +35,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
||||
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
||||
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
|
||||
slug: CODER_REVIEW_FLOW_SLUG,
|
||||
revision: 4,
|
||||
revision: 5,
|
||||
path: "builtin/flows/coder-review.dcdl",
|
||||
content: CODER_REVIEW_FLOW_SOURCE,
|
||||
}];
|
||||
|
||||
@@ -941,6 +941,7 @@ mod tests {
|
||||
let list_error = run_list(
|
||||
&root,
|
||||
ListRequest {
|
||||
after: None,
|
||||
path: FsPath::new("selected-dir").unwrap(),
|
||||
limit: 10,
|
||||
},
|
||||
@@ -1008,6 +1009,7 @@ mod tests {
|
||||
let listed = run_list(
|
||||
&root,
|
||||
ListRequest {
|
||||
after: None,
|
||||
path: FsPath::new("notes").unwrap(),
|
||||
limit: 10,
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::BinaryHeap;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::io::{BufReader, Read};
|
||||
@@ -7,9 +9,9 @@ use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{
|
||||
AtomicWriteMode, BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind,
|
||||
FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadBytesRequest,
|
||||
ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||
direct_symlink,
|
||||
FsAccessPolicy, FsError, FsPath, ListCursor, ListEntry, ListRequest, ListResult,
|
||||
ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult,
|
||||
WriteRequest, WriteResult, direct_symlink,
|
||||
};
|
||||
|
||||
const READ_BUFFER_BYTES: usize = 16 * 1024;
|
||||
@@ -437,12 +439,55 @@ pub fn run_edit(
|
||||
})
|
||||
}
|
||||
|
||||
fn list_order(
|
||||
left_kind: EntryKind,
|
||||
left: &FsPath,
|
||||
right_kind: EntryKind,
|
||||
right: &FsPath,
|
||||
) -> Ordering {
|
||||
(left_kind != EntryKind::Directory)
|
||||
.cmp(&(right_kind != EntryKind::Directory))
|
||||
.then_with(|| left.as_str().cmp(right.as_str()))
|
||||
}
|
||||
|
||||
struct RetainedListEntry(ListEntry);
|
||||
|
||||
impl PartialEq for RetainedListEntry {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.cmp(other) == Ordering::Equal
|
||||
}
|
||||
}
|
||||
impl Eq for RetainedListEntry {}
|
||||
impl Ord for RetainedListEntry {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
list_order(self.0.kind, &self.0.path, other.0.kind, &other.0.path)
|
||||
}
|
||||
}
|
||||
impl PartialOrd for RetainedListEntry {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
fn list_entry_bytes(entry: &ListEntry) -> usize {
|
||||
entry.path.as_str().len().saturating_add(64)
|
||||
}
|
||||
|
||||
pub fn run_list(
|
||||
root: &Path,
|
||||
request: ListRequest,
|
||||
access: &dyn FsAccessPolicy,
|
||||
) -> Result<ListResult, FsError> {
|
||||
let logical = request.path;
|
||||
if let Some(after) = &request.after {
|
||||
if after.path.is_root()
|
||||
|| Path::new(after.path.as_str()).parent() != Some(Path::new(logical.as_str()))
|
||||
{
|
||||
return Err(FsError::InvalidArgument(
|
||||
"List cursor must name a direct child of the listed directory".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
access
|
||||
.check_cancelled()
|
||||
.map_err(|error| map_io(&logical, error))?;
|
||||
@@ -462,11 +507,15 @@ pub fn run_list(
|
||||
total_entries: 0,
|
||||
total_bytes: 0,
|
||||
truncated: false,
|
||||
next_after: None,
|
||||
});
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let mut entries: BinaryHeap<RetainedListEntry> = BinaryHeap::new();
|
||||
let mut retained_limit = request.limit.saturating_add(1);
|
||||
let mut retained_path_bytes = 0_usize;
|
||||
let mut provider_truncated = false;
|
||||
let mut total_entries = 0usize;
|
||||
let mut total_bytes = 0u64;
|
||||
let mut remaining_entries = 0usize;
|
||||
let read_dir = access
|
||||
.open_read_dir(&logical_base, &path)
|
||||
.map_err(|error| map_io(&logical, error))?;
|
||||
@@ -513,34 +562,77 @@ pub fn run_list(
|
||||
let Ok(result_path) = FsPath::new(relative) else {
|
||||
continue;
|
||||
};
|
||||
let retained = result_path.as_str().len().saturating_add(64);
|
||||
if retained_path_bytes.saturating_add(retained) > crate::MAX_RESULT_PATH_BYTES {
|
||||
provider_truncated = true;
|
||||
break;
|
||||
total_entries += 1;
|
||||
total_bytes = total_bytes.saturating_add(metadata.len());
|
||||
if request.after.as_ref().is_some_and(|after| {
|
||||
list_order(kind, &result_path, after.kind, &after.path) != Ordering::Greater
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
retained_path_bytes = retained_path_bytes.saturating_add(retained);
|
||||
entries.push(ListEntry {
|
||||
remaining_entries += 1;
|
||||
let candidate = RetainedListEntry(ListEntry {
|
||||
path: result_path,
|
||||
kind,
|
||||
size: metadata.len(),
|
||||
});
|
||||
if entries.len() == retained_limit {
|
||||
if candidate >= *entries.peek().expect("nonempty List retention") {
|
||||
continue;
|
||||
}
|
||||
retained_path_bytes -= list_entry_bytes(&entries.pop().unwrap().0);
|
||||
}
|
||||
retained_path_bytes = retained_path_bytes.saturating_add(list_entry_bytes(&candidate.0));
|
||||
entries.push(candidate);
|
||||
// Evict greatest keys, never stop scanning: enumeration order is
|
||||
// unspecified and a later entry may precede every retained key.
|
||||
while retained_path_bytes > crate::MAX_RESULT_PATH_BYTES {
|
||||
retained_path_bytes -= list_entry_bytes(&entries.pop().unwrap().0);
|
||||
// Never grow cardinality again after a byte-bound eviction: a
|
||||
// later short path must not leap over a discarded earlier key.
|
||||
retained_limit = entries.len();
|
||||
}
|
||||
if retained_limit == 0 {
|
||||
return Err(FsError::InvalidArgument(
|
||||
"List entry exceeds provider response bound".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
entries.sort_by(|left, right| {
|
||||
let left_dir = left.kind == EntryKind::Directory;
|
||||
let right_dir = right.kind == EntryKind::Directory;
|
||||
right_dir
|
||||
.cmp(&left_dir)
|
||||
.then_with(|| left.path.as_str().cmp(right.path.as_str()))
|
||||
});
|
||||
let total_entries = entries.len();
|
||||
let total_bytes = entries.iter().map(|entry| entry.size).sum();
|
||||
let truncated = provider_truncated || entries.len() > request.limit;
|
||||
let mut entries: Vec<_> = entries
|
||||
.into_sorted_vec()
|
||||
.into_iter()
|
||||
.map(|entry| entry.0)
|
||||
.collect();
|
||||
entries.truncate(request.limit);
|
||||
// Continuation duplicates the last path on the wire; include it in the
|
||||
// aggregate provider response bound too.
|
||||
let mut response_bytes: usize = entries.iter().map(list_entry_bytes).sum();
|
||||
while remaining_entries > entries.len()
|
||||
&& entries.last().is_some_and(|last| {
|
||||
response_bytes.saturating_add(list_entry_bytes(last)) > crate::MAX_RESULT_PATH_BYTES
|
||||
})
|
||||
{
|
||||
response_bytes -= list_entry_bytes(&entries.pop().unwrap());
|
||||
}
|
||||
if request.limit > 0 && remaining_entries > 0 && entries.is_empty() {
|
||||
return Err(FsError::InvalidArgument(
|
||||
"List entry exceeds provider response bound".into(),
|
||||
));
|
||||
}
|
||||
let truncated = remaining_entries > entries.len();
|
||||
let next_after = if truncated {
|
||||
entries.last().map(|entry| ListCursor {
|
||||
kind: entry.kind,
|
||||
path: entry.path.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(ListResult {
|
||||
entries,
|
||||
total_entries,
|
||||
total_bytes,
|
||||
truncated,
|
||||
next_after,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +278,22 @@ pub struct ListRequest {
|
||||
pub path: FsPath,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub limit: usize,
|
||||
/// Exclusive live continuation in the same coordinates as returned entries.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub after: Option<ListCursor>,
|
||||
}
|
||||
|
||||
/// A List ordering key: directories first, then lexical path (all other kinds
|
||||
/// share one ordering group). The cursor need not still exist. Each page scans
|
||||
/// the live directory: insertions before the key are not revisited, insertions
|
||||
/// after it may appear, and removals disappear. Renames or kind changes can move
|
||||
/// an entry across the key and cause omission or repetition; no snapshot or
|
||||
/// cross-page consistency is promised. Scoped wrappers translate coordinates.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ListCursor {
|
||||
pub kind: EntryKind,
|
||||
pub path: FsPath,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -293,6 +309,11 @@ pub struct ListEntry {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ListResult {
|
||||
pub entries: Vec<ListEntry>,
|
||||
/// Last returned key when more entries follow. A zero-limit request has no
|
||||
/// continuation even when truncated. Totals describe all visible entries
|
||||
/// in this scan, including those at or before `after`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub next_after: Option<ListCursor>,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub total_entries: usize,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
use fs_operation::{
|
||||
EntryKind, FsAccessPolicy, FsError, FsPath, ListCursor, ListRequest, ListResult,
|
||||
MAX_RESULT_PATH_BYTES, run_list,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
struct Access {
|
||||
root: PathBuf,
|
||||
checks: AtomicUsize,
|
||||
cancel_at: usize,
|
||||
enumerate: bool,
|
||||
}
|
||||
impl Access {
|
||||
fn new(root: &Path) -> Self {
|
||||
Self {
|
||||
root: root.to_path_buf(),
|
||||
checks: AtomicUsize::new(0),
|
||||
cancel_at: usize::MAX,
|
||||
enumerate: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl FsAccessPolicy for Access {
|
||||
fn is_readable(&self, path: &Path) -> bool {
|
||||
path.starts_with(&self.root) && path != self.root.join("denied")
|
||||
}
|
||||
fn is_writable(&self, _: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
fn can_enumerate_directory(&self, _: &Path, _: &Path) -> bool {
|
||||
self.enumerate
|
||||
}
|
||||
fn check_cancelled(&self) -> std::io::Result<()> {
|
||||
if self.checks.fetch_add(1, Ordering::Relaxed) >= self.cancel_at {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Interrupted,
|
||||
"cancelled",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn path(value: &str) -> FsPath {
|
||||
FsPath::new(value).unwrap()
|
||||
}
|
||||
fn page(root: &Path, limit: usize, after: Option<ListCursor>) -> ListResult {
|
||||
run_list(
|
||||
root,
|
||||
ListRequest {
|
||||
path: FsPath::root(),
|
||||
limit,
|
||||
after,
|
||||
},
|
||||
&Access::new(root),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn names(result: &ListResult) -> Vec<&str> {
|
||||
result
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.path.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_pages_order_directories_first_then_paths_without_duplicates() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for name in ["z", "b", "a"] {
|
||||
std::fs::write(root.path().join(name), b"xx").unwrap();
|
||||
}
|
||||
for name in ["z-dir", "a-dir"] {
|
||||
std::fs::create_dir(root.path().join(name)).unwrap();
|
||||
}
|
||||
let full = page(root.path(), usize::MAX, None);
|
||||
assert_eq!(names(&full), ["a-dir", "z-dir", "a", "b", "z"]);
|
||||
let mut after = None;
|
||||
let mut entries = Vec::new();
|
||||
loop {
|
||||
let result = page(root.path(), 2, after);
|
||||
assert!(result.entries.len() <= 2);
|
||||
assert_eq!(result.total_entries, 5);
|
||||
assert_eq!(result.total_bytes, full.total_bytes);
|
||||
assert_eq!(result.truncated, result.next_after.is_some());
|
||||
after = result.next_after;
|
||||
entries.extend(result.entries);
|
||||
if after.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(entries, full.entries);
|
||||
let zero = page(root.path(), 0, None);
|
||||
assert!(zero.entries.is_empty());
|
||||
assert!(zero.truncated);
|
||||
assert!(zero.next_after.is_none());
|
||||
assert_eq!(zero.total_entries, 5);
|
||||
let exhausted = page(
|
||||
root.path(),
|
||||
2,
|
||||
Some(ListCursor {
|
||||
kind: EntryKind::File,
|
||||
path: path("z"),
|
||||
}),
|
||||
);
|
||||
assert!(exhausted.entries.is_empty());
|
||||
assert!(!exhausted.truncated);
|
||||
assert!(exhausted.next_after.is_none());
|
||||
assert_eq!(exhausted.total_entries, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_live_cursor_survives_removal_and_only_observes_new_keys_after_it() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for name in ["b", "d"] {
|
||||
std::fs::write(root.path().join(name), b"x").unwrap();
|
||||
}
|
||||
let first = page(root.path(), 1, None);
|
||||
assert_eq!(names(&first), ["b"]);
|
||||
std::fs::remove_file(root.path().join("b")).unwrap();
|
||||
for name in ["a", "c"] {
|
||||
std::fs::write(root.path().join(name), b"xx").unwrap();
|
||||
}
|
||||
// All directories sort before a file cursor, even when created later.
|
||||
std::fs::create_dir(root.path().join("z-directory")).unwrap();
|
||||
let second = page(root.path(), 10, first.next_after);
|
||||
assert_eq!(names(&second), ["c", "d"]);
|
||||
assert_eq!(second.total_entries, 4);
|
||||
assert!(!second.truncated);
|
||||
assert!(second.next_after.is_none());
|
||||
std::fs::remove_file(root.path().join("d")).unwrap();
|
||||
std::fs::rename(root.path().join("a"), root.path().join("e")).unwrap();
|
||||
let moved = page(
|
||||
root.path(),
|
||||
10,
|
||||
Some(ListCursor {
|
||||
kind: EntryKind::File,
|
||||
path: path("d"),
|
||||
}),
|
||||
);
|
||||
assert_eq!(names(&moved), ["e"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_path_byte_bound_returns_a_continuation_instead_of_losing_the_tail() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let long_count = MAX_RESULT_PATH_BYTES / (240 + 64) + 10;
|
||||
// A shorter late key must not leap over a longer evicted earlier key.
|
||||
let count = long_count + 50;
|
||||
let expected: Vec<_> = (0..count)
|
||||
.map(|n| format!("{n:05}{}", "x".repeat(if n < long_count { 235 } else { 1 })))
|
||||
.collect();
|
||||
for name in expected.iter().rev() {
|
||||
std::fs::write(root.path().join(name), b"x").unwrap();
|
||||
}
|
||||
let first = page(root.path(), usize::MAX, None);
|
||||
assert!(first.truncated);
|
||||
assert_eq!(first.total_entries, count);
|
||||
assert_eq!(first.total_bytes, count as u64);
|
||||
let path_bytes: usize = first
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.path.as_str().len() + 64)
|
||||
.sum::<usize>()
|
||||
+ first.next_after.as_ref().unwrap().path.as_str().len()
|
||||
+ 64;
|
||||
assert!(path_bytes <= MAX_RESULT_PATH_BYTES);
|
||||
let next = page(root.path(), usize::MAX, first.next_after.clone());
|
||||
assert!(!next.truncated);
|
||||
let got: Vec<_> = names(&first).into_iter().chain(names(&next)).collect();
|
||||
assert_eq!(got, expected.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
let small = page(root.path(), 1, None);
|
||||
assert_eq!(names(&small), [expected[0].as_str()]);
|
||||
assert_eq!(small.total_entries, count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_keeps_ignored_and_hidden_entries_but_respects_read_and_enumeration_authority() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(root.path().join(".gitignore"), "ignored\n").unwrap();
|
||||
for name in ["ignored", ".hidden", "denied"] {
|
||||
std::fs::write(root.path().join(name), b"x").unwrap();
|
||||
}
|
||||
let first = page(root.path(), 2, None);
|
||||
assert_eq!(names(&first), [".gitignore", ".hidden"]);
|
||||
let second = page(root.path(), 2, first.next_after);
|
||||
assert_eq!(names(&second), ["ignored"]);
|
||||
assert_eq!(second.total_entries, 3);
|
||||
let mut access = Access::new(root.path());
|
||||
access.enumerate = false;
|
||||
let result = run_list(
|
||||
root.path(),
|
||||
ListRequest {
|
||||
path: FsPath::root(),
|
||||
limit: 1,
|
||||
after: None,
|
||||
},
|
||||
&access,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result.entries.is_empty());
|
||||
assert!(!result.truncated);
|
||||
assert!(result.next_after.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_cancellation_is_checked_even_after_the_page_is_full() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for name in ["a", "b", "c"] {
|
||||
std::fs::write(root.path().join(name), b"x").unwrap();
|
||||
}
|
||||
let mut access = Access::new(root.path());
|
||||
access.cancel_at = 3;
|
||||
let error = run_list(
|
||||
root.path(),
|
||||
ListRequest {
|
||||
path: FsPath::root(),
|
||||
limit: 1,
|
||||
after: None,
|
||||
},
|
||||
&access,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, FsError::Io { source, .. } if source.kind() == std::io::ErrorKind::Interrupted)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_rejects_cursor_outside_the_requested_directory() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for value in ["", "other/a"] {
|
||||
let error = run_list(
|
||||
root.path(),
|
||||
ListRequest {
|
||||
path: FsPath::root(),
|
||||
limit: 1,
|
||||
after: Some(ListCursor {
|
||||
kind: EntryKind::File,
|
||||
path: path(value),
|
||||
}),
|
||||
},
|
||||
&Access::new(root.path()),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, FsError::InvalidArgument(_)));
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,9 @@ pub struct FeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub workdir_catalog: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub workspace_config: Option<WorkspaceConfigFeatureConfigPartial>,
|
||||
pub workspace_config: Option<ActivationFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub drive: Option<ActivationFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub ticket: Option<TicketFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
@@ -149,7 +151,12 @@ impl FeatureConfigPartial {
|
||||
workspace_config: merge_option(
|
||||
self.workspace_config,
|
||||
other.workspace_config,
|
||||
WorkspaceConfigFeatureConfigPartial::merge,
|
||||
ActivationFeatureConfigPartial::merge,
|
||||
),
|
||||
drive: merge_option(
|
||||
self.drive,
|
||||
other.drive,
|
||||
ActivationFeatureConfigPartial::merge,
|
||||
),
|
||||
ticket: merge_option(self.ticket, other.ticket, TicketFeatureConfigPartial::merge),
|
||||
merge_request: merge_option(
|
||||
@@ -183,12 +190,12 @@ impl FeatureFlagConfigPartial {
|
||||
/// Activation only: no Workspace/grant/source/permission fields are accepted.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigFeatureConfigPartial {
|
||||
pub struct ActivationFeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl WorkspaceConfigFeatureConfigPartial {
|
||||
impl ActivationFeatureConfigPartial {
|
||||
fn merge(self, other: Self) -> Self {
|
||||
Self {
|
||||
enabled: other.enabled.or(self.enabled),
|
||||
@@ -196,15 +203,15 @@ impl WorkspaceConfigFeatureConfigPartial {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkspaceConfigFeatureConfigPartial> for FeatureFlagConfig {
|
||||
fn from(value: WorkspaceConfigFeatureConfigPartial) -> Self {
|
||||
impl From<ActivationFeatureConfigPartial> for FeatureFlagConfig {
|
||||
fn from(value: ActivationFeatureConfigPartial) -> Self {
|
||||
Self {
|
||||
enabled: value.enabled.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FeatureFlagConfig> for WorkspaceConfigFeatureConfigPartial {
|
||||
impl From<FeatureFlagConfig> for ActivationFeatureConfigPartial {
|
||||
fn from(value: FeatureFlagConfig) -> Self {
|
||||
Self {
|
||||
enabled: Some(value.enabled),
|
||||
@@ -448,6 +455,7 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
.workspace_config
|
||||
.map(FeatureFlagConfig::from)
|
||||
.unwrap_or_default(),
|
||||
drive: value.drive.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||
ticket: value
|
||||
.ticket
|
||||
.map(TicketFeatureConfig::from)
|
||||
@@ -643,6 +651,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||
manage_workdir: Some(value.manage_workdir.into()),
|
||||
workdir_catalog: Some(value.workdir_catalog.into()),
|
||||
workspace_config: Some(value.workspace_config.into()),
|
||||
drive: Some(value.drive.into()),
|
||||
ticket: Some(value.ticket.into()),
|
||||
merge_request: Some(value.merge_request.into()),
|
||||
orchestration: Some(value.orchestration.into()),
|
||||
@@ -2311,6 +2320,93 @@ worker_max_turns = 7
|
||||
assert!(!manifest.feature.merge_request.any());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_defaults_disabled_in_partial_resolved_and_missing_snapshot_fields() {
|
||||
for source in ["", "[feature]\n", "[feature.drive]\n"] {
|
||||
let partial = WorkerManifestConfig::from_toml(source).unwrap();
|
||||
let manifest: WorkerManifest = minimal_valid().merge(partial).try_into().unwrap();
|
||||
assert!(!manifest.feature.drive.enabled, "{source}");
|
||||
}
|
||||
for source in ["{}", r#"{"drive": {}}"#] {
|
||||
let feature: FeatureConfig = serde_json::from_str(source).unwrap();
|
||||
assert!(!feature.drive.enabled);
|
||||
}
|
||||
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||
let mut snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap();
|
||||
snapshot["manifest"]["feature"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("drive");
|
||||
let restored = crate::read_persisted_worker_manifest_snapshot(snapshot).unwrap();
|
||||
assert!(!restored.feature.drive.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_activation_merges_and_roundtrips_config_and_snapshots() {
|
||||
for enabled in [false, true] {
|
||||
let lower =
|
||||
WorkerManifestConfig::from_toml(&format!("[feature.drive]\nenabled = {enabled}\n"))
|
||||
.unwrap();
|
||||
let empty = WorkerManifestConfig::from_toml("[feature.drive]\n").unwrap();
|
||||
let manifest: WorkerManifest = minimal_valid()
|
||||
.merge(lower.clone())
|
||||
.merge(empty)
|
||||
.try_into()
|
||||
.unwrap();
|
||||
assert_eq!(manifest.feature.drive.enabled, enabled);
|
||||
assert!(!manifest.feature.workspace_config.enabled);
|
||||
assert!(!manifest.feature.manage_workdir.enabled);
|
||||
let partial = FeatureConfigPartial::from(manifest.feature.clone());
|
||||
assert_eq!(partial.drive.as_ref().unwrap().enabled, Some(enabled));
|
||||
let restored: FeatureConfigPartial =
|
||||
toml::from_str(&toml::to_string(&partial).unwrap()).unwrap();
|
||||
assert_eq!(FeatureConfig::from(restored), manifest.feature);
|
||||
let json = serde_json::to_value(&manifest.feature).unwrap();
|
||||
assert_eq!(json["drive"], serde_json::json!({"enabled": enabled}));
|
||||
assert_eq!(
|
||||
serde_json::from_value::<FeatureConfig>(json).unwrap(),
|
||||
manifest.feature
|
||||
);
|
||||
let snapshot = crate::write_persisted_worker_manifest_snapshot(&manifest).unwrap();
|
||||
let restored = crate::read_persisted_worker_manifest_snapshot(snapshot).unwrap();
|
||||
assert_eq!(restored.feature.drive.enabled, enabled);
|
||||
let upper = WorkerManifestConfig::from_toml(&format!(
|
||||
"[feature.drive]\nenabled = {}\n",
|
||||
!enabled
|
||||
))
|
||||
.unwrap();
|
||||
let overridden: WorkerManifest = minimal_valid()
|
||||
.merge(lower)
|
||||
.merge(upper)
|
||||
.try_into()
|
||||
.unwrap();
|
||||
assert_eq!(overridden.feature.drive.enabled, !enabled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_activation_rejects_authority_and_unknown_fields() {
|
||||
for (field, value) in [
|
||||
("workspace_id", "\"workspace-1\""),
|
||||
("grant_id", "\"grant-1\""),
|
||||
("access", "\"read_write\""),
|
||||
("host_path", "\"/host/drive\""),
|
||||
("source", "\"drive\""),
|
||||
("writable", "true"),
|
||||
("enabeld", "true"),
|
||||
] {
|
||||
let source = format!("[feature.drive]\nenabled = true\n{field} = {value}\n");
|
||||
assert!(WorkerManifestConfig::from_toml(&source).is_err(), "{field}");
|
||||
assert!(
|
||||
toml::from_str::<FeatureConfig>(&format!(
|
||||
"[drive]\nenabled = true\n{field} = {value}\n"
|
||||
))
|
||||
.is_err(),
|
||||
"{field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_config_defaults_disabled_for_partial_and_resolved_configs() {
|
||||
assert!(!FeatureConfig::default().workspace_config.enabled);
|
||||
|
||||
@@ -143,8 +143,12 @@ pub struct FeatureConfig {
|
||||
pub workdir_catalog: FeatureFlagConfig,
|
||||
/// Opt-in WIP-only logical Workspace configuration feature. This flag is
|
||||
/// not an access grant; Backend-owned Worker grants remain authoritative.
|
||||
#[serde(default, deserialize_with = "deserialize_workspace_config")]
|
||||
#[serde(default, deserialize_with = "deserialize_activation_feature")]
|
||||
pub workspace_config: FeatureFlagConfig,
|
||||
/// Opt-in Drive tools and native WIP surface. Activation grants no Drive
|
||||
/// access: Backend-owned Worker grants are checked on every operation.
|
||||
#[serde(default, deserialize_with = "deserialize_activation_feature")]
|
||||
pub drive: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
pub ticket: TicketFeatureConfig,
|
||||
#[serde(default)]
|
||||
@@ -169,11 +173,11 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
fn deserialize_workspace_config<'de, D>(deserializer: D) -> Result<FeatureFlagConfig, D::Error>
|
||||
fn deserialize_activation_feature<'de, D>(deserializer: D) -> Result<FeatureFlagConfig, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Ok(config::WorkspaceConfigFeatureConfigPartial::deserialize(deserializer)?.into())
|
||||
Ok(config::ActivationFeatureConfigPartial::deserialize(deserializer)?.into())
|
||||
}
|
||||
|
||||
impl Default for FeatureConfig {
|
||||
@@ -192,6 +196,7 @@ impl Default for FeatureConfig {
|
||||
manage_workdir: FeatureFlagConfig::disabled(),
|
||||
workdir_catalog: default_workdir_catalog(),
|
||||
workspace_config: FeatureFlagConfig::disabled(),
|
||||
drive: FeatureFlagConfig::disabled(),
|
||||
ticket: TicketFeatureConfig::default(),
|
||||
merge_request: MergeRequestFeatureConfig::default(),
|
||||
orchestration: FeatureFlagConfig::disabled(),
|
||||
|
||||
@@ -1279,6 +1279,106 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_drive_activation_is_workspace_role_specific_and_excludes_jobs() {
|
||||
// Production DCDL imports are the policy boundary: changing the shared
|
||||
// base must not accidentally activate Drive in a read-only Job/Reviewer.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let registry = ProfileDiscovery::with_sources(None, None)
|
||||
.discover()
|
||||
.unwrap();
|
||||
for entry in registry.entries() {
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, &entry.name),
|
||||
ProfileResolveOptions::with_worker_name("drive-profile-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
let expected = matches!(
|
||||
entry.name.as_str(),
|
||||
"default" | "ticket-worker" | "coder" | "companion" | "intake" | "orchestrator"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.manifest.feature.drive.enabled, expected,
|
||||
"{}",
|
||||
entry.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_worker_profile_enables_work_reporting_without_authoring_or_integration() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let registry = ProfileDiscovery::with_sources(None, None)
|
||||
.discover()
|
||||
.unwrap();
|
||||
let selector = ProfileSelector::parse_cli("builtin:ticket-worker");
|
||||
assert_eq!(
|
||||
registry.select(&selector).unwrap().qualified_name(),
|
||||
"builtin:ticket-worker"
|
||||
);
|
||||
let manifest = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&selector,
|
||||
ProfileResolveOptions::with_worker_name("ticket-worker"),
|
||||
)
|
||||
.unwrap()
|
||||
.manifest;
|
||||
|
||||
assert_eq!(manifest.engine.instruction, "role.ticket_worker");
|
||||
// enabled supplies ShowTicket; thread supplies TicketComment,
|
||||
// TicketWorkflowState and CompleteTicket without broad workflow access.
|
||||
let feature = &manifest.feature;
|
||||
assert!(feature.ticket.enabled);
|
||||
assert!(feature.ticket.thread);
|
||||
assert!(!feature.ticket.authoring);
|
||||
assert!(!feature.ticket.intake);
|
||||
assert!(!feature.ticket.workflow);
|
||||
assert!(!feature.merge_request.show);
|
||||
assert!(!feature.merge_request.open);
|
||||
assert!(!feature.merge_request.review);
|
||||
assert!(!feature.merge_request.readiness_check);
|
||||
assert!(!feature.merge_request.complete);
|
||||
assert!(!feature.orchestration.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_worker_profile_inherits_default_without_flow_or_workdir_management() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let resolve = |selector| {
|
||||
ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::parse_cli(selector),
|
||||
ProfileResolveOptions::with_worker_name("ticket-worker"),
|
||||
)
|
||||
.unwrap()
|
||||
.manifest
|
||||
};
|
||||
let default = resolve("builtin:default");
|
||||
let worker = resolve("builtin:ticket-worker");
|
||||
|
||||
assert_eq!(worker.model, default.model);
|
||||
assert_eq!(worker.scope.allow, default.scope.allow);
|
||||
assert_eq!(worker.scope.deny, default.scope.deny);
|
||||
assert_eq!(
|
||||
worker.delegation_scope.allow,
|
||||
default.delegation_scope.allow
|
||||
);
|
||||
assert_eq!(worker.delegation_scope.deny, default.delegation_scope.deny);
|
||||
assert!(worker.feature.task.enabled);
|
||||
assert!(worker.feature.web.enabled);
|
||||
assert!(worker.feature.image.enabled);
|
||||
assert!(worker.feature.sub_worker.enabled);
|
||||
assert!(!worker.feature.flow.enabled);
|
||||
assert!(!worker.feature.manage_workdir.enabled);
|
||||
assert!(!worker.feature.worker.enabled);
|
||||
assert!(!worker.feature.memory.profile.enabled);
|
||||
assert!(!worker.feature.subjektiv.profile.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_workspace_profiles_cut_over_to_subjektiv_without_legacy_memory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -1054,6 +1054,23 @@ pub enum SessionSnapshotEntryData {
|
||||
source: RunResumeSource,
|
||||
active_run_turn_count: usize,
|
||||
},
|
||||
/// Snapshot-only replay of persisted invocation/usage records. These are
|
||||
/// accounting metadata, never model context or conversation rows.
|
||||
Invoke {
|
||||
trigger: InvokeKind,
|
||||
},
|
||||
Usage {
|
||||
input_tokens: u64,
|
||||
cache_read_input_tokens: u64,
|
||||
output_tokens: u64,
|
||||
},
|
||||
/// Display-only accounting reconstructed from persisted run/usage records.
|
||||
RunStats {
|
||||
elapsed_ms: u64,
|
||||
requests: u64,
|
||||
upload_tokens: u64,
|
||||
output_tokens: u64,
|
||||
},
|
||||
/// Durable terminal for an intentional cancellation. Consumers must not
|
||||
/// present this lifecycle result as an execution failure.
|
||||
RunCancelled,
|
||||
@@ -1177,6 +1194,9 @@ pub enum Event {
|
||||
/// following `UserMessage` / `SystemItem` event.
|
||||
InvokeStart {
|
||||
kind: InvokeKind,
|
||||
/// Timestamp of the persisted Invoke, not the client's attach time.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
timestamp_ms: Option<u64>,
|
||||
},
|
||||
/// One AgentTurn boundary opened. An AgentTurn is a maximal run of
|
||||
/// LLM generation calls whose input messages are identical (i.e.
|
||||
@@ -1293,6 +1313,9 @@ pub enum Event {
|
||||
/// actually paid full price to send on this request, which is what
|
||||
/// the TUI status line accumulates per turn.
|
||||
Usage {
|
||||
/// Present for usage delivered from the committed session-log lane.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
timestamp_ms: Option<u64>,
|
||||
input_tokens: Option<u64>,
|
||||
output_tokens: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -2335,19 +2358,29 @@ mod tests {
|
||||
InvokeKind::SystemReminder,
|
||||
InvokeKind::Wakeup,
|
||||
] {
|
||||
let event = Event::InvokeStart { kind };
|
||||
let event = Event::InvokeStart {
|
||||
kind,
|
||||
timestamp_ms: Some(1234),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "invoke_start");
|
||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||
match decoded {
|
||||
Event::InvokeStart { kind: k } => assert_eq!(k, kind),
|
||||
Event::InvokeStart {
|
||||
kind: k,
|
||||
timestamp_ms,
|
||||
} => {
|
||||
assert_eq!(k, kind);
|
||||
assert_eq!(timestamp_ms, Some(1234));
|
||||
}
|
||||
other => panic!("expected InvokeStart, got {other:?}"),
|
||||
}
|
||||
}
|
||||
let parsed: serde_json::Value = serde_json::from_str(
|
||||
&serde_json::to_string(&Event::InvokeStart {
|
||||
kind: InvokeKind::UserSend,
|
||||
timestamp_ms: None,
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
|
||||
@@ -55,3 +55,7 @@ required-features = ["typescript"]
|
||||
[[example]]
|
||||
name = "generate_repository_access_types"
|
||||
required-features = ["typescript"]
|
||||
|
||||
[[example]]
|
||||
name = "generate_drive_api_types"
|
||||
required-features = ["typescript"]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(server_api::drive_api_typescript())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,902 @@
|
||||
//! Workspace Drive HTTP contracts. Entries are logical, workspace-bound resources,
|
||||
//! never host paths. Worker identity and grants are resolved by the service from
|
||||
//! trusted request context, not supplied by ordinary operation DTOs.
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
pub const DRIVE_TEXT_MAX_BYTES: usize = 64 * 1024;
|
||||
pub const DRIVE_CHUNK_MAX_BYTES: u32 = 64 * 1024;
|
||||
pub const DRIVE_PAGE_MAX_LIMIT: u32 = 200;
|
||||
pub const DRIVE_SEARCH_MAX_LIMIT: u32 = 128;
|
||||
pub const DRIVE_FILE_MAX_BYTES: u32 = 16 * 1024 * 1024;
|
||||
|
||||
fn decimal<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
if !canonical_positive_decimal(&value) {
|
||||
return Err(serde::de::Error::custom(
|
||||
"expected a canonical positive decimal string",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
fn optional_decimal<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<String>, D::Error> {
|
||||
let value = Option::<String>::deserialize(deserializer)?;
|
||||
if value
|
||||
.as_deref()
|
||||
.is_some_and(|value| !canonical_positive_decimal(value))
|
||||
{
|
||||
return Err(serde::de::Error::custom(
|
||||
"expected a canonical positive decimal string",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
fn canonical_positive_decimal(value: &str) -> bool {
|
||||
value
|
||||
.as_bytes()
|
||||
.first()
|
||||
.is_some_and(|first| matches!(first, b'1'..=b'9'))
|
||||
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
||||
&& value.parse::<i64>().is_ok_and(|value| value > 0)
|
||||
}
|
||||
fn bounded_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
if value.len() > DRIVE_TEXT_MAX_BYTES {
|
||||
return Err(serde::de::Error::custom("Drive text exceeds 64 KiB"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
fn bounded_chunk<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
|
||||
let value = u32::deserialize(deserializer)?;
|
||||
if value == 0 || value > DRIVE_CHUNK_MAX_BYTES {
|
||||
return Err(serde::de::Error::custom(
|
||||
"Drive read size must be between 1 and 65536 bytes",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
fn optional_limit<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<u32>, D::Error> {
|
||||
let value = Option::<u32>::deserialize(deserializer)?;
|
||||
if value.is_some_and(|value| value == 0 || value > DRIVE_PAGE_MAX_LIMIT) {
|
||||
return Err(serde::de::Error::custom(
|
||||
"Drive page limit must be between 1 and 200",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
const fn default_read_size() -> u32 {
|
||||
DRIVE_CHUNK_MAX_BYTES
|
||||
}
|
||||
fn optional_search_limit<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<u32>, D::Error> {
|
||||
let value = Option::<u32>::deserialize(deserializer)?;
|
||||
if value.is_some_and(|value| value == 0 || value > DRIVE_SEARCH_MAX_LIMIT) {
|
||||
return Err(serde::de::Error::custom(
|
||||
"Drive search limit must be between 1 and 128",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Numeric node IDs alone are not public references: the referenced Workspace
|
||||
/// must match the route Workspace before the service resolves the node.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveEntryRef {
|
||||
pub workspace_id: String,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub node_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriveEntryKind {
|
||||
Folder,
|
||||
File,
|
||||
}
|
||||
|
||||
/// Metadata only: content is returned through bounded text or binary routes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveEntry {
|
||||
pub entry: DriveEntryRef,
|
||||
pub parent: Option<DriveEntryRef>,
|
||||
pub name: String,
|
||||
pub kind: DriveEntryKind,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub revision: String,
|
||||
/// File length in bytes; absent for folders.
|
||||
#[schemars(range(min = 0, max = 16_777_216))]
|
||||
pub size: Option<u32>,
|
||||
pub content_type: Option<String>,
|
||||
pub updated_by: String,
|
||||
/// Timestamp projected from storage's updated_at_ms; not a creation time.
|
||||
pub updated_at: String,
|
||||
/// Stable relative authenticated URL in this same Workspace. No bearer token
|
||||
/// or cross-Workspace link is embedded in this metadata.
|
||||
pub latest_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveEntryQuery {
|
||||
pub entry_workspace_id: String,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub id: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveListQuery {
|
||||
pub entry_workspace_id: String,
|
||||
/// Folder to list.
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "optional_limit")]
|
||||
#[schemars(range(min = 1, max = 200))]
|
||||
pub limit: Option<u32>,
|
||||
/// Opaque service-issued page cursor, not a bare node ID.
|
||||
#[serde(default)]
|
||||
pub after: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveSearchQuery {
|
||||
pub query: String,
|
||||
/// Search file text as well as names only when explicitly requested.
|
||||
#[serde(default)]
|
||||
pub include_text: bool,
|
||||
#[serde(default, deserialize_with = "optional_search_limit")]
|
||||
#[schemars(range(min = 1, max = 128))]
|
||||
pub limit: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub after: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveListResponse {
|
||||
pub entries: Vec<DriveEntry>,
|
||||
pub next_after: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveReadTextQuery {
|
||||
pub entry_workspace_id: String,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub id: String,
|
||||
#[serde(default = "default_read_size", deserialize_with = "bounded_chunk")]
|
||||
#[schemars(range(min = 1, max = 65536))]
|
||||
pub max_bytes: u32,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveReadTextResponse {
|
||||
pub entry: DriveEntry,
|
||||
#[serde(deserialize_with = "bounded_text")]
|
||||
pub text: String,
|
||||
pub truncated: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveReadChunkQuery {
|
||||
pub entry_workspace_id: String,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub id: String,
|
||||
/// Bind a multi-chunk read to the same file revision.
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub expected_revision: String,
|
||||
#[schemars(range(min = 0, max = 16_777_216))]
|
||||
pub offset: u32,
|
||||
#[serde(default = "default_read_size", deserialize_with = "bounded_chunk")]
|
||||
#[schemars(range(min = 1, max = 65536))]
|
||||
pub length: u32,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveDownloadQuery {
|
||||
pub entry_workspace_id: String,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub id: String,
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub expected_revision: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveMutationRequest {
|
||||
pub request_id: String,
|
||||
pub mutation: DriveMutation,
|
||||
}
|
||||
/// JSON can mutate bounded UTF-8 TEXT only. Arbitrary file bytes use drive_upload.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum DriveMutation {
|
||||
CreateFolder {
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
},
|
||||
CreateText {
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
#[serde(deserialize_with = "bounded_text")]
|
||||
text: String,
|
||||
content_type: String,
|
||||
},
|
||||
UpdateText {
|
||||
id: DriveEntryRef,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
expected_revision: String,
|
||||
#[serde(deserialize_with = "bounded_text")]
|
||||
text: String,
|
||||
content_type: String,
|
||||
},
|
||||
Relocate {
|
||||
id: DriveEntryRef,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
expected_revision: String,
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
},
|
||||
Delete {
|
||||
id: DriveEntryRef,
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
expected_revision: String,
|
||||
},
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveMutationResponse {
|
||||
pub request_id: String,
|
||||
/// A deleted entry has no live metadata.
|
||||
pub entry: Option<DriveEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriveUploadOperation {
|
||||
Create,
|
||||
Update,
|
||||
}
|
||||
/// Flat query metadata accompanies the binary body. The service rejects invalid
|
||||
/// combinations: create requires parent_id/name and forbids id/expected_revision;
|
||||
/// update requires id/expected_revision and forbids parent_id/name. Both targets
|
||||
/// are bound by entry_workspace_id; declared size and SHA256 must match the body.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveUploadQuery {
|
||||
pub operation: DriveUploadOperation,
|
||||
pub request_id: String,
|
||||
pub entry_workspace_id: String,
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub parent_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub expected_revision: Option<String>,
|
||||
pub content_type: String,
|
||||
/// Values above the file limit still decode as u32 so the service returns
|
||||
/// typed Limit rather than a generic request-decoding error.
|
||||
#[schemars(range(min = 0, max = 16_777_216))]
|
||||
pub size: u32,
|
||||
pub sha256: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriveRequestState {
|
||||
/// No commit record exists at the observation snapshot. An in-flight request
|
||||
/// can still commit afterward; this is not proof of a final failed outcome.
|
||||
Uncommitted,
|
||||
Committed,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveRequestStatusResponse {
|
||||
pub request_id: String,
|
||||
pub state: DriveRequestState,
|
||||
pub response: Option<DriveMutationResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriveAccess {
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveGrantCreateRequest {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub access: DriveAccess,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveGrantResponse {
|
||||
#[serde(deserialize_with = "decimal")]
|
||||
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||
pub grant_id: String,
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub access: DriveAccess,
|
||||
pub revoked: bool,
|
||||
pub created_by: String,
|
||||
pub created_at: String,
|
||||
pub revoked_by: Option<String>,
|
||||
pub revoked_at: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveGrantListQuery {
|
||||
#[serde(default, deserialize_with = "optional_limit")]
|
||||
#[schemars(range(min = 1, max = 200))]
|
||||
pub limit: Option<u32>,
|
||||
/// Canonical grant ID cursor within this Workspace.
|
||||
#[serde(default, deserialize_with = "optional_decimal")]
|
||||
pub after: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveGrantListResponse {
|
||||
pub grants: Vec<DriveGrantResponse>,
|
||||
pub next_after: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriveApiErrorCode {
|
||||
Denied,
|
||||
NotFound,
|
||||
Conflict,
|
||||
Invalid,
|
||||
Limit,
|
||||
StorageUnavailable,
|
||||
OutcomeUnknown,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DriveFailureClassification {
|
||||
NotCommitted,
|
||||
Unknown,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveApiError {
|
||||
pub code: DriveApiErrorCode,
|
||||
pub classification: DriveFailureClassification,
|
||||
pub message: String,
|
||||
}
|
||||
impl DriveApiError {
|
||||
/// Never include provider errors, paths, SQL, or caller-supplied messages.
|
||||
pub fn new(code: DriveApiErrorCode) -> Self {
|
||||
use DriveApiErrorCode::*;
|
||||
Self {
|
||||
code,
|
||||
classification: if code == OutcomeUnknown {
|
||||
DriveFailureClassification::Unknown
|
||||
} else {
|
||||
DriveFailureClassification::NotCommitted
|
||||
},
|
||||
message: match code {
|
||||
Denied => "Drive access denied",
|
||||
NotFound => "Drive resource not found",
|
||||
Conflict => "Drive revision or name conflict",
|
||||
Invalid => "Invalid Drive request",
|
||||
Limit => "Drive request exceeds a limit",
|
||||
StorageUnavailable => "Drive storage unavailable",
|
||||
OutcomeUnknown => "Drive mutation outcome unknown",
|
||||
}
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl api_macros::HttpError for DriveApiError {
|
||||
fn status_code(&self) -> u16 {
|
||||
use DriveApiErrorCode::*;
|
||||
match self.code {
|
||||
Denied => 403,
|
||||
NotFound => 404,
|
||||
Conflict => 409,
|
||||
Invalid => 400,
|
||||
Limit => 413,
|
||||
StorageUnavailable | OutcomeUnknown => 503,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl api_macros::HttpRequestError for DriveApiError {
|
||||
fn from_request_rejection(status: u16, _message: String) -> Self {
|
||||
Self::new(if status == 413 {
|
||||
DriveApiErrorCode::Limit
|
||||
} else {
|
||||
DriveApiErrorCode::Invalid
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl api_macros::openapi::OpenApiSchema for DriveApiError {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveEntry {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveEntryQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveListQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveSearchQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveListResponse {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveReadTextQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveReadTextResponse {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveReadChunkQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveDownloadQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveMutationRequest {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveMutationResponse {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveUploadQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveRequestStatusResponse {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveGrantCreateRequest {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveGrantResponse {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveGrantListQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveGrantListResponse {}
|
||||
|
||||
/// Browser wire declarations are derived from the transport DTOs, not copied
|
||||
/// from storage models or maintained independently in the Web client.
|
||||
#[cfg(feature = "typescript")]
|
||||
pub fn drive_api_typescript() -> String {
|
||||
use ts_rs::TS;
|
||||
let config = ts_rs::Config::default();
|
||||
let declarations = [
|
||||
DriveEntryRef::decl(&config),
|
||||
DriveEntryKind::decl(&config),
|
||||
DriveEntry::decl(&config),
|
||||
DriveEntryQuery::decl(&config),
|
||||
DriveListQuery::decl(&config),
|
||||
DriveSearchQuery::decl(&config),
|
||||
DriveListResponse::decl(&config),
|
||||
DriveReadTextQuery::decl(&config),
|
||||
DriveReadTextResponse::decl(&config),
|
||||
DriveReadChunkQuery::decl(&config),
|
||||
DriveDownloadQuery::decl(&config),
|
||||
DriveMutation::decl(&config),
|
||||
DriveMutationRequest::decl(&config),
|
||||
DriveMutationResponse::decl(&config),
|
||||
DriveUploadOperation::decl(&config),
|
||||
DriveUploadQuery::decl(&config),
|
||||
DriveRequestState::decl(&config),
|
||||
DriveRequestStatusResponse::decl(&config),
|
||||
DriveAccess::decl(&config),
|
||||
DriveGrantCreateRequest::decl(&config),
|
||||
DriveGrantResponse::decl(&config),
|
||||
DriveGrantListQuery::decl(&config),
|
||||
DriveGrantListResponse::decl(&config),
|
||||
DriveApiErrorCode::decl(&config),
|
||||
DriveFailureClassification::decl(&config),
|
||||
DriveApiError::decl(&config),
|
||||
];
|
||||
format!(
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_drive_api_types > web/workspace/src/lib/generated/drive-api.ts\n\n{}\n",
|
||||
declarations
|
||||
.into_iter()
|
||||
.map(|declaration| format!("export {declaration}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "typescript"))]
|
||||
mod typescript_tests {
|
||||
#[test]
|
||||
fn generated_drive_api_contract_is_current() {
|
||||
let expected = super::drive_api_typescript();
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../web/workspace/src/lib/generated/drive-api.ts");
|
||||
let actual = std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
|
||||
assert_eq!(
|
||||
normalize(&actual),
|
||||
normalize(&expected),
|
||||
"regenerate Drive API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_drive_api_types > web/workspace/src/lib/generated/drive-api.ts` and format the generated file"
|
||||
);
|
||||
}
|
||||
fn normalize(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter_map(|character| match character {
|
||||
character if character.is_whitespace() => None,
|
||||
',' => Some(';'),
|
||||
character => Some(character),
|
||||
})
|
||||
.collect::<String>()
|
||||
.replace("=|", "=")
|
||||
.replace(";}", "}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ApiContract, HttpMethod, ServerApiMetadata, canonical_openapi_document};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn drive_entry_references_reject_unbound_or_noncanonical_numeric_ids() {
|
||||
let valid = json!({"workspace_id":"workspace-a", "node_id":"9007199254740993"});
|
||||
let entry: DriveEntryRef = serde_json::from_value(valid.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(entry).unwrap(), valid);
|
||||
for invalid in [
|
||||
json!("7"),
|
||||
json!(7),
|
||||
json!({"node_id":"7"}),
|
||||
json!({"workspace_id":"a", "node_id":7}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<DriveEntryRef>(invalid).is_err());
|
||||
}
|
||||
for id in [
|
||||
"",
|
||||
"0",
|
||||
"01",
|
||||
"-1",
|
||||
"+1",
|
||||
"1.0",
|
||||
"1e3",
|
||||
" 1",
|
||||
"١",
|
||||
"9223372036854775808",
|
||||
"18446744073709551616",
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<DriveEntryRef>(json!({"workspace_id":"a", "node_id":id}))
|
||||
.is_err(),
|
||||
"accepted {id:?}"
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<DriveEntryQuery>(
|
||||
json!({"entry_workspace_id":"a", "id":id})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<DriveEntryRef>(
|
||||
json!({"workspace_id":"a","node_id":"9223372036854775807"})
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(serde_json::from_value::<DriveEntryQuery>(json!({"id":"7"})).is_err());
|
||||
assert!(serde_json::from_value::<DriveListQuery>(json!({"id":"7"})).is_err());
|
||||
assert!(serde_json::from_value::<DriveReadTextQuery>(json!({"id":"7"})).is_err());
|
||||
assert!(serde_json::from_value::<DriveDownloadQuery>(json!({"id":"7"})).is_err());
|
||||
assert!(
|
||||
serde_json::from_value::<DriveEntryQuery>(
|
||||
json!({"entry_workspace_id":"a", "id":"7", "worker_id":"spoofed"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_metadata_exposes_only_stored_update_provenance_and_same_workspace_url() {
|
||||
let value = json!({
|
||||
"entry":{"workspace_id":"a","node_id":"7"},
|
||||
"parent":{"workspace_id":"a","node_id":"1"},
|
||||
"name":"file.txt","kind":"file","revision":"2","size":5,
|
||||
"content_type":"text/plain","updated_by":"user-a","updated_at":"2026-10-08T00:00:00Z",
|
||||
"latest_url":"/api/w/a/drive/download?entry_workspace_id=a&id=7"
|
||||
});
|
||||
let entry: DriveEntry = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(entry).unwrap(), value);
|
||||
for field in ["sha256", "created_at"] {
|
||||
let mut fabricated = value.clone();
|
||||
fabricated[field] = json!("not stored");
|
||||
assert!(serde_json::from_value::<DriveEntry>(fabricated).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_search_text_is_opt_in_and_work_budget_is_bounded() {
|
||||
let query: DriveSearchQuery = serde_json::from_value(json!({"query":"name"})).unwrap();
|
||||
assert!(!query.include_text);
|
||||
let query: DriveSearchQuery =
|
||||
serde_json::from_value(json!({"query":"text","include_text":true,"limit":128}))
|
||||
.unwrap();
|
||||
assert!(query.include_text);
|
||||
for limit in [0, 129, u32::MAX] {
|
||||
assert!(
|
||||
serde_json::from_value::<DriveSearchQuery>(json!({"query":"name","limit":limit}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_request_status_is_a_commit_snapshot_not_a_fictitious_lifecycle() {
|
||||
for state in ["uncommitted", "committed"] {
|
||||
let value = json!({"request_id":"r","state":state,"response":null});
|
||||
let response: DriveRequestStatusResponse =
|
||||
serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(response).unwrap(), value);
|
||||
}
|
||||
for state in ["pending", "unknown", "not_committed"] {
|
||||
assert!(
|
||||
serde_json::from_value::<DriveRequestStatusResponse>(
|
||||
json!({"request_id":"r","state":state,"response":null})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_json_mutations_accept_only_bounded_text_and_workspace_bound_cas() {
|
||||
let valid = json!({"request_id":"request-a", "mutation":{
|
||||
"operation":"update_text", "id":{"workspace_id":"a","node_id":"7"},
|
||||
"expected_revision":"9007199254740993", "text":"hello", "content_type":"text/plain"
|
||||
}});
|
||||
let request: DriveMutationRequest = serde_json::from_value(valid.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), valid);
|
||||
let mut invalid = valid.clone();
|
||||
invalid["mutation"]["bytes"] = json!([0, 255]);
|
||||
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
|
||||
let mut invalid = valid.clone();
|
||||
invalid["mutation"]["id"] = json!("7");
|
||||
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
|
||||
let mut invalid = valid.clone();
|
||||
invalid["mutation"]["expected_revision"] = json!("01");
|
||||
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
|
||||
// UTF-8 byte count, not character count, determines the text bound.
|
||||
let mut boundary = valid;
|
||||
boundary["mutation"]["text"] = json!("é".repeat(DRIVE_TEXT_MAX_BYTES / 2));
|
||||
assert!(serde_json::from_value::<DriveMutationRequest>(boundary.clone()).is_ok());
|
||||
boundary["mutation"]["text"] = json!("é".repeat(DRIVE_TEXT_MAX_BYTES / 2 + 1));
|
||||
assert!(serde_json::from_value::<DriveMutationRequest>(boundary).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_reads_reject_zero_or_over_64kib_requests() {
|
||||
for size in [0, 65537, u32::MAX] {
|
||||
assert!(
|
||||
serde_json::from_value::<DriveReadTextQuery>(
|
||||
json!({"entry_workspace_id":"a","id":"7","max_bytes":size})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(serde_json::from_value::<DriveReadChunkQuery>(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":0,"length":size})).is_err());
|
||||
}
|
||||
let default: DriveReadTextQuery =
|
||||
serde_json::from_value(json!({"entry_workspace_id":"a","id":"7"})).unwrap();
|
||||
assert_eq!(default.max_bytes, 65536);
|
||||
let chunk: DriveReadChunkQuery = serde_json::from_value(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":0,"length":65536})).unwrap();
|
||||
assert_eq!(chunk.length, 65536);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_upload_requires_binding_declared_integrity_and_request_identity() {
|
||||
let valid = json!({"operation":"update","request_id":"request-a","entry_workspace_id":"a","id":"7","expected_revision":"2","content_type":"application/octet-stream","size":3,"sha256":"a".repeat(64)});
|
||||
assert!(serde_json::from_value::<DriveUploadQuery>(valid.clone()).is_ok());
|
||||
for field in ["entry_workspace_id", "size", "sha256", "request_id"] {
|
||||
let mut missing = valid.clone();
|
||||
missing.as_object_mut().unwrap().remove(field);
|
||||
assert!(
|
||||
serde_json::from_value::<DriveUploadQuery>(missing).is_err(),
|
||||
"accepted missing {field}"
|
||||
);
|
||||
}
|
||||
for size in [DRIVE_FILE_MAX_BYTES, DRIVE_FILE_MAX_BYTES + 1, u32::MAX] {
|
||||
let mut oversized = valid.clone();
|
||||
oversized["size"] = json!(size);
|
||||
let query: DriveUploadQuery = serde_json::from_value(oversized).unwrap();
|
||||
assert_eq!(
|
||||
query.size, size,
|
||||
"service must receive oversized declared sizes for typed Limit classification"
|
||||
);
|
||||
}
|
||||
assert!(serde_json::from_value::<DriveReadChunkQuery>(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":DRIVE_FILE_MAX_BYTES,"length":1})).is_ok());
|
||||
let mut invalid = valid;
|
||||
invalid["bytes"] = json!([0, 255]);
|
||||
assert!(serde_json::from_value::<DriveUploadQuery>(invalid).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_grants_reject_command_access_and_unbounded_pages() {
|
||||
let grant = json!({"runtime_id":"r","worker_id":"w","access":"read_write"});
|
||||
assert_eq!(
|
||||
serde_json::to_value(
|
||||
serde_json::from_value::<DriveGrantCreateRequest>(grant.clone()).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
grant
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<DriveGrantCreateRequest>(
|
||||
json!({"runtime_id":"r","worker_id":"w","access":"read_write_command"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(serde_json::from_value::<DriveGrantCreateRequest>(json!({"runtime_id":"r","worker_id":"w","access":"read_only","workspace_id":"spoofed"})).is_err());
|
||||
for limit in [0, 201, u32::MAX] {
|
||||
assert!(serde_json::from_value::<DriveGrantListQuery>(json!({"limit":limit})).is_err());
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<DriveGrantListQuery>(json!({"limit":200,"after":"7"})).is_ok()
|
||||
);
|
||||
assert!(serde_json::from_value::<DriveGrantListQuery>(json!({"after":"01"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_errors_have_fixed_safe_messages_and_typed_outcome_classification() {
|
||||
use DriveApiErrorCode::*;
|
||||
for (code, wire, status, message) in [
|
||||
(Denied, "denied", 403, "Drive access denied"),
|
||||
(NotFound, "not_found", 404, "Drive resource not found"),
|
||||
(Conflict, "conflict", 409, "Drive revision or name conflict"),
|
||||
(Invalid, "invalid", 400, "Invalid Drive request"),
|
||||
(Limit, "limit", 413, "Drive request exceeds a limit"),
|
||||
(
|
||||
StorageUnavailable,
|
||||
"storage_unavailable",
|
||||
503,
|
||||
"Drive storage unavailable",
|
||||
),
|
||||
(
|
||||
OutcomeUnknown,
|
||||
"outcome_unknown",
|
||||
503,
|
||||
"Drive mutation outcome unknown",
|
||||
),
|
||||
] {
|
||||
let error = DriveApiError::new(code);
|
||||
assert_eq!(api_macros::HttpError::status_code(&error), status);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&error).unwrap(),
|
||||
json!({"code":wire,"classification":if code == OutcomeUnknown {"unknown"} else {"not_committed"},"message":message})
|
||||
);
|
||||
}
|
||||
for status in [400, 413, 415, 422] {
|
||||
let error = <DriveApiError as api_macros::HttpRequestError>::from_request_rejection(
|
||||
status,
|
||||
"/private/path: SQL failed".into(),
|
||||
);
|
||||
assert_eq!(
|
||||
error,
|
||||
DriveApiError::new(if status == 413 { Limit } else { Invalid })
|
||||
);
|
||||
}
|
||||
assert!(serde_json::from_value::<DriveApiError>(json!({"code":"invalid","classification":"not_committed","message":"Invalid Drive request","provider_error":"secret"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_routes_are_workspace_scoped_authenticated_and_binary_is_not_json() {
|
||||
let document = canonical_openapi_document().unwrap();
|
||||
let document: serde_json::Value =
|
||||
serde_json::from_str(&document.to_json().unwrap()).unwrap();
|
||||
let schemas = &document["components"]["schemas"];
|
||||
assert_eq!(
|
||||
schemas["DriveEntry"]["properties"]["size"]["maximum"],
|
||||
DRIVE_FILE_MAX_BYTES
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["DriveReadChunkQuery"]["properties"]["offset"]["maximum"],
|
||||
DRIVE_FILE_MAX_BYTES
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["DriveUploadQuery"]["properties"]["size"]["maximum"],
|
||||
DRIVE_FILE_MAX_BYTES
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["DriveSearchQuery"]["properties"]["limit"]["maximum"],
|
||||
DRIVE_SEARCH_MAX_LIMIT
|
||||
);
|
||||
for (name, method, suffix, wire_method) in [
|
||||
("drive_root", HttpMethod::Get, "/root", "get"),
|
||||
("drive_metadata", HttpMethod::Get, "/metadata", "get"),
|
||||
("drive_list", HttpMethod::Get, "/list", "get"),
|
||||
("drive_search", HttpMethod::Get, "/search", "get"),
|
||||
("drive_read_text", HttpMethod::Get, "/read-text", "get"),
|
||||
("drive_read_chunk", HttpMethod::Get, "/read-chunk", "get"),
|
||||
("drive_download", HttpMethod::Get, "/download", "get"),
|
||||
("drive_mutate", HttpMethod::Post, "/mutate", "post"),
|
||||
("drive_upload", HttpMethod::Put, "/upload", "put"),
|
||||
(
|
||||
"drive_request_status",
|
||||
HttpMethod::Get,
|
||||
"/requests/{request_id}",
|
||||
"get",
|
||||
),
|
||||
("drive_grant_create", HttpMethod::Post, "/grants", "post"),
|
||||
(
|
||||
"drive_grant_revoke",
|
||||
HttpMethod::Delete,
|
||||
"/grants/{grant_id}",
|
||||
"delete",
|
||||
),
|
||||
("drive_grant_list", HttpMethod::Get, "/grants", "get"),
|
||||
] {
|
||||
let operation = ServerApiMetadata::OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.operation_id == name)
|
||||
.unwrap();
|
||||
let path = format!("/api/w/{{workspace_id}}/drive{suffix}");
|
||||
assert_eq!(operation.method, method);
|
||||
assert_eq!(operation.path, path);
|
||||
let documented = &document["paths"][&path][wire_method];
|
||||
assert_eq!(documented["operationId"], name);
|
||||
assert_eq!(
|
||||
documented["security"],
|
||||
json!([{"bearerAuth":[]},{"browserSession":[]}])
|
||||
);
|
||||
// Server extensions must not become caller-controlled parameters.
|
||||
assert!(
|
||||
documented["parameters"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|parameter| parameter["name"] != "context")
|
||||
);
|
||||
if matches!(name, "drive_read_chunk" | "drive_download") {
|
||||
let content = &documented["responses"]["200"]["content"];
|
||||
assert!(content["application/octet-stream"].is_object());
|
||||
assert!(content["application/json"].is_null());
|
||||
}
|
||||
if name == "drive_download" {
|
||||
let headers = documented["responses"]["200"]["headers"]
|
||||
.as_object()
|
||||
.unwrap();
|
||||
assert_eq!(headers.len(), 5);
|
||||
for header in [
|
||||
"content-disposition",
|
||||
"cache-control",
|
||||
"etag",
|
||||
"x-content-type-options",
|
||||
"content-security-policy",
|
||||
] {
|
||||
assert_eq!(
|
||||
headers[header]["schema"]["$ref"],
|
||||
"#/components/schemas/string"
|
||||
);
|
||||
}
|
||||
assert!(!headers.contains_key("content-type"));
|
||||
assert!(!headers.contains_key("content-length"));
|
||||
}
|
||||
if name == "drive_upload" {
|
||||
let content = &documented["requestBody"]["content"];
|
||||
assert!(content["application/octet-stream"].is_object());
|
||||
assert!(content["application/json"].is_null());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ use api_macros::api;
|
||||
pub use api_macros::axum as server_support;
|
||||
pub use api_macros::reqwest as client_support;
|
||||
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
|
||||
mod drive;
|
||||
pub mod repository_openapi_typescript;
|
||||
pub use drive::*;
|
||||
mod workspace_config;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -2603,6 +2605,116 @@ pub trait ServerApi {
|
||||
#[path] working_directory_id: String,
|
||||
#[body] request: WorkingDirectoryRemovalRequest,
|
||||
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>;
|
||||
// Ordinary operations accept member browser sessions or trusted Worker request
|
||||
// context. The service resolves Worker grants; no operation accepts an actor or
|
||||
// grant ID from the caller. Grant management is Workspace-owner-only.
|
||||
#[get("/api/w/{workspace_id}/drive/root", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_root(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
) -> Result<DriveEntry, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/metadata", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_metadata(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveEntryQuery,
|
||||
) -> Result<DriveEntry, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/list", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_list(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveListQuery,
|
||||
) -> Result<DriveListResponse, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/search", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_search(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveSearchQuery,
|
||||
) -> Result<DriveListResponse, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/read-text", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_read_text(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveReadTextQuery,
|
||||
) -> Result<DriveReadTextResponse, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/read-chunk", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_read_chunk(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveReadChunkQuery,
|
||||
) -> Result<BinaryBody, DriveApiError>;
|
||||
// Download safety/cache headers are service-produced typed response metadata;
|
||||
// Content-Type/Content-Length remain controlled by the binary transport.
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/drive/download",
|
||||
responses = [(status = 200, body = BinaryBody, headers = [
|
||||
("content-disposition", String),
|
||||
("cache-control", String),
|
||||
("etag", String),
|
||||
("x-content-type-options", String),
|
||||
("content-security-policy", String)
|
||||
])],
|
||||
error_status = 400,
|
||||
additional_error_statuses = [401,403,404,409,413,503],
|
||||
bearer_auth = true,
|
||||
browser_auth = true
|
||||
)]
|
||||
async fn drive_download(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveDownloadQuery,
|
||||
) -> Result<server_api_responses::DriveDownload, DriveApiError>;
|
||||
#[post("/api/w/{workspace_id}/drive/mutate", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||
async fn drive_mutate(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: DriveMutationRequest,
|
||||
) -> Result<DriveMutationResponse, DriveApiError>;
|
||||
#[put("/api/w/{workspace_id}/drive/upload", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||
async fn drive_upload(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveUploadQuery,
|
||||
#[binary] body: BinaryBody,
|
||||
) -> Result<DriveMutationResponse, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/requests/{request_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_request_status(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] request_id: String,
|
||||
) -> Result<DriveRequestStatusResponse, DriveApiError>;
|
||||
#[post("/api/w/{workspace_id}/drive/grants", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||
async fn drive_grant_create(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: DriveGrantCreateRequest,
|
||||
) -> Result<DriveGrantResponse, DriveApiError>;
|
||||
#[delete("/api/w/{workspace_id}/drive/grants/{grant_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_grant_revoke(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[path] grant_id: String,
|
||||
) -> Result<DriveGrantResponse, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/grants", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_grant_list(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[query] query: DriveGrantListQuery,
|
||||
) -> Result<DriveGrantListResponse, DriveApiError>;
|
||||
|
||||
#[get("/api/w/{workspace_id}/workers/self/workspace-config", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
|
||||
async fn current_worker_workspace_config_get(
|
||||
&self,
|
||||
@@ -7380,7 +7492,7 @@ pub enum TicketAssignmentPrincipal {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TicketAssignmentRole {
|
||||
Orchestrator,
|
||||
Coder,
|
||||
Worker,
|
||||
Owner,
|
||||
Contributor,
|
||||
}
|
||||
@@ -7416,7 +7528,7 @@ pub struct TicketActionEligibility {
|
||||
pub can_assign_orchestrator: bool,
|
||||
pub can_unassign_orchestrator: bool,
|
||||
pub can_queue: bool,
|
||||
pub can_start_manual_coder: bool,
|
||||
pub can_start_manual_worker: bool,
|
||||
pub queue_tickets: Vec<String>,
|
||||
pub blockers: Vec<String>,
|
||||
}
|
||||
@@ -7554,7 +7666,7 @@ pub struct TicketQueryItem {
|
||||
pub evidence: TicketEvidenceSummary,
|
||||
pub merge_requests: Vec<TicketMergeRequestSummary>,
|
||||
pub merge_request: Option<TicketMergeRequestSummary>,
|
||||
pub current_coder: Option<TicketAssignmentSummary>,
|
||||
pub current_worker: Option<TicketAssignmentSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -7608,7 +7720,7 @@ pub struct TicketDetail {
|
||||
pub linked_objectives: Vec<ObjectiveLinkSummary>,
|
||||
pub implementation_reports: Vec<TicketEvidenceEvent>,
|
||||
pub assignments: Vec<TicketRoleAssignmentSummary>,
|
||||
pub current_coder: Option<TicketAssignmentSummary>,
|
||||
pub current_worker: Option<TicketAssignmentSummary>,
|
||||
pub assignment_diagnostics: Vec<String>,
|
||||
pub action_eligibility: TicketActionEligibility,
|
||||
pub merge_requests: Vec<TicketMergeRequestSummary>,
|
||||
@@ -7705,6 +7817,19 @@ pub struct TicketOrchestrationPlanSearchRequest {
|
||||
pub kind: Option<ticket::OrchestrationPlanKind>,
|
||||
}
|
||||
|
||||
/// Selected existing live Workdir attachment, including its current connection
|
||||
/// identity. The Workspace authority validates the selection against live
|
||||
/// attachments; callers must select them explicitly, and use an empty list
|
||||
/// only when the Worker has no live Workdirs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TicketWorkerAttachmentBinding {
|
||||
pub alias: String,
|
||||
pub working_directory_id: String,
|
||||
pub connection_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -7712,6 +7837,8 @@ pub struct SetTicketRoleAssignmentRequest {
|
||||
pub operation_id: String,
|
||||
pub principal: TicketAssignmentPrincipal,
|
||||
pub expected_assignment_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub workdir_bindings: Vec<TicketWorkerAttachmentBinding>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -8350,7 +8477,7 @@ pub enum RuntimeWorkerSpawnIntent {
|
||||
pub enum RuntimeTicketWorkerRole {
|
||||
Intake,
|
||||
Orchestrator,
|
||||
Coder,
|
||||
Worker,
|
||||
Reviewer,
|
||||
}
|
||||
|
||||
@@ -9729,7 +9856,9 @@ pub struct WorkerLaunchRuntimeOption {
|
||||
pub display_name: String,
|
||||
pub built_in: bool,
|
||||
pub worker_creation_available: bool,
|
||||
pub working_directory_required: bool,
|
||||
/// Whether this Runtime supports explicitly selected Workdir attachments.
|
||||
/// Workdirs are optional for generic launches; embedded Runtime does not support them.
|
||||
pub supports_workdir_attachments: bool,
|
||||
pub status: String,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
@@ -11121,6 +11250,70 @@ mod skill_typescript_tests {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ticket_role_assignment_request_defaults_empty_bindings_and_requires_each_selected_connection()
|
||||
{
|
||||
let base = serde_json::json!({"operation_id":"claim", "principal":{"kind":"worker","runtime_id":"r","worker_id":"w"}, "expected_assignment_id":null});
|
||||
let omitted: SetTicketRoleAssignmentRequest = serde_json::from_value(base.clone()).unwrap();
|
||||
assert!(omitted.workdir_bindings.is_empty());
|
||||
let mut selected = base;
|
||||
selected["workdir_bindings"] = serde_json::json!([{"alias":"main","working_directory_id":"checkout","connection_id":"connection"}]);
|
||||
let parsed: SetTicketRoleAssignmentRequest =
|
||||
serde_json::from_value(selected.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(parsed).unwrap(), selected);
|
||||
for key in ["alias", "working_directory_id", "connection_id"] {
|
||||
let mut malformed = selected.clone();
|
||||
malformed["workdir_bindings"][0]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove(key);
|
||||
assert!(
|
||||
serde_json::from_value::<SetTicketRoleAssignmentRequest>(malformed).is_err(),
|
||||
"{key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_worker_roles_and_manual_start_projection_use_worker_wire_names() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(TicketAssignmentRole::Worker).unwrap(),
|
||||
serde_json::json!("worker")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(RuntimeTicketWorkerRole::Worker).unwrap(),
|
||||
serde_json::json!("worker")
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<TicketAssignmentRole>(serde_json::json!("coder")).is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<RuntimeTicketWorkerRole>(serde_json::json!("coder")).is_err()
|
||||
);
|
||||
let eligibility = serde_json::json!({
|
||||
"can_assign_orchestrator": false, "can_unassign_orchestrator": false,
|
||||
"can_queue": true, "can_start_manual_worker": true, "queue_tickets": [], "blockers": []
|
||||
});
|
||||
let parsed: TicketActionEligibility = serde_json::from_value(eligibility.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(parsed).unwrap(), eligibility);
|
||||
let mut legacy = eligibility;
|
||||
legacy
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("can_start_manual_worker");
|
||||
legacy["can_start_manual_coder"] = serde_json::json!(true);
|
||||
assert!(serde_json::from_value::<TicketActionEligibility>(legacy).is_err());
|
||||
for schema in [
|
||||
schemars::schema_for!(TicketDetail),
|
||||
schemars::schema_for!(TicketQueryItem),
|
||||
] {
|
||||
let schema = serde_json::to_value(schema).unwrap();
|
||||
let properties = schema["properties"].as_object().unwrap();
|
||||
assert!(properties.contains_key("current_worker"));
|
||||
assert!(!properties.contains_key("current_coder"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_ticket_targets_are_one_collection_authority() {
|
||||
let edit = BrowserTicketTargetEdit::Set {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
mod run_stats;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use protocol::{
|
||||
Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
||||
@@ -850,6 +852,17 @@ fn read_history_turns_backward(
|
||||
let mut pending_entries = Vec::new();
|
||||
let mut seen_entries = HashSet::new();
|
||||
let mut corrections = HashMap::<String, SessionSnapshotEntryData>::new();
|
||||
// Accounting needs the whole Invoke range, including the marker immediately
|
||||
// before the oldest user entry on this page. The existing scan budgets also
|
||||
// bound this suffix; no unbounded second pass over the Session is needed.
|
||||
let mut accounting_records = Vec::new();
|
||||
let finish = |mut turns: Vec<PositionedHistoryTurn>, records: &[LogEntry]| {
|
||||
let stats = run_stats::project(records.iter().rev());
|
||||
for turn in &mut turns {
|
||||
run_stats::append_to(&mut turn.turn.entries, &stats);
|
||||
}
|
||||
turns
|
||||
};
|
||||
|
||||
// Carry only bounded log positions, never correction text or image bodies,
|
||||
// across pages. Re-read and validate the referenced records from adopted
|
||||
@@ -994,11 +1007,58 @@ fn read_history_turns_backward(
|
||||
&mut pending_entries,
|
||||
&mut turns,
|
||||
) {
|
||||
return Ok(turns);
|
||||
return Ok(finish(turns, &accounting_records));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// TurnEnd is the lineage boundary, but Worker persists final usage and
|
||||
// the run outcome immediately after it. For a compacted ancestor, read
|
||||
// only this accounting tail, never adopt later conversation records.
|
||||
if before_offset == adopted_end
|
||||
&& segment_index > 0
|
||||
&& matches!(
|
||||
lineage[segment_index - 1].origin.as_ref().map(|o| o.kind),
|
||||
Some(LineageOriginKind::Compact)
|
||||
)
|
||||
{
|
||||
let mut offset = adopted_end;
|
||||
let mut tail = Vec::new();
|
||||
while offset < segment.file_len {
|
||||
let (record, bytes) = store
|
||||
.read_next_log_record_read_only_bounded(
|
||||
session_id,
|
||||
segment.segment_id,
|
||||
offset,
|
||||
limits.max_scan_bytes.saturating_sub(*scanned_bytes),
|
||||
)
|
||||
.map_err(map_history_store_error)?;
|
||||
*scanned_bytes = scanned_bytes
|
||||
.checked_add(bytes)
|
||||
.ok_or(RetainedHistoryReadError::ResourceLimit)?;
|
||||
let Some(record) = record else { break };
|
||||
*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);
|
||||
}
|
||||
offset = record.end_offset;
|
||||
match record.entry {
|
||||
entry @ LogEntry::LlmUsage { .. } => tail.push(entry),
|
||||
LogEntry::Extension { .. } => {}
|
||||
entry @ (LogEntry::RunCompleted { .. }
|
||||
| LogEntry::RunYielded { .. }
|
||||
| LogEntry::RunCancelled { .. }
|
||||
| LogEntry::RunErrored { .. }) => {
|
||||
tail.push(entry);
|
||||
break;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
accounting_records.extend(tail.into_iter().rev());
|
||||
}
|
||||
let mut reader = store
|
||||
.open_retained_segment_reader(session_id, segment.segment_id, before_offset)
|
||||
.map_err(map_history_store_error)?;
|
||||
@@ -1025,6 +1085,16 @@ fn read_history_turns_backward(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let at_invoke = matches!(record.entry, LogEntry::Invoke { .. });
|
||||
accounting_records.push(record.entry.clone());
|
||||
if turns.len() >= turn_limit {
|
||||
if at_invoke {
|
||||
return Ok(finish(turns, &accounting_records));
|
||||
}
|
||||
// Older records here supply accounting only, never additional
|
||||
// conversation turns beyond the requested page boundary.
|
||||
continue;
|
||||
}
|
||||
if capture_tool_correction(&record.entry, &mut corrections) {
|
||||
let LogEntry::ToolResultCorrected { entry, .. } = &record.entry else {
|
||||
unreachable!()
|
||||
@@ -1044,7 +1114,7 @@ fn read_history_turns_backward(
|
||||
&project_history_record(session_id, segment.segment_id, &record.entry),
|
||||
&corrections,
|
||||
);
|
||||
if collect_history_entries_backward(
|
||||
collect_history_entries_backward(
|
||||
&entries,
|
||||
segment.segment_id,
|
||||
record.start_offset,
|
||||
@@ -1054,12 +1124,11 @@ fn read_history_turns_backward(
|
||||
&mut seen_entries,
|
||||
&mut pending_entries,
|
||||
&mut turns,
|
||||
) {
|
||||
return Ok(turns);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if segment_index + 1 == lineage.len()
|
||||
if turns.len() < turn_limit
|
||||
&& segment_index + 1 == lineage.len()
|
||||
&& collect_history_entries_backward(
|
||||
&corrected_history_entries(&segment.seed_entries, &corrections),
|
||||
segment.segment_id,
|
||||
@@ -1072,10 +1141,10 @@ fn read_history_turns_backward(
|
||||
&mut turns,
|
||||
)
|
||||
{
|
||||
return Ok(turns);
|
||||
return Ok(finish(turns, &accounting_records));
|
||||
}
|
||||
}
|
||||
Ok(turns)
|
||||
Ok(finish(turns, &accounting_records))
|
||||
}
|
||||
|
||||
fn capture_tool_correction(
|
||||
@@ -1322,6 +1391,148 @@ pub fn project_current_session_snapshot(log: &[LogEntry]) -> SessionSnapshot {
|
||||
project_session_snapshot(session_id.unwrap_or_else(SessionId::nil), log)
|
||||
}
|
||||
|
||||
fn run_accounting_entries(session_id: &SessionId, log: &[LogEntry]) -> Vec<SessionSnapshotEntry> {
|
||||
let start = log
|
||||
.iter()
|
||||
.rposition(|entry| matches!(entry, LogEntry::Invoke { .. }))
|
||||
.unwrap_or(0);
|
||||
log.iter()
|
||||
.enumerate()
|
||||
.skip(start)
|
||||
.filter_map(|(index, record)| {
|
||||
let (ts, data) = match record {
|
||||
LogEntry::Invoke { ts, trigger } => {
|
||||
(*ts, SessionSnapshotEntryData::Invoke { trigger: *trigger })
|
||||
}
|
||||
LogEntry::LlmUsage {
|
||||
ts,
|
||||
input_total_tokens,
|
||||
cache_read_tokens,
|
||||
output_tokens,
|
||||
..
|
||||
} => (
|
||||
*ts,
|
||||
SessionSnapshotEntryData::Usage {
|
||||
input_tokens: *input_total_tokens,
|
||||
cache_read_input_tokens: *cache_read_tokens,
|
||||
output_tokens: *output_tokens,
|
||||
},
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
Some(legacy_entry(session_id, None, index, 0, ts, data))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reconstruct snapshot accounting from existing compacted ancestors, without a
|
||||
/// second accumulator or checkpoint. The live suffix is the caller's frozen
|
||||
/// mirror prefix; never reread the active segment after subscribing.
|
||||
pub fn project_current_session_snapshot_with_accounting(
|
||||
store: &dyn crate::Store,
|
||||
log: &[LogEntry],
|
||||
) -> SessionSnapshot {
|
||||
let mut snapshot = project_current_session_snapshot(log);
|
||||
let session_id = log
|
||||
.iter()
|
||||
.find_map(|entry| match entry {
|
||||
LogEntry::AnnotatedSegmentStart { session_id, .. } => Some(*session_id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(SessionId::nil);
|
||||
snapshot
|
||||
.entries
|
||||
.extend(run_accounting_entries(&session_id, log));
|
||||
if log
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, LogEntry::Invoke { .. }))
|
||||
{
|
||||
return snapshot;
|
||||
}
|
||||
let mut source = log.to_vec();
|
||||
let mut ancestors = Vec::new();
|
||||
let mut visited = HashSet::new();
|
||||
let mut count = log.len();
|
||||
for _ in 0..DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS {
|
||||
let Some((session_id, origin)) = source.iter().find_map(|entry| match entry {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
session_id,
|
||||
compacted_from: Some(origin),
|
||||
..
|
||||
} => Some((*session_id, origin.clone())),
|
||||
_ => None,
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
if !visited.insert(origin.segment_id) {
|
||||
break;
|
||||
}
|
||||
let Ok(mut entries) = store.read_all(session_id, origin.segment_id) else {
|
||||
break;
|
||||
};
|
||||
count = count.saturating_add(entries.len());
|
||||
if count > DEFAULT_RETAINED_HISTORY_MAX_ENTRIES {
|
||||
break;
|
||||
}
|
||||
// Compaction adopts through TurnEnd plus its trailing usage/outcome,
|
||||
// never through a subsequent Invoke or unrelated conversation.
|
||||
if let Some(end) = entries.iter().position(|entry| {
|
||||
matches!(entry, LogEntry::TurnEnd { turn_count, .. } if *turn_count == origin.at_turn_index)
|
||||
}) {
|
||||
let mut cutoff = end + 1;
|
||||
while cutoff < entries.len()
|
||||
&& matches!(entries[cutoff],
|
||||
LogEntry::LlmUsage { .. } | LogEntry::Extension { .. }
|
||||
| LogEntry::RunCompleted { .. } | LogEntry::RunYielded { .. }
|
||||
| LogEntry::RunErrored { .. } | LogEntry::RunCancelled { .. })
|
||||
{
|
||||
cutoff += 1;
|
||||
}
|
||||
entries.truncate(cutoff);
|
||||
} else if matches!(entries.first(), Some(LogEntry::AnnotatedSegmentStart {
|
||||
compacted_from: Some(inherited), ..
|
||||
}) if origin.at_turn_index <= inherited.at_turn_index) {
|
||||
// Consecutive compactions can adopt only the inherited seed.
|
||||
entries.truncate(1);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
let complete = entries
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, LogEntry::Invoke { .. }));
|
||||
source = entries.clone();
|
||||
ancestors.push(entries);
|
||||
if complete {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !ancestors.is_empty() {
|
||||
let combined: Vec<LogEntry> = ancestors
|
||||
.into_iter()
|
||||
.rev()
|
||||
.flatten()
|
||||
.chain(log.iter().cloned())
|
||||
.collect();
|
||||
snapshot.entries.retain(|entry| {
|
||||
!matches!(
|
||||
entry.data,
|
||||
SessionSnapshotEntryData::Invoke { .. } | SessionSnapshotEntryData::Usage { .. }
|
||||
)
|
||||
});
|
||||
let session_id = log
|
||||
.iter()
|
||||
.find_map(|entry| match entry {
|
||||
LogEntry::AnnotatedSegmentStart { session_id, .. } => Some(*session_id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(SessionId::nil);
|
||||
snapshot
|
||||
.entries
|
||||
.extend(run_accounting_entries(&session_id, &combined));
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
/// Project the current durable segment into the only public session-history
|
||||
/// representation. Append-log records remain an internal persistence format.
|
||||
pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> SessionSnapshot {
|
||||
@@ -1523,6 +1734,7 @@ pub(crate) fn project_session_snapshot_for_segment(
|
||||
}
|
||||
}
|
||||
|
||||
run_stats::append_to(&mut entries, &run_stats::project(log));
|
||||
SessionSnapshot {
|
||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
||||
entries,
|
||||
@@ -1800,6 +2012,258 @@ mod tests {
|
||||
LoggedWorkerSubject, Store, WorkerMetadataStore,
|
||||
};
|
||||
|
||||
fn usage(ts: u64) -> LogEntry {
|
||||
LogEntry::LlmUsage {
|
||||
ts,
|
||||
history_len: 2,
|
||||
input_total_tokens: 2500,
|
||||
cache_read_tokens: 1000,
|
||||
cache_write_tokens: 0,
|
||||
output_tokens: 200,
|
||||
}
|
||||
}
|
||||
|
||||
fn complete(ts: u64) -> LogEntry {
|
||||
LogEntry::RunCompleted {
|
||||
ts,
|
||||
interrupted: false,
|
||||
result: agen::EngineResult::Finished,
|
||||
active_run_turn_count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn stats_entries(entries: &[SessionSnapshotEntry]) -> Vec<SessionSnapshotEntry> {
|
||||
entries
|
||||
.iter()
|
||||
.filter(|entry| matches!(entry.data, SessionSnapshotEntryData::RunStats { .. }))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_stats_restore_identically_from_snapshot_and_history_pages() {
|
||||
let session_id = SessionId::now_v7();
|
||||
let segment_id = SegmentId::now_v7();
|
||||
let mut log = vec![segment_start(session_id, vec![], None, None)];
|
||||
for turn in 1..=6 {
|
||||
append_turn(&mut log, turn);
|
||||
log.push(usage(turn as u64 * 10 + 4));
|
||||
log.push(complete(turn as u64 * 10 + 8));
|
||||
}
|
||||
let expected = stats_entries(&project_current_session_snapshot(&log).entries);
|
||||
assert_eq!(expected.len(), 6);
|
||||
assert_eq!(
|
||||
expected[0].data,
|
||||
SessionSnapshotEntryData::RunStats {
|
||||
elapsed_ms: 8,
|
||||
requests: 1,
|
||||
upload_tokens: 1500,
|
||||
output_tokens: 200,
|
||||
}
|
||||
);
|
||||
let (_root, path) =
|
||||
persist_history_fixture("stats", session_id, segment_id, vec![(segment_id, log)]);
|
||||
let mut cursor = None;
|
||||
let mut actual = Vec::new();
|
||||
loop {
|
||||
let page = read_retained_session_history_page(
|
||||
&path,
|
||||
"stats",
|
||||
cursor.as_deref(),
|
||||
Some(1),
|
||||
RetainedHistoryReadLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(page.turns.len(), 1);
|
||||
actual.splice(0..0, stats_entries(&page.turns[0].entries));
|
||||
cursor = page.next_cursor;
|
||||
if !page.has_more {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_stats_span_compaction_without_counting_seed_history_twice() {
|
||||
let session_id = SessionId::now_v7();
|
||||
let source_id = SegmentId::now_v7();
|
||||
let active_id = SegmentId::now_v7();
|
||||
let mut source = vec![segment_start(session_id, vec![], None, None)];
|
||||
append_turn(&mut source, 1);
|
||||
source.push(usage(15));
|
||||
source.push(LogEntry::RunYielded {
|
||||
ts: 16,
|
||||
entry_id: None,
|
||||
reason: protocol::RunYieldReason::Compaction,
|
||||
active_run_turn_count: 1,
|
||||
});
|
||||
let active = vec![
|
||||
segment_start(
|
||||
session_id,
|
||||
vec![user_message(1), assistant_message(1, "final")],
|
||||
None,
|
||||
Some(SegmentOrigin {
|
||||
segment_id: source_id,
|
||||
at_turn_index: 1,
|
||||
}),
|
||||
),
|
||||
LogEntry::RunResumed {
|
||||
ts: 20,
|
||||
entry_id: None,
|
||||
source: protocol::RunResumeSource::Compaction,
|
||||
active_run_turn_count: 1,
|
||||
},
|
||||
usage(25),
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 28,
|
||||
entry: assistant_message(1, "after-compact"),
|
||||
},
|
||||
complete(30),
|
||||
];
|
||||
assert!(
|
||||
stats_entries(&project_current_session_snapshot(&active).entries).is_empty(),
|
||||
"a partial segment must not invent the missing start or traffic"
|
||||
);
|
||||
let (_root, path) = persist_history_fixture(
|
||||
"stats",
|
||||
session_id,
|
||||
active_id,
|
||||
vec![(source_id, source), (active_id, active)],
|
||||
);
|
||||
let page = read_retained_session_history_page(
|
||||
&path,
|
||||
"stats",
|
||||
None,
|
||||
Some(1),
|
||||
RetainedHistoryReadLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let stats = stats_entries(&page.turns[0].entries);
|
||||
assert_eq!(stats.len(), 1);
|
||||
assert_eq!(stats[0].entry_id, "run-stats:assistant-1-after-compact");
|
||||
assert_eq!(
|
||||
stats[0].data,
|
||||
SessionSnapshotEntryData::RunStats {
|
||||
elapsed_ms: 20,
|
||||
requests: 2,
|
||||
upload_tokens: 3000,
|
||||
output_tokens: 400,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnect_accounting_replays_existing_usage_across_compaction() {
|
||||
let session_id = SessionId::now_v7();
|
||||
let source_id = SegmentId::now_v7();
|
||||
let active_id = SegmentId::now_v7();
|
||||
let mut source = vec![segment_start(session_id, vec![], None, None)];
|
||||
append_turn(&mut source, 1);
|
||||
source.push(usage(15));
|
||||
let active = vec![
|
||||
segment_start(
|
||||
session_id,
|
||||
vec![],
|
||||
None,
|
||||
Some(SegmentOrigin {
|
||||
segment_id: source_id,
|
||||
at_turn_index: 1,
|
||||
}),
|
||||
),
|
||||
usage(25),
|
||||
];
|
||||
let (_root, path) = persist_history_fixture(
|
||||
"accounting",
|
||||
session_id,
|
||||
active_id,
|
||||
vec![(source_id, source), (active_id, active.clone())],
|
||||
);
|
||||
let store = WorkerSessionStore::new(path.join("session")).unwrap();
|
||||
let snapshot = project_current_session_snapshot_with_accounting(&store, &active);
|
||||
let records: Vec<_> = snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
matches!(
|
||||
entry.data,
|
||||
SessionSnapshotEntryData::Invoke { .. }
|
||||
| SessionSnapshotEntryData::Usage { .. }
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(records.len(), 3);
|
||||
assert_eq!(
|
||||
records
|
||||
.iter()
|
||||
.map(|entry| entry.timestamp)
|
||||
.collect::<Vec<_>>(),
|
||||
[10, 15, 25]
|
||||
);
|
||||
assert!(matches!(
|
||||
records[0].data,
|
||||
SessionSnapshotEntryData::Invoke {
|
||||
trigger: InvokeKind::UserSend
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
records[1].data,
|
||||
SessionSnapshotEntryData::Usage {
|
||||
input_tokens: 2500,
|
||||
cache_read_input_tokens: 1000,
|
||||
output_tokens: 200
|
||||
}
|
||||
));
|
||||
assert!(matches!(
|
||||
records[2].data,
|
||||
SessionSnapshotEntryData::Usage {
|
||||
input_tokens: 2500,
|
||||
cache_read_input_tokens: 1000,
|
||||
output_tokens: 200
|
||||
}
|
||||
));
|
||||
|
||||
// A second compaction can adopt only the first one's inherited seed.
|
||||
// Its unadopted usage must not leak into the restored totals.
|
||||
let next = vec![
|
||||
segment_start(
|
||||
session_id,
|
||||
vec![],
|
||||
None,
|
||||
Some(SegmentOrigin {
|
||||
segment_id: active_id,
|
||||
at_turn_index: 1,
|
||||
}),
|
||||
),
|
||||
usage(35),
|
||||
];
|
||||
let snapshot = project_current_session_snapshot_with_accounting(&store, &next);
|
||||
let timestamps: Vec<_> = snapshot
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
matches!(
|
||||
entry.data,
|
||||
SessionSnapshotEntryData::Invoke { .. }
|
||||
| SessionSnapshotEntryData::Usage { .. }
|
||||
)
|
||||
.then_some(entry.timestamp)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(timestamps, [10, 15, 35]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_stats_do_not_complete_an_active_or_unmeasured_run() {
|
||||
let mut log = Vec::new();
|
||||
append_turn(&mut log, 1);
|
||||
log.push(usage(15));
|
||||
assert!(stats_entries(&project_current_session_snapshot(&log).entries).is_empty());
|
||||
log.pop();
|
||||
log.push(complete(20));
|
||||
assert!(stats_entries(&project_current_session_snapshot(&log).entries).is_empty());
|
||||
}
|
||||
|
||||
fn history_message(
|
||||
entry_id: impl Into<String>,
|
||||
role: LoggedRole,
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Display-only accounting; never inserted into model history or written back
|
||||
//! to the journal. Both snapshot and bounded history paging use this projection.
|
||||
use std::collections::HashMap;
|
||||
|
||||
use protocol::{SessionEntryProvenance, SessionSnapshotEntry, SessionSnapshotEntryData};
|
||||
|
||||
use crate::LogEntry;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Activity {
|
||||
started_at: u64,
|
||||
requests: u64,
|
||||
upload: u64,
|
||||
output: u64,
|
||||
anchor: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn project<'a>(
|
||||
records: impl IntoIterator<Item = &'a LogEntry>,
|
||||
) -> HashMap<String, SessionSnapshotEntry> {
|
||||
let mut active: Option<Activity> = None;
|
||||
let mut stats = HashMap::new();
|
||||
for record in records {
|
||||
match record {
|
||||
LogEntry::Invoke { ts, .. } => {
|
||||
active = Some(Activity {
|
||||
started_at: *ts,
|
||||
..Activity::default()
|
||||
})
|
||||
}
|
||||
LogEntry::LlmUsage {
|
||||
input_total_tokens,
|
||||
cache_read_tokens,
|
||||
output_tokens,
|
||||
..
|
||||
} => {
|
||||
if let Some(run) = &mut active {
|
||||
run.requests += 1;
|
||||
run.upload = run
|
||||
.upload
|
||||
.saturating_add(input_total_tokens.saturating_sub(*cache_read_tokens));
|
||||
run.output = run.output.saturating_add(*output_tokens);
|
||||
}
|
||||
}
|
||||
LogEntry::AnnotatedUserInput { history, .. } => {
|
||||
if let Some(run) = &mut active {
|
||||
for entry in history {
|
||||
if super::project_item(&entry.metadata.entry_id.0, &entry.item).is_some() {
|
||||
run.anchor = Some(entry.metadata.entry_id.0.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||
| LogEntry::AnnotatedToolResult { entry, .. } => {
|
||||
if let Some(run) = &mut active
|
||||
&& super::project_item(&entry.metadata.entry_id.0, &entry.item).is_some()
|
||||
{
|
||||
run.anchor = Some(entry.metadata.entry_id.0.clone());
|
||||
}
|
||||
}
|
||||
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||
if let Some(run) = &mut active {
|
||||
run.anchor = Some(entry.metadata.entry_id.0.clone());
|
||||
}
|
||||
}
|
||||
LogEntry::RunCompleted { ts, result, .. }
|
||||
if !matches!(result, agen::EngineResult::Yielded) =>
|
||||
{
|
||||
finish(&active, *ts, &mut stats);
|
||||
// Pause/resume retains the Invoke's cumulative accounting, just
|
||||
// like the live TUI. Terminal outcomes cannot leak traffic into
|
||||
// a later resumed segment whose Invoke is unavailable.
|
||||
if !matches!(result, agen::EngineResult::Paused) {
|
||||
active = None;
|
||||
}
|
||||
}
|
||||
LogEntry::RunCancelled { ts, .. } | LogEntry::RunErrored { ts, .. } => {
|
||||
finish(&active, *ts, &mut stats);
|
||||
active = None;
|
||||
}
|
||||
// Compaction seeds repeat retained entries, not fresh traffic.
|
||||
// A page traverses adopted lineage in journal order and retains the
|
||||
// open Invoke across these checkpoints. A standalone new segment
|
||||
// without that Invoke cannot claim complete accounting.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
stats
|
||||
}
|
||||
|
||||
fn finish(
|
||||
active: &Option<Activity>,
|
||||
ended_at: u64,
|
||||
stats: &mut HashMap<String, SessionSnapshotEntry>,
|
||||
) {
|
||||
let Some(run) = active else { return };
|
||||
let Some(anchor) = &run.anchor else { return };
|
||||
// No usage records means unavailable measurement, not known zero traffic.
|
||||
if run.requests == 0 {
|
||||
return;
|
||||
}
|
||||
stats.insert(
|
||||
anchor.clone(),
|
||||
SessionSnapshotEntry {
|
||||
entry_id: format!("run-stats:{anchor}"),
|
||||
timestamp: ended_at,
|
||||
provenance: SessionEntryProvenance::LegacyUnknown,
|
||||
derived_from: vec![anchor.clone()],
|
||||
data: SessionSnapshotEntryData::RunStats {
|
||||
elapsed_ms: ended_at.saturating_sub(run.started_at),
|
||||
requests: run.requests,
|
||||
upload_tokens: run.upload,
|
||||
output_tokens: run.output,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn append_to(
|
||||
entries: &mut Vec<SessionSnapshotEntry>,
|
||||
stats: &HashMap<String, SessionSnapshotEntry>,
|
||||
) {
|
||||
let mut with_stats = Vec::with_capacity(entries.len());
|
||||
for entry in std::mem::take(entries) {
|
||||
let summary = stats.get(&entry.entry_id);
|
||||
with_stats.push(entry);
|
||||
if let Some(summary) = summary {
|
||||
with_stats.push(summary.clone());
|
||||
}
|
||||
}
|
||||
*entries = with_stats;
|
||||
}
|
||||
@@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize};
|
||||
pub enum TicketRole {
|
||||
Intake,
|
||||
Orchestrator,
|
||||
Coder,
|
||||
Worker,
|
||||
Reviewer,
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ impl TicketRole {
|
||||
pub const ALL: [TicketRole; 4] = [
|
||||
TicketRole::Intake,
|
||||
TicketRole::Orchestrator,
|
||||
TicketRole::Coder,
|
||||
TicketRole::Worker,
|
||||
TicketRole::Reviewer,
|
||||
];
|
||||
|
||||
@@ -33,7 +33,7 @@ impl TicketRole {
|
||||
match self {
|
||||
Self::Intake => "intake",
|
||||
Self::Orchestrator => "orchestrator",
|
||||
Self::Coder => "coder",
|
||||
Self::Worker => "worker",
|
||||
Self::Reviewer => "reviewer",
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ impl TicketRole {
|
||||
match value {
|
||||
"intake" => Some(Self::Intake),
|
||||
"orchestrator" => Some(Self::Orchestrator),
|
||||
"coder" => Some(Self::Coder),
|
||||
"worker" => Some(Self::Worker),
|
||||
"reviewer" => Some(Self::Reviewer),
|
||||
_ => None,
|
||||
}
|
||||
@@ -52,7 +52,7 @@ impl TicketRole {
|
||||
match self {
|
||||
Self::Intake => "builtin:intake",
|
||||
Self::Orchestrator => "builtin:orchestrator",
|
||||
Self::Coder => "builtin:coder",
|
||||
Self::Worker => "builtin:default",
|
||||
Self::Reviewer => "builtin:reviewer",
|
||||
}
|
||||
}
|
||||
@@ -96,4 +96,20 @@ mod tests {
|
||||
}
|
||||
assert_eq!(TicketRole::parse("operator"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_role_uses_worker_wire_name_and_default_profile() {
|
||||
assert_eq!(TicketRole::parse("worker"), Some(TicketRole::Worker));
|
||||
assert_eq!(TicketRole::parse("coder"), None);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&TicketRole::Worker).unwrap(),
|
||||
"\"worker\""
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<TicketRole>("\"worker\"").unwrap(),
|
||||
TicketRole::Worker
|
||||
);
|
||||
assert!(serde_json::from_str::<TicketRole>("\"coder\"").is_err());
|
||||
assert_eq!(TicketRole::Worker.default_profile(), "builtin:default");
|
||||
}
|
||||
}
|
||||
|
||||
+135
-26
@@ -26,7 +26,6 @@ pub mod tool;
|
||||
|
||||
pub use sqlite_schema::{migrate_sqlite_ticket_schema, verify_sqlite_ticket_schema};
|
||||
|
||||
const MAX_STATE_CHANGE_REASON_BYTES: usize = 1024;
|
||||
const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024;
|
||||
const MAX_ORCHESTRATION_PLAN_TEXT_BYTES: usize = 16 * 1024;
|
||||
const MAX_ORCHESTRATION_PLAN_FIELD_BYTES: usize = 1024;
|
||||
@@ -2360,7 +2359,7 @@ impl SqliteTicketBackend {
|
||||
.event_attributes
|
||||
.get("source_actor_role")
|
||||
.map(String::as_str)
|
||||
!= Some("coder")
|
||||
!= Some("ticket_worker")
|
||||
&& !self.event_attributes.contains_key("source_assignment_id")
|
||||
{
|
||||
return Ok(());
|
||||
@@ -4293,11 +4292,12 @@ fn validate_required_event_value(label: &str, value: &str) -> Result<()> {
|
||||
fn validate_state_change(change: &TicketStateChange) -> Result<()> {
|
||||
validate_required_event_value("from", &change.from)?;
|
||||
validate_required_event_value("to", &change.to)?;
|
||||
validate_required_event_value("reason", &change.reason)?;
|
||||
if change.reason.len() > MAX_STATE_CHANGE_REASON_BYTES {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"state_changed reason exceeds {MAX_STATE_CHANGE_REASON_BYTES} bytes"
|
||||
)));
|
||||
// Reasons are persisted as SQLite text, not legacy thread-comment attributes.
|
||||
// Preserve free-form Markdown without per-field length or single-line limits.
|
||||
if change.reason.trim().is_empty() {
|
||||
return Err(TicketError::Conflict(
|
||||
"state_changed event requires non-empty reason".to_owned(),
|
||||
));
|
||||
}
|
||||
if let Some(author) = change.author.as_deref() {
|
||||
validate_required_event_value("author", author)?;
|
||||
@@ -4306,11 +4306,6 @@ fn validate_state_change(change: &TicketStateChange) -> Result<()> {
|
||||
validate_required_event_value("reference_kind", &reference.kind)?;
|
||||
validate_required_event_value("reference_target", &reference.target)?;
|
||||
}
|
||||
if change.body.as_str().len() > MAX_INTAKE_SUMMARY_BODY_BYTES {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"state_changed body exceeds {MAX_INTAKE_SUMMARY_BODY_BYTES} bytes"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -5678,6 +5673,41 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unassigned_worker_actor_is_distinct_from_ticket_worker_requiring_assignment() {
|
||||
for role in ["worker", "ticket_worker"] {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let (reference, ticket) = backend
|
||||
.create_with_snapshot(NewTicket::new("Actor boundary"))
|
||||
.unwrap();
|
||||
let source = backend.clone().with_event_attributes(BTreeMap::from([
|
||||
("source_actor_role".into(), role.into()),
|
||||
("source_runtime_id".into(), "runtime".into()),
|
||||
("source_worker_id".into(), "worker".into()),
|
||||
]));
|
||||
let result = source.complete(&reference.id, completion_request(&ticket, "complete"));
|
||||
if role == "worker" {
|
||||
let completed = result.unwrap();
|
||||
assert_eq!(
|
||||
completed
|
||||
.events
|
||||
.last()
|
||||
.unwrap()
|
||||
.attributes
|
||||
.get("source_actor_role")
|
||||
.map(String::as_str),
|
||||
Some("worker")
|
||||
);
|
||||
} else {
|
||||
assert!(result.is_err());
|
||||
let reread = backend.show(reference.id.into()).unwrap();
|
||||
assert_eq!(reread.meta.workflow_state, ticket.meta.workflow_state);
|
||||
assert_eq!(reread.events.len(), ticket.events.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assignment_fence_accepts_exact_live_source_inside_state_and_close_transaction() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -5686,7 +5716,7 @@ mod tests {
|
||||
.create_with_snapshot(NewTicket::new("Active source"))
|
||||
.unwrap();
|
||||
active_assignment_fixture(&backend, &reference.id);
|
||||
let source = backend.clone().with_event_attributes(assignment_source("coder"))
|
||||
let source = backend.clone().with_event_attributes(assignment_source("ticket_worker"))
|
||||
.with_mutation_hook(Arc::new(|conn, _| {
|
||||
assert!(!conn.is_autocommit());
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM ticket_active_worker_assignments WHERE runtime_id = 'runtime-a' AND worker_id = 'worker-a' AND assignment_id = 'assignment-a'", [], |row| row.get(0)).map_err(sqlite_err)?;
|
||||
@@ -5735,7 +5765,7 @@ mod tests {
|
||||
"source_worker_id",
|
||||
"source_assignment_id",
|
||||
] {
|
||||
let mut attrs = assignment_source("coder");
|
||||
let mut attrs = assignment_source("ticket_worker");
|
||||
attrs.insert(key.to_owned(), "other".to_owned());
|
||||
let source = backend.clone().with_event_attributes(attrs);
|
||||
assert!(
|
||||
@@ -5763,7 +5793,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let source = backend
|
||||
.clone()
|
||||
.with_event_attributes(assignment_source("coder"));
|
||||
.with_event_attributes(assignment_source("ticket_worker"));
|
||||
assert!(
|
||||
source
|
||||
.complete(&other.id, completion_request(&other_ticket, "complete"))
|
||||
@@ -5777,7 +5807,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let source = other_workspace
|
||||
.clone()
|
||||
.with_event_attributes(assignment_source("coder"));
|
||||
.with_event_attributes(assignment_source("ticket_worker"));
|
||||
assert!(
|
||||
source
|
||||
.complete(&other.id, completion_request(&other_ticket, "complete"))
|
||||
@@ -5798,7 +5828,7 @@ mod tests {
|
||||
"source_worker_id",
|
||||
"source_assignment_id",
|
||||
] {
|
||||
let mut attrs = assignment_source("coder");
|
||||
let mut attrs = assignment_source("ticket_worker");
|
||||
attrs.remove(key);
|
||||
let source = backend.clone().with_event_attributes(attrs);
|
||||
assert!(
|
||||
@@ -5809,7 +5839,7 @@ mod tests {
|
||||
}
|
||||
let source = backend
|
||||
.clone()
|
||||
.with_event_attributes(assignment_source("coder"));
|
||||
.with_event_attributes(assignment_source("ticket_worker"));
|
||||
assert!(
|
||||
source
|
||||
.complete(&reference.id, completion_request(&ticket, "complete"))
|
||||
@@ -5838,7 +5868,7 @@ mod tests {
|
||||
active_assignment_fixture(&backend, &reference.id);
|
||||
let source = backend
|
||||
.clone()
|
||||
.with_event_attributes(assignment_source("coder"));
|
||||
.with_event_attributes(assignment_source("ticket_worker"));
|
||||
let request = completion_request(&ticket, "complete");
|
||||
let completed = source.complete(&reference.id, request.clone()).unwrap();
|
||||
release_assignment_fixture(&backend);
|
||||
@@ -5906,7 +5936,7 @@ mod tests {
|
||||
active_assignment_fixture(&backend, &reference.id);
|
||||
let source = backend
|
||||
.clone()
|
||||
.with_event_attributes(assignment_source("coder"));
|
||||
.with_event_attributes(assignment_source("ticket_worker"));
|
||||
let request = completion_request(&ticket, "complete");
|
||||
let completed = source.complete(&reference.id, request.clone()).unwrap();
|
||||
release_assignment_fixture(&backend);
|
||||
@@ -5916,7 +5946,7 @@ mod tests {
|
||||
"source_assignment_id",
|
||||
"source_actor_role",
|
||||
] {
|
||||
let mut attrs = assignment_source("coder");
|
||||
let mut attrs = assignment_source("ticket_worker");
|
||||
attrs.insert(key.to_owned(), "different-source".to_owned());
|
||||
let other_source = backend.clone().with_event_attributes(attrs);
|
||||
assert!(matches!(
|
||||
@@ -6069,7 +6099,7 @@ mod tests {
|
||||
active_assignment_fixture(&backend, &reference.id);
|
||||
let source = backend
|
||||
.clone()
|
||||
.with_event_attributes(assignment_source("coder"));
|
||||
.with_event_attributes(assignment_source("ticket_worker"));
|
||||
let request = completion_request(&ticket, "complete");
|
||||
let completed = source.complete(&reference.id, request.clone()).unwrap();
|
||||
release_assignment_fixture(&backend);
|
||||
@@ -6186,6 +6216,89 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_preserves_long_and_multiline_reasons_across_reload_and_replay() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let db = tmp.path().join("tickets.db");
|
||||
let backend = SqliteTicketBackend::open(&db, "workspace-test").unwrap();
|
||||
for reason in [
|
||||
"x".repeat(1025),
|
||||
"日本語の完了理由を省略せず記録する。".repeat(1024),
|
||||
"## 完了理由\r\n\n- 検証済み\n<!-- 詳細 -->\n".repeat(1024),
|
||||
] {
|
||||
let (reference, ticket) = backend
|
||||
.create_with_snapshot(NewTicket::new("Long completion reason"))
|
||||
.unwrap();
|
||||
let request = TicketCompletion {
|
||||
reason: reason.clone(),
|
||||
..completion_request(&ticket, "complete")
|
||||
};
|
||||
let completed = backend.complete(&reference.id, request.clone()).unwrap();
|
||||
assert_eq!(completed.meta.workflow_state, TicketWorkflowState::Done);
|
||||
assert_eq!(completed.events.len(), ticket.events.len() + 1);
|
||||
let event = completed.events.last().unwrap();
|
||||
assert_eq!(event.reason.as_deref(), Some(reason.as_str()));
|
||||
assert_eq!(event.body.as_str(), reason);
|
||||
|
||||
let reopened = SqliteTicketBackend::open(&db, "workspace-test").unwrap();
|
||||
assert_eq!(
|
||||
reopened.show(reference.id.clone().into()).unwrap(),
|
||||
completed
|
||||
);
|
||||
assert_eq!(
|
||||
reopened.complete(&reference.id, request.clone()).unwrap(),
|
||||
completed
|
||||
);
|
||||
let changed = TicketCompletion {
|
||||
reason: format!("{reason}追加の判断"),
|
||||
..request
|
||||
};
|
||||
assert!(matches!(
|
||||
reopened.complete(&reference.id, changed),
|
||||
Err(TicketError::OperationFingerprintMismatch { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_changes_and_close_preserve_long_markdown_reasons_and_bodies() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let backend = backend(&tmp);
|
||||
let (reference, _) = backend
|
||||
.create_with_snapshot(NewTicket::new("Long state and close reasons"))
|
||||
.unwrap();
|
||||
let reason = "## 判断\n\n長文の理由を記録する。\n".repeat(1024);
|
||||
let body = "## 検証結果\n\n本文も省略しない。\n".repeat(1024);
|
||||
backend
|
||||
.set_workflow_state(
|
||||
reference.id.clone().into(),
|
||||
TicketStateChange::new("planning", "inprogress", reason.clone(), body.clone()),
|
||||
)
|
||||
.unwrap();
|
||||
let current = backend.show(reference.id.clone().into()).unwrap();
|
||||
let event = current.events.last().unwrap();
|
||||
assert_eq!(event.reason.as_deref(), Some(reason.as_str()));
|
||||
assert_eq!(event.body.as_str(), body);
|
||||
let request = TicketStateUpdate {
|
||||
state: TicketWorkflowState::Closed,
|
||||
reason: reason.clone(),
|
||||
..completion_request(¤t, "close").into()
|
||||
};
|
||||
let closed = backend
|
||||
.update_state(&reference.id, request.clone())
|
||||
.unwrap();
|
||||
assert_eq!(closed.meta.workflow_state, TicketWorkflowState::Closed);
|
||||
assert_eq!(closed.resolution, Some(MarkdownText::new(&reason)));
|
||||
assert_eq!(
|
||||
closed.events.last().unwrap().reason.as_deref(),
|
||||
Some(reason.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
backend.update_state(&reference.id, request).unwrap(),
|
||||
closed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_cas_rejects_stale_item_or_state_without_mutation() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -6530,10 +6643,6 @@ mod tests {
|
||||
}],
|
||||
..request.clone()
|
||||
},
|
||||
TicketCompletion {
|
||||
reason: "x".repeat(MAX_STATE_CHANGE_REASON_BYTES + 1),
|
||||
..request.clone()
|
||||
},
|
||||
TicketCompletion {
|
||||
author: Some("".to_owned()),
|
||||
..request.clone()
|
||||
|
||||
@@ -38,6 +38,7 @@ capabilities remain authoritative. `Create` uses the `Write` capability/identity
|
||||
| Edit | `old_string`, `new_string`, `replace_all?` | Existing file; prior read/hash and provider validator required |
|
||||
| Write | `content` | Existing file only; prior read/hash and provider validator required |
|
||||
| Create | `path`, `content` | Bound parent directory; relative destination strictly below it; create-new, missing parents allowed |
|
||||
| List | `limit?` (1..1000, default 100), `after?` (`{kind, path}`) | Bound directory; Read capability; all typed `EntryKind` cursor values accepted, path is a direct child in checkout-root-relative provider coordinates |
|
||||
| Glob | `pattern`, `path?` | Bound directory; optional path relative beneath it |
|
||||
| Grep | `pattern`, `path?`, `glob?`, `type?`, `case_insensitive?`, `-B?`, `-A?`, `-C?`, `multiline?`, `output_mode?`, `head_limit?`, `offset?` | Bound directory; same provider search/options/rendering as Tool Grep |
|
||||
|
||||
@@ -55,6 +56,22 @@ The constructor's empty scope layers/root output are refined by provider wrapper
|
||||
Tools does not synthesize authority or parse/rebase rendered filenames. Ordinary
|
||||
Tool dispatch remains `session.glob`/`session.grep`, with its existing error mapping.
|
||||
|
||||
List dispatches one bounded `checkout_search` page without Bash or entry prefetch.
|
||||
`CheckoutToolOutput.listing` preserves the typed `ListResult`, including
|
||||
`next_after`, for Host projection into canonical Worldspace entries and cursors.
|
||||
Its normal Tool output is a summary only, and `paths` is empty; every other
|
||||
Operation returns `listing: None`. The Host decodes canonical cursor paths before
|
||||
calling Tools, mapping the public `{kind, entry}` cursor into `{kind, path}`.
|
||||
Both returned entries and the continuation must stay directly inside the bound
|
||||
directory. Cursor kinds retain all typed values (`directory`, `file`, `symlink`,
|
||||
`other`); ordering groups directories first and all non-directories together,
|
||||
then compares paths. A truncated page must supply `next_after` matching its last
|
||||
entry's ordering key; an untruncated page has no continuation. Special typed
|
||||
listing entries are path coordinates, not checkout observations: direct checkout
|
||||
observation remains provider File/Directory-only. Pagination is live, not a
|
||||
cross-page snapshot; the same before/after bound-directory validator checks
|
||||
used for search apply to List.
|
||||
|
||||
`CheckoutToolOutput.paths` contains typed checkout-root-relative search paths
|
||||
(from `GlobResult.paths` / `GrepResult.paths`) and the created path for Create.
|
||||
Rendered Grep text is never parsed to construct links. The optional validator is
|
||||
|
||||
@@ -18,6 +18,10 @@ use crate::{ToolsError, Tracker, file_target::FileTarget};
|
||||
pub struct CheckoutToolOutput {
|
||||
pub output: ToolOutput,
|
||||
pub paths: Vec<WorkdirPath>,
|
||||
/// Typed directory page, including its provider-coordinate continuation.
|
||||
/// Special entries are path coordinates, not checked checkout observations.
|
||||
/// The Host owns projection into canonical Worldspace entries and cursors.
|
||||
pub listing: Option<workdir::ListResult>,
|
||||
pub validator: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
@@ -47,6 +51,7 @@ pub async fn execute_checkout_tool(
|
||||
.unwrap_or_default();
|
||||
let (capability, allowed): (_, &[&str]) = match tool_name {
|
||||
"Read" => (WorkdirSessionCapability::Read, &file_fields),
|
||||
"List" => (WorkdirSessionCapability::Read, &["limit", "after"]),
|
||||
"Edit" => (WorkdirSessionCapability::Edit, &file_fields),
|
||||
"Write" => (WorkdirSessionCapability::Write, &file_fields),
|
||||
"Create" => (WorkdirSessionCapability::Write, &["path", "content"]),
|
||||
@@ -132,7 +137,7 @@ pub async fn execute_checkout_tool(
|
||||
crate::write::execute_write(target, tracker, params.content, Some(destination), ctx)
|
||||
.await
|
||||
}
|
||||
"Glob" | "Grep" => {
|
||||
"List" | "Glob" | "Grep" => {
|
||||
let before = selected
|
||||
.session
|
||||
.checkout_observe(path.clone())
|
||||
@@ -141,7 +146,10 @@ pub async fn execute_checkout_tool(
|
||||
if before.validator != validator {
|
||||
return Err(stale_search());
|
||||
}
|
||||
let mut result = if tool_name == "Glob" {
|
||||
let mut result = if tool_name == "List" {
|
||||
let params: ListParams = decode(Value::Object(arguments), tool_name)?;
|
||||
execute_list(selected.session.clone(), path.clone(), params).await?
|
||||
} else if tool_name == "Glob" {
|
||||
let params: crate::glob::GlobParams = decode(Value::Object(arguments), tool_name)?;
|
||||
check_pattern(¶ms.pattern)?;
|
||||
let search_path =
|
||||
@@ -191,6 +199,120 @@ pub async fn execute_checkout_tool(
|
||||
}
|
||||
}
|
||||
|
||||
/// Native List has no path override. `after.path` is already decoded by the Host
|
||||
/// into checkout-root-relative provider coordinates, not directory-relative.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ListParams {
|
||||
#[serde(default = "default_list_limit")]
|
||||
limit: usize,
|
||||
#[serde(default)]
|
||||
after: Option<workdir::ListCursor>,
|
||||
}
|
||||
|
||||
fn default_list_limit() -> usize {
|
||||
100
|
||||
}
|
||||
|
||||
async fn execute_list(
|
||||
session: workdir::WorkdirSessionHandle,
|
||||
path: WorkdirPath,
|
||||
params: ListParams,
|
||||
) -> Result<CheckoutToolOutput, ToolError> {
|
||||
if !(1..=1000).contains(¶ms.limit) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"List limit must be between 1 and 1000".into(),
|
||||
));
|
||||
}
|
||||
if let Some(after) = ¶ms.after {
|
||||
if !is_direct_child(&path, &after.path) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"List after must name a direct child of the bound directory".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let result = session
|
||||
.checkout_search(workdir::CheckoutSearchRequest::new(
|
||||
workdir::CheckoutSearchOperation::List(workdir::ListRequest {
|
||||
path: path.clone(),
|
||||
limit: params.limit,
|
||||
after: params.after,
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.map_err(crate::file_target::checked_error)?;
|
||||
let listing = match result {
|
||||
workdir::CheckoutSearchResult::List(listing) => listing,
|
||||
_ => return Err(crate::file_target::readonly_result_error("List")),
|
||||
};
|
||||
// Fail closed before the Host can publish typed links to provider results.
|
||||
if listing.entries.len() > params.limit
|
||||
|| listing
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| !is_direct_child(&path, &entry.path))
|
||||
|| listing
|
||||
.next_after
|
||||
.as_ref()
|
||||
.is_some_and(|after| !is_direct_child(&path, &after.path))
|
||||
{
|
||||
return Err(crate::file_target::readonly_result_error(
|
||||
"List page escaped bound directory or exceeded its limit",
|
||||
));
|
||||
}
|
||||
// The provider ordering key is directory/non-directory group plus path.
|
||||
// Preserve the typed kind; special entries remain continuable without
|
||||
// acquiring a File/Directory-only checkout observation for them.
|
||||
let valid_continuation = match (&listing.next_after, listing.entries.last()) {
|
||||
(Some(after), Some(last)) if listing.truncated => {
|
||||
after.path == last.path
|
||||
&& (after.kind == workdir::EntryKind::Directory)
|
||||
== (last.kind == workdir::EntryKind::Directory)
|
||||
}
|
||||
(None, _) => !listing.truncated,
|
||||
_ => false,
|
||||
};
|
||||
if !valid_continuation {
|
||||
return Err(crate::file_target::readonly_result_error(
|
||||
"List continuation does not match the truncated page's last ordering key",
|
||||
));
|
||||
}
|
||||
let summary = format!(
|
||||
"Listed {} of {} entries in {}{}",
|
||||
listing.entries.len(),
|
||||
listing.total_entries,
|
||||
path,
|
||||
if listing.truncated {
|
||||
" (truncated)"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
);
|
||||
Ok(CheckoutToolOutput {
|
||||
output: ToolOutput {
|
||||
summary,
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
paths: Vec::new(),
|
||||
listing: Some(listing),
|
||||
validator: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_direct_child(base: &WorkdirPath, path: &WorkdirPath) -> bool {
|
||||
let Ok(logical) = WorkdirPath::new(path.as_str()) else {
|
||||
return false;
|
||||
};
|
||||
let parent = std::path::Path::new(logical.as_str()).parent();
|
||||
let expected = if base == &WorkdirPath::root() {
|
||||
std::path::Path::new("")
|
||||
} else {
|
||||
std::path::Path::new(base.as_str())
|
||||
};
|
||||
logical != WorkdirPath::root() && parent == Some(expected)
|
||||
}
|
||||
|
||||
fn decode<T: DeserializeOwned>(arguments: Value, name: &str) -> Result<T, ToolError> {
|
||||
crate::error::decode_file_value(arguments, name)
|
||||
}
|
||||
|
||||
@@ -85,6 +85,326 @@ impl Fixture {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ListProvider {
|
||||
workdir: workdir::Workdir,
|
||||
capabilities: workdir::WorkdirSessionCapabilities,
|
||||
result: workdir::CheckoutSearchResult,
|
||||
change_validator: bool,
|
||||
expected_after: Option<workdir::ListCursor>,
|
||||
searched: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
// This boundary adapter exposes only checked metadata/search. Any file content
|
||||
// read, ordinary list fallback, or command execution is a test failure.
|
||||
#[async_trait::async_trait]
|
||||
impl workdir::WorkdirSession for ListProvider {
|
||||
fn workdir(&self) -> &workdir::Workdir {
|
||||
&self.workdir
|
||||
}
|
||||
fn capabilities(&self) -> workdir::WorkdirSessionCapabilities {
|
||||
self.capabilities
|
||||
}
|
||||
async fn checkout_observe(
|
||||
&self,
|
||||
path: WorkdirPath,
|
||||
) -> Result<workdir::CheckoutObservation, workdir::WorkdirError> {
|
||||
assert_eq!(
|
||||
path.as_str(),
|
||||
"src",
|
||||
"List observes only its bound directory"
|
||||
);
|
||||
Ok(workdir::CheckoutObservation {
|
||||
path,
|
||||
kind: workdir::EntryKind::Directory,
|
||||
size: 0,
|
||||
validator: vec![u8::from(
|
||||
self.change_validator && self.searched.load(std::sync::atomic::Ordering::SeqCst),
|
||||
)],
|
||||
capabilities: self.capabilities,
|
||||
})
|
||||
}
|
||||
async fn checkout_search(
|
||||
&self,
|
||||
request: workdir::CheckoutSearchRequest,
|
||||
) -> Result<workdir::CheckoutSearchResult, workdir::WorkdirError> {
|
||||
let workdir::CheckoutSearchOperation::List(list) = request.operation else {
|
||||
panic!("native List must dispatch a typed List request");
|
||||
};
|
||||
assert_eq!(list.after, self.expected_after);
|
||||
assert!(
|
||||
!self
|
||||
.searched
|
||||
.swap(true, std::sync::atomic::Ordering::SeqCst),
|
||||
"List must not prefetch another page"
|
||||
);
|
||||
Ok(self.result.clone())
|
||||
}
|
||||
async fn stat(
|
||||
&self,
|
||||
_: workdir::StatRequest,
|
||||
) -> Result<workdir::StatResult, workdir::WorkdirError> {
|
||||
panic!("native List must not call stat");
|
||||
}
|
||||
async fn read(
|
||||
&self,
|
||||
_: workdir::ReadRequest,
|
||||
) -> Result<workdir::ReadResult, workdir::WorkdirError> {
|
||||
panic!("native List must not read file content");
|
||||
}
|
||||
async fn write(
|
||||
&self,
|
||||
_: workdir::WriteRequest,
|
||||
) -> Result<workdir::WriteResult, workdir::WorkdirError> {
|
||||
panic!("native List must not write");
|
||||
}
|
||||
async fn edit(
|
||||
&self,
|
||||
_: workdir::EditRequest,
|
||||
) -> Result<workdir::EditResult, workdir::WorkdirError> {
|
||||
panic!("native List must not edit");
|
||||
}
|
||||
async fn list(
|
||||
&self,
|
||||
_: workdir::ListRequest,
|
||||
) -> Result<workdir::ListResult, workdir::WorkdirError> {
|
||||
panic!("native List must use checkout_search, not ordinary list");
|
||||
}
|
||||
async fn glob(
|
||||
&self,
|
||||
_: workdir::GlobRequest,
|
||||
) -> Result<workdir::GlobResult, workdir::WorkdirError> {
|
||||
panic!("native List must not glob");
|
||||
}
|
||||
async fn grep(
|
||||
&self,
|
||||
_: workdir::GrepRequest,
|
||||
) -> Result<workdir::GrepResult, workdir::WorkdirError> {
|
||||
panic!("native List must not grep");
|
||||
}
|
||||
async fn start_command(
|
||||
&self,
|
||||
_: workdir::CommandRequest,
|
||||
) -> Result<workdir::CommandHandle, workdir::WorkdirError> {
|
||||
panic!("native List must not execute commands");
|
||||
}
|
||||
async fn command_status(
|
||||
&self,
|
||||
_: workdir::CommandHandle,
|
||||
) -> Result<workdir::CommandStatus, workdir::WorkdirError> {
|
||||
panic!("native List must not use commands");
|
||||
}
|
||||
async fn command_output(
|
||||
&self,
|
||||
_: workdir::CommandOutputRequest,
|
||||
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
|
||||
panic!("native List must not use commands");
|
||||
}
|
||||
async fn cancel_command(&self, _: workdir::CommandHandle) -> Result<(), workdir::WorkdirError> {
|
||||
panic!("native List must not use commands");
|
||||
}
|
||||
async fn close(&self) -> Result<(), workdir::WorkdirError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_with_provider(
|
||||
capabilities: workdir::WorkdirSessionCapabilities,
|
||||
result: workdir::CheckoutSearchResult,
|
||||
change_validator: bool,
|
||||
) -> Result<CheckoutToolOutput, ToolError> {
|
||||
list_with_provider_after(capabilities, result, change_validator, None).await
|
||||
}
|
||||
|
||||
async fn list_with_provider_after(
|
||||
capabilities: workdir::WorkdirSessionCapabilities,
|
||||
result: workdir::CheckoutSearchResult,
|
||||
change_validator: bool,
|
||||
after: Option<workdir::ListCursor>,
|
||||
) -> Result<CheckoutToolOutput, ToolError> {
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("selected").unwrap(),
|
||||
Arc::new(ListProvider {
|
||||
workdir: workdir::Workdir::new("list-test"),
|
||||
capabilities,
|
||||
result,
|
||||
change_validator,
|
||||
expected_after: after.clone(),
|
||||
searched: std::sync::atomic::AtomicBool::new(false),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let generation = router.resolve(Some("selected")).unwrap().generation;
|
||||
execute_checkout_tool(
|
||||
router,
|
||||
Tracker::new(),
|
||||
"selected",
|
||||
generation,
|
||||
WorkdirPath::new("src").unwrap(),
|
||||
vec![0],
|
||||
"List",
|
||||
json!({"limit": 1, "after": after}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn list_page() -> workdir::ListResult {
|
||||
workdir::ListResult {
|
||||
entries: vec![workdir::ListEntry {
|
||||
path: WorkdirPath::new("src/a.txt").unwrap(),
|
||||
kind: workdir::EntryKind::File,
|
||||
size: 5,
|
||||
}],
|
||||
total_entries: 2,
|
||||
total_bytes: 10,
|
||||
truncated: true,
|
||||
next_after: Some(fs_operation::ListCursor {
|
||||
kind: workdir::EntryKind::File,
|
||||
path: WorkdirPath::new("src/a.txt").unwrap(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_list_uses_read_capability_and_checked_search_without_prefetch() {
|
||||
let read_only = workdir::WorkdirSessionCapabilities::EMPTY.with(WorkdirSessionCapability::Read);
|
||||
let page = list_page();
|
||||
let result = list_with_provider(
|
||||
read_only,
|
||||
workdir::CheckoutSearchResult::List(page.clone()),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.listing, Some(page));
|
||||
assert_eq!(result.validator, Some(vec![0]));
|
||||
let no_read = workdir::WorkdirSessionCapabilities::EMPTY.with(WorkdirSessionCapability::Glob);
|
||||
let error = list_with_provider(
|
||||
no_read,
|
||||
workdir::CheckoutSearchResult::List(list_page()),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(matches!(error, ToolError::InvalidArgument(_)));
|
||||
assert!(error.to_string().contains("Read"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_list_rejects_changed_post_search_directory_validator() {
|
||||
let error = list_with_provider(
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
workdir::CheckoutSearchResult::List(list_page()),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_checkout_stale(error);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_list_rejects_provider_pages_that_escape_or_exceed_bounds() {
|
||||
let mut outside = list_page();
|
||||
outside.entries[0].path = WorkdirPath::new("outside.txt").unwrap();
|
||||
let mut nested = list_page();
|
||||
nested.entries[0].path = WorkdirPath::new("src/nested/a.txt").unwrap();
|
||||
let mut cursor_outside = list_page();
|
||||
cursor_outside.next_after.as_mut().unwrap().path = WorkdirPath::new("outside.txt").unwrap();
|
||||
let mut cursor_kind = list_page();
|
||||
cursor_kind.next_after.as_mut().unwrap().kind = workdir::EntryKind::Directory;
|
||||
let mut oversized = list_page();
|
||||
oversized.entries.push(oversized.entries[0].clone());
|
||||
for result in [
|
||||
workdir::CheckoutSearchResult::List(outside),
|
||||
workdir::CheckoutSearchResult::List(nested),
|
||||
workdir::CheckoutSearchResult::List(cursor_outside),
|
||||
workdir::CheckoutSearchResult::List(cursor_kind),
|
||||
workdir::CheckoutSearchResult::List(oversized),
|
||||
workdir::CheckoutSearchResult::Glob(workdir::GlobResult {
|
||||
paths: vec![],
|
||||
truncated: false,
|
||||
}),
|
||||
] {
|
||||
let error = list_with_provider(
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
result,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_checkout_code(error, "checkout_unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_list_special_entry_cursors_preserve_kind_and_continue_the_page() {
|
||||
// Provider-only special entries stay path coordinates: the adapter forbids
|
||||
// content reads and does not offer direct observations for these entries.
|
||||
for kind in [workdir::EntryKind::Other, workdir::EntryKind::Symlink] {
|
||||
let mut first_page = list_page();
|
||||
first_page.entries[0].kind = kind;
|
||||
first_page.next_after.as_mut().unwrap().kind = kind;
|
||||
let first = list_with_provider(
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
workdir::CheckoutSearchResult::List(first_page),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.listing
|
||||
.unwrap();
|
||||
assert_eq!(first.entries[0].kind, kind);
|
||||
let after = first.next_after.unwrap();
|
||||
assert_eq!(after.kind, kind);
|
||||
let mut second_page = list_page();
|
||||
second_page.entries[0].path = WorkdirPath::new("src/z.txt").unwrap();
|
||||
second_page.truncated = false;
|
||||
second_page.next_after = None;
|
||||
let second = list_with_provider_after(
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
workdir::CheckoutSearchResult::List(second_page.clone()),
|
||||
false,
|
||||
Some(after),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.listing
|
||||
.unwrap();
|
||||
assert_eq!(second, second_page);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_list_rejects_missing_or_mismatched_truncated_continuations() {
|
||||
let mut missing = list_page();
|
||||
missing.next_after = None;
|
||||
let mut empty = list_page();
|
||||
empty.entries.clear();
|
||||
let mut wrong_path = list_page();
|
||||
wrong_path.next_after.as_mut().unwrap().path = WorkdirPath::new("src/z.txt").unwrap();
|
||||
let mut wrong_group = list_page();
|
||||
wrong_group.next_after.as_mut().unwrap().kind = workdir::EntryKind::Directory;
|
||||
let mut untruncated = list_page();
|
||||
untruncated.truncated = false;
|
||||
for page in [missing, empty, wrong_path, wrong_group, untruncated] {
|
||||
let error = list_with_provider(
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
workdir::CheckoutSearchResult::List(page),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_checkout_code(error, "checkout_unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_checkout_stale(error: ToolError) {
|
||||
assert_checkout_code(error, "checkout_stale");
|
||||
}
|
||||
@@ -557,6 +877,183 @@ async fn checkout_search_typed_paths_and_normal_rendering() {
|
||||
let normal = fixture.normal("Grep", json!({"target_workdir": "main", "path": "src", "pattern": "hit", "output_mode": mode, "-C": 1})).await.unwrap();
|
||||
assert_eq!(grep.output.summary, normal.summary);
|
||||
assert_eq!(grep.output.content, normal.content);
|
||||
assert!(grep.listing.is_none());
|
||||
assert!(!grep.output.content.unwrap().contains("outside.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_list_returns_one_typed_page_and_provider_coordinate_continuation() {
|
||||
let fixture = Fixture::new();
|
||||
std::fs::create_dir_all(fixture.dir.path().join("src/nested")).unwrap();
|
||||
std::fs::write(fixture.dir.path().join("src/a.txt"), "alpha").unwrap();
|
||||
std::fs::write(fixture.dir.path().join("src/z.txt"), "z").unwrap();
|
||||
std::fs::write(fixture.dir.path().join("outside.txt"), "outside").unwrap();
|
||||
let (_, validator) = fixture.observation("main", "src").await;
|
||||
let first = fixture
|
||||
.native("main", "src", "List", json!({"limit": 1}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(first.paths.is_empty());
|
||||
assert!(first.output.content.is_none());
|
||||
assert!(first.output.summary.contains("Listed 1 of 3 entries"));
|
||||
assert!(first.output.summary.contains("truncated"));
|
||||
assert_eq!(first.validator, Some(validator));
|
||||
let page = first.listing.unwrap();
|
||||
assert_eq!(page.entries.len(), 1);
|
||||
assert_eq!(page.entries[0].path.as_str(), "src/nested");
|
||||
assert_eq!(page.entries[0].kind, workdir::EntryKind::Directory);
|
||||
assert!(page.truncated);
|
||||
let total_bytes = page.total_bytes;
|
||||
assert!(total_bytes >= 6);
|
||||
let cursor = page.next_after.unwrap();
|
||||
assert_eq!(cursor.path.as_str(), "src/nested");
|
||||
let second = fixture
|
||||
.native("main", "src", "List", json!({"after": cursor}))
|
||||
.await
|
||||
.unwrap();
|
||||
let page = second.listing.unwrap();
|
||||
assert_eq!(
|
||||
page.entries
|
||||
.iter()
|
||||
.map(|entry| entry.path.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["src/a.txt", "src/z.txt"]
|
||||
);
|
||||
assert_eq!(page.total_entries, 3);
|
||||
assert_eq!(page.total_bytes, total_bytes);
|
||||
assert!(!page.truncated);
|
||||
assert!(page.next_after.is_none());
|
||||
assert_eq!(fixture.tracker.change_stat(), crate::ChangeStat::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_list_root_file_cursor_stays_on_selected_attachment() {
|
||||
let fixture = Fixture::new();
|
||||
std::fs::write(fixture.dir.path().join("a.txt"), "a").unwrap();
|
||||
std::fs::write(fixture.dir.path().join("z.txt"), "z").unwrap();
|
||||
let other = TempDir::new().unwrap();
|
||||
std::fs::write(other.path().join("wrong.txt"), "wrong").unwrap();
|
||||
fixture
|
||||
.router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("other").unwrap(),
|
||||
Arc::new(LocalWorkdirSession::new(
|
||||
Scope::writable(other.path()).unwrap(),
|
||||
other.path().to_path_buf(),
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
let first = fixture
|
||||
.native("main", ".", "List", json!({"limit": 1}))
|
||||
.await
|
||||
.unwrap()
|
||||
.listing
|
||||
.unwrap();
|
||||
let cursor = first.next_after.unwrap();
|
||||
assert_eq!(cursor.kind, workdir::EntryKind::File);
|
||||
assert_eq!(cursor.path.as_str(), "a.txt");
|
||||
let second = fixture
|
||||
.native("main", ".", "List", json!({"after": cursor}))
|
||||
.await
|
||||
.unwrap()
|
||||
.listing
|
||||
.unwrap();
|
||||
assert_eq!(second.entries.len(), 1);
|
||||
assert_eq!(second.entries[0].path.as_str(), "z.txt");
|
||||
let other_page = fixture
|
||||
.native("other", ".", "List", json!({}))
|
||||
.await
|
||||
.unwrap()
|
||||
.listing
|
||||
.unwrap();
|
||||
assert_eq!(other_page.entries.len(), 1);
|
||||
assert_eq!(other_page.entries[0].path.as_str(), "wrong.txt");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_list_defaults_to_100_and_accepts_both_limit_bounds() {
|
||||
let fixture = Fixture::new();
|
||||
for index in 0..101 {
|
||||
std::fs::write(fixture.dir.path().join(format!("{index:03}.txt")), "").unwrap();
|
||||
}
|
||||
for (arguments, count, truncated) in [
|
||||
(json!({}), 100, true),
|
||||
(json!({"limit": 1}), 1, true),
|
||||
(json!({"limit": 1000}), 101, false),
|
||||
] {
|
||||
let result = fixture
|
||||
.native("main", ".", "List", arguments)
|
||||
.await
|
||||
.unwrap();
|
||||
let page = result.listing.unwrap();
|
||||
assert_eq!(page.entries.len(), count);
|
||||
assert_eq!(page.truncated, truncated);
|
||||
assert_eq!(page.next_after.is_some(), truncated);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_list_rejects_route_overrides_malformed_limits_and_nonchild_cursors() {
|
||||
let fixture = Fixture::new();
|
||||
std::fs::create_dir(fixture.dir.path().join("src")).unwrap();
|
||||
for arguments in [
|
||||
json!({"path": "."}),
|
||||
json!({"target_workdir": "other"}),
|
||||
json!({"generation": 1}),
|
||||
json!({"validator": []}),
|
||||
json!({"limit": 0}),
|
||||
json!({"limit": 1001}),
|
||||
json!({"limit": -1}),
|
||||
json!({"limit": 1.5}),
|
||||
json!({"limit": "1"}),
|
||||
json!({"limit": null}),
|
||||
json!({"after": "src/a.txt"}),
|
||||
json!({"after": {"path": "src/a.txt"}}),
|
||||
json!({"after": {"kind": "file", "path": "src/a.txt", "extra": 1}}),
|
||||
json!({"after": {"kind": "bogus", "path": "src/a.txt"}}),
|
||||
json!({"after": {"kind": "file", "path": "src"}}),
|
||||
json!({"after": {"kind": "directory", "path": "src/nested/deep"}}),
|
||||
json!({"after": {"kind": "file", "path": "src-other/a.txt"}}),
|
||||
json!({"after": {"kind": "file", "path": "a.txt"}}),
|
||||
json!({"after": {"kind": "file", "path": "/src/a.txt"}}),
|
||||
json!({"after": {"kind": "file", "path": "src/../a.txt"}}),
|
||||
json!({"after": {"kind": "file", "path": "."}}),
|
||||
] {
|
||||
let error = fixture
|
||||
.native("main", "src", "List", arguments.clone())
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(error, ToolError::InvalidArgument(_)),
|
||||
"{arguments}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_list_requires_current_generation_and_directory_validator() {
|
||||
let fixture = Fixture::new();
|
||||
let (generation, validator) = fixture.observation("main", ".").await;
|
||||
// No timing dependency: make the supplied observation explicitly stale.
|
||||
let mut stale_validator = validator.clone();
|
||||
stale_validator.push(0);
|
||||
for (generation, validator) in [(generation, stale_validator), (generation + 1, validator)] {
|
||||
let error = execute_checkout_tool(
|
||||
fixture.router.clone(),
|
||||
fixture.tracker.clone(),
|
||||
"main",
|
||||
generation,
|
||||
WorkdirPath::root(),
|
||||
validator,
|
||||
"List",
|
||||
json!({}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_checkout_stale(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ pub(crate) async fn execute_edit(
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
paths: Vec::new(),
|
||||
listing: None,
|
||||
validator,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ pub(crate) async fn execute_glob(
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
paths: result.paths,
|
||||
listing: None,
|
||||
validator: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -147,6 +147,7 @@ pub(crate) async fn execute_grep(
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
paths: result.paths,
|
||||
listing: None,
|
||||
validator: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ mod grep;
|
||||
mod read;
|
||||
mod routing;
|
||||
mod search_target;
|
||||
mod view_image;
|
||||
pub mod view_image;
|
||||
mod web;
|
||||
mod write;
|
||||
|
||||
@@ -33,7 +33,9 @@ pub use glob::glob_tool;
|
||||
pub use grep::grep_tool;
|
||||
pub use read::read_tool;
|
||||
pub use tracker::{ChangeStat, Tracker};
|
||||
pub use view_image::{routed_view_image_tool, view_image_tool};
|
||||
pub use view_image::{
|
||||
detect_image_mime, read_workdir_bytes, routed_view_image_tool, view_image_tool,
|
||||
};
|
||||
pub use web::{web_fetch_tool, web_search_tool};
|
||||
pub use write::write_tool;
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ pub(crate) async fn execute_read(
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
paths: Vec::new(),
|
||||
listing: None,
|
||||
validator,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ use agen::tool::{
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use workdir::{ReadBytesRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter};
|
||||
use sha2::{Digest, Sha256};
|
||||
use workdir::{
|
||||
ContentHash, ReadBytesRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter,
|
||||
};
|
||||
|
||||
use crate::error::ToolsError;
|
||||
|
||||
@@ -49,7 +52,7 @@ impl Tool for ViewImageTool {
|
||||
workdir::WorkdirSessionCapability::Read,
|
||||
)?;
|
||||
let path = WorkdirPath::new(&input.path).map_err(ToolsError::from)?;
|
||||
let bytes = read_image_bytes(selected.session.as_ref(), path.clone())
|
||||
let bytes = read_workdir_bytes(selected.session.as_ref(), path.clone(), MAX_IMAGE_BYTES)
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
let mime_type = detect_image_mime(&bytes).ok_or_else(|| {
|
||||
@@ -70,9 +73,13 @@ impl Tool for ViewImageTool {
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_image_bytes(
|
||||
/// Read arbitrary bytes through the scoped provider, bounded by `max_total_bytes`.
|
||||
/// Checks every chunk's identity, range, size and hash, then verifies the assembled
|
||||
/// SHA256 against the provider's pinned [`ContentHash`]. No MIME restriction applies.
|
||||
pub async fn read_workdir_bytes(
|
||||
session: &dyn workdir::WorkdirSession,
|
||||
path: WorkdirPath,
|
||||
max_total_bytes: usize,
|
||||
) -> Result<Vec<u8>, workdir::WorkdirError> {
|
||||
let mut bytes = Vec::new();
|
||||
let mut offset = 0_u64;
|
||||
@@ -116,16 +123,16 @@ async fn read_image_bytes(
|
||||
"bounded binary read returned a truncated or invalid byte range".to_string(),
|
||||
));
|
||||
}
|
||||
if result.total_bytes > MAX_IMAGE_BYTES as u64 {
|
||||
if result.total_bytes > max_total_bytes as u64 {
|
||||
return Err(workdir::WorkdirError::InvalidArgument(format!(
|
||||
"image exceeds the {MAX_IMAGE_BYTES}-byte limit"
|
||||
"file exceeds the {max_total_bytes}-byte limit"
|
||||
)));
|
||||
}
|
||||
if expected_total.is_some_and(|total| total != result.total_bytes)
|
||||
|| expected_hash.is_some_and(|hash| hash != result.content_hash)
|
||||
{
|
||||
return Err(workdir::WorkdirError::Conflict(
|
||||
"image changed while it was being read; retry ViewImage".to_string(),
|
||||
"file changed while it was being read; retry the read".to_string(),
|
||||
));
|
||||
}
|
||||
if expected_total.is_none() {
|
||||
@@ -146,7 +153,13 @@ async fn read_image_bytes(
|
||||
if result.eof {
|
||||
if bytes.len() as u64 != result.total_bytes {
|
||||
return Err(workdir::WorkdirError::Transport(
|
||||
"bounded binary read returned a partial image".to_string(),
|
||||
"bounded binary read returned partial file bytes".to_string(),
|
||||
));
|
||||
}
|
||||
let assembled_hash: ContentHash = Sha256::digest(&bytes).into();
|
||||
if assembled_hash != result.content_hash {
|
||||
return Err(workdir::WorkdirError::Transport(
|
||||
"bounded binary read assembled bytes do not match the content hash".to_string(),
|
||||
));
|
||||
}
|
||||
return Ok(bytes);
|
||||
@@ -172,7 +185,8 @@ pub fn routed_view_image_tool(router: Arc<WorkdirSessionRouter>) -> ToolDefiniti
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_image_mime(bytes: &[u8]) -> Option<&'static str> {
|
||||
/// Detect PNG, JPEG, GIF or WebP signatures without trusting a file extension.
|
||||
pub fn detect_image_mime(bytes: &[u8]) -> Option<&'static str> {
|
||||
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
Some("image/png")
|
||||
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
|
||||
@@ -189,6 +203,189 @@ fn detect_image_mime(bytes: &[u8]) -> Option<&'static str> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::Mutex;
|
||||
use tempfile::TempDir;
|
||||
use workdir::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BinarySession {
|
||||
local: LocalWorkdirSession,
|
||||
responses: Mutex<VecDeque<ReadBytesResult>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkdirSession for BinarySession {
|
||||
fn workdir(&self) -> &Workdir {
|
||||
self.local.workdir()
|
||||
}
|
||||
fn capabilities(&self) -> WorkdirSessionCapabilities {
|
||||
self.local.capabilities()
|
||||
}
|
||||
async fn read_bytes(
|
||||
&self,
|
||||
request: ReadBytesRequest,
|
||||
) -> Result<ReadBytesResult, WorkdirError> {
|
||||
let response = self.responses.lock().unwrap().pop_front().unwrap();
|
||||
if request.offset > 0 {
|
||||
assert!(request.expected_hash.is_some());
|
||||
}
|
||||
assert!(request.max_bytes <= IMAGE_READ_CHUNK_BYTES);
|
||||
Ok(response)
|
||||
}
|
||||
async fn stat(&self, r: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
self.local.stat(r).await
|
||||
}
|
||||
async fn read(&self, r: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||
self.local.read(r).await
|
||||
}
|
||||
async fn write(&self, r: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
self.local.write(r).await
|
||||
}
|
||||
async fn edit(&self, r: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||
self.local.edit(r).await
|
||||
}
|
||||
async fn list(&self, r: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||
self.local.list(r).await
|
||||
}
|
||||
async fn glob(&self, r: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
||||
self.local.glob(r).await
|
||||
}
|
||||
async fn grep(&self, r: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
||||
self.local.grep(r).await
|
||||
}
|
||||
async fn start_command(&self, r: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||
self.local.start_command(r).await
|
||||
}
|
||||
async fn command_status(&self, r: CommandHandle) -> Result<CommandStatus, WorkdirError> {
|
||||
self.local.command_status(r).await
|
||||
}
|
||||
async fn command_output(
|
||||
&self,
|
||||
r: CommandOutputRequest,
|
||||
) -> Result<CommandOutput, WorkdirError> {
|
||||
self.local.command_output(r).await
|
||||
}
|
||||
async fn cancel_command(&self, r: CommandHandle) -> Result<(), WorkdirError> {
|
||||
self.local.cancel_command(r).await
|
||||
}
|
||||
async fn close(&self) -> Result<(), WorkdirError> {
|
||||
self.local.close().await
|
||||
}
|
||||
}
|
||||
|
||||
fn binary_session(dir: &TempDir, responses: Vec<ReadBytesResult>) -> BinarySession {
|
||||
BinarySession {
|
||||
local: LocalWorkdirSession::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().into(),
|
||||
),
|
||||
responses: Mutex::new(responses.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn chunk(bytes: &[u8], offset: u64, total: u64, hash: ContentHash) -> ReadBytesResult {
|
||||
ReadBytesResult {
|
||||
path: WorkdirPath::new("artifact.bin").unwrap(),
|
||||
bytes: bytes.to_vec(),
|
||||
offset,
|
||||
total_bytes: total,
|
||||
content_hash: hash,
|
||||
eof: offset + bytes.len() as u64 == total,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_binary_read_rejects_bytes_with_forged_consistent_hash() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let hash = Sha256::digest(b"abcd").into();
|
||||
let session = binary_session(
|
||||
&dir,
|
||||
vec![chunk(b"ab", 0, 4, hash), chunk(b"cx", 2, 4, hash)],
|
||||
);
|
||||
let error = read_workdir_bytes(&session, WorkdirPath::new("artifact.bin").unwrap(), 4)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, WorkdirError::Transport(message) if message.contains("content hash"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_binary_read_rejects_invalid_provider_chunk_metadata() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let hash = Sha256::digest(b"abcd").into();
|
||||
let valid = chunk(b"abcd", 0, 4, hash);
|
||||
let mut cases = Vec::new();
|
||||
let mut wrong_path = valid.clone();
|
||||
wrong_path.path = WorkdirPath::new("other.bin").unwrap();
|
||||
cases.push(wrong_path);
|
||||
let mut wrong_offset = valid.clone();
|
||||
wrong_offset.offset = 1;
|
||||
cases.push(wrong_offset);
|
||||
let mut wrong_eof = valid.clone();
|
||||
wrong_eof.eof = false;
|
||||
cases.push(wrong_eof);
|
||||
let mut wrong_total = valid.clone();
|
||||
wrong_total.total_bytes = 3;
|
||||
cases.push(wrong_total);
|
||||
cases.push(chunk(b"", 0, 4, hash));
|
||||
cases.push(chunk(
|
||||
&vec![0; IMAGE_READ_CHUNK_BYTES + 1],
|
||||
0,
|
||||
(IMAGE_READ_CHUNK_BYTES + 1) as u64,
|
||||
hash,
|
||||
));
|
||||
for response in cases {
|
||||
let session = binary_session(&dir, vec![response]);
|
||||
let error = read_workdir_bytes(&session, valid.path.clone(), 16 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, WorkdirError::Transport(_)), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_binary_read_rejects_hash_or_size_changes_between_chunks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let hash = Sha256::digest(b"abcd").into();
|
||||
for second in [chunk(b"cd", 2, 4, [0; 32]), chunk(b"cd", 2, 5, hash)] {
|
||||
let session = binary_session(&dir, vec![chunk(b"ab", 0, 4, hash), second]);
|
||||
let error = read_workdir_bytes(&session, WorkdirPath::new("artifact.bin").unwrap(), 5)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, WorkdirError::Conflict(_)), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_binary_read_accepts_arbitrary_bytes_at_caller_limit_and_rejects_larger_files()
|
||||
{
|
||||
let dir = TempDir::new().unwrap();
|
||||
let bytes = vec![0xff; 16 * 1024 * 1024];
|
||||
std::fs::write(dir.path().join("artifact.bin"), &bytes).unwrap();
|
||||
let session =
|
||||
LocalWorkdirSession::new(Scope::writable(dir.path()).unwrap(), dir.path().into());
|
||||
let path = WorkdirPath::new("artifact.bin").unwrap();
|
||||
assert_eq!(
|
||||
read_workdir_bytes(&session, path.clone(), bytes.len())
|
||||
.await
|
||||
.unwrap(),
|
||||
bytes
|
||||
);
|
||||
assert!(matches!(
|
||||
read_workdir_bytes(&session, path.clone(), bytes.len() - 1).await,
|
||||
Err(WorkdirError::InvalidArgument(_))
|
||||
));
|
||||
std::fs::write(dir.path().join("artifact.bin"), []).unwrap();
|
||||
assert!(
|
||||
read_workdir_bytes(&session, path, 0)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_supported_image_signatures_without_trusting_extensions() {
|
||||
|
||||
@@ -125,6 +125,7 @@ pub(crate) async fn execute_write(
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
paths: create_path.into_iter().collect(),
|
||||
listing: None,
|
||||
validator,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1741,6 +1741,7 @@ impl App {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_read_input_tokens,
|
||||
..
|
||||
} => {
|
||||
if let Some(input_tokens) = input_tokens {
|
||||
self.session_context_tokens = input_tokens;
|
||||
@@ -3000,7 +3001,10 @@ impl App {
|
||||
self.apply_system_item(data);
|
||||
}
|
||||
}
|
||||
SessionSnapshotEntryData::RunYielded { .. }
|
||||
SessionSnapshotEntryData::Invoke { .. }
|
||||
| SessionSnapshotEntryData::Usage { .. }
|
||||
| SessionSnapshotEntryData::RunStats { .. }
|
||||
| SessionSnapshotEntryData::RunYielded { .. }
|
||||
| SessionSnapshotEntryData::RunResumed { .. }
|
||||
| SessionSnapshotEntryData::RunCancelled => {
|
||||
// Durable logical-Run transitions are intentionally not
|
||||
@@ -3981,6 +3985,7 @@ mod completion_flow_tests {
|
||||
});
|
||||
app.handle_worker_event(Event::TurnStart { turn: 1 });
|
||||
app.handle_worker_event(Event::Usage {
|
||||
timestamp_ms: None,
|
||||
input_tokens: Some(100),
|
||||
output_tokens: Some(0),
|
||||
cache_read_input_tokens: Some(40),
|
||||
@@ -4210,6 +4215,7 @@ mod completion_flow_tests {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_worker_event(Event::TurnStart { turn: 1 });
|
||||
app.handle_worker_event(Event::InvokeStart {
|
||||
timestamp_ms: None,
|
||||
kind: protocol::InvokeKind::UserSend,
|
||||
});
|
||||
app.handle_worker_event(Event::RunEnd {
|
||||
@@ -5156,6 +5162,7 @@ mod completion_flow_tests {
|
||||
let mut app = App::new("test".into());
|
||||
|
||||
app.handle_worker_event(Event::Usage {
|
||||
timestamp_ms: None,
|
||||
input_tokens: Some(42_000),
|
||||
output_tokens: Some(9),
|
||||
cache_read_input_tokens: Some(40_000),
|
||||
|
||||
@@ -120,13 +120,6 @@ impl FormState {
|
||||
self.field = Field::Runtime;
|
||||
return None;
|
||||
}
|
||||
if runtime.working_directory_required {
|
||||
self.status =
|
||||
"The selected Runtime requires a workdir; this launch flow does not select one yet."
|
||||
.to_string();
|
||||
self.field = Field::Runtime;
|
||||
return None;
|
||||
}
|
||||
let Some(profile) = self.current_profile(options) else {
|
||||
self.status = "No Worker profile is available.".to_string();
|
||||
self.field = Field::Profile;
|
||||
@@ -377,8 +370,8 @@ fn profile_title(state: &FormState, options: &BackendWorkerLaunchOptions) -> Str
|
||||
fn runtime_label(runtime: &BackendWorkerLaunchRuntimeOption) -> String {
|
||||
let availability = if !runtime.worker_creation_available {
|
||||
"unavailable"
|
||||
} else if runtime.working_directory_required {
|
||||
"workdir required"
|
||||
} else if runtime.supports_workdir_attachments {
|
||||
"optional workdirs"
|
||||
} else {
|
||||
"no workdir"
|
||||
};
|
||||
@@ -389,7 +382,7 @@ fn runtime_label(runtime: &BackendWorkerLaunchRuntimeOption) -> String {
|
||||
}
|
||||
|
||||
fn runtime_supports_workdirless_creation(runtime: &BackendWorkerLaunchRuntimeOption) -> bool {
|
||||
runtime.worker_creation_available && !runtime.working_directory_required
|
||||
runtime.worker_creation_available
|
||||
}
|
||||
|
||||
fn cycle_index(current: usize, len: usize, delta: isize) -> usize {
|
||||
@@ -413,7 +406,7 @@ mod tests {
|
||||
display_name: "External".to_string(),
|
||||
built_in: false,
|
||||
worker_creation_available: true,
|
||||
working_directory_required: true,
|
||||
supports_workdir_attachments: true,
|
||||
status: "online".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
@@ -422,7 +415,7 @@ mod tests {
|
||||
display_name: "Embedded".to_string(),
|
||||
built_in: true,
|
||||
worker_creation_available: true,
|
||||
working_directory_required: false,
|
||||
supports_workdir_attachments: false,
|
||||
status: "online".to_string(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
@@ -454,20 +447,18 @@ mod tests {
|
||||
let state = FormState::new(&options);
|
||||
assert_eq!(
|
||||
state.current_runtime(&options).unwrap().runtime_id,
|
||||
"embedded"
|
||||
"external"
|
||||
);
|
||||
assert_eq!(state.current_profile(&options).unwrap().id, "builtin:coder");
|
||||
assert_eq!(state.display_name, "Worker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_required_runtime_cannot_be_submitted() {
|
||||
fn attachment_capable_runtime_can_submit_without_resources() {
|
||||
let options = options();
|
||||
let mut state = FormState::new(&options);
|
||||
state.runtime_index = 0;
|
||||
assert_eq!(state.submit(&options), None);
|
||||
assert!(state.status.contains("requires a workdir"));
|
||||
assert_eq!(state.field, Field::Runtime);
|
||||
assert_eq!(state.submit(&options).unwrap().runtime_id, "external");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -101,6 +101,37 @@ Host generation reuse, native permission identity, read-history updates, model
|
||||
Operation publication, transport hosting (Runtime/Backend/External), and durable
|
||||
Ticket/MR review remain the parent integration's responsibilities.
|
||||
|
||||
## Bounded live List pagination
|
||||
|
||||
`ListRequest { path, limit, after: Option<ListCursor> }` accepts an exclusive
|
||||
ordering key `ListCursor { kind: EntryKind, path: FsPath }`. Directories sort
|
||||
first, then paths sort lexically within the directory/non-directory groups;
|
||||
File, Symlink and Other share the non-directory group. `ListResult.next_after`
|
||||
is the last returned key when another page remains. Reuse that key unchanged
|
||||
with the same directory and output root. Cursor paths must name a direct child
|
||||
in the result coordinate frame; they are not statted and need not still exist.
|
||||
The optional fields default to absent and are omitted from JSON when absent.
|
||||
|
||||
The shared provider engine retains at most `limit + 1` candidates, additionally
|
||||
bounded by the 1 MiB aggregate path-response ceiling (including continuation),
|
||||
while scanning the directory once. It never stops merely because the page is
|
||||
full: enumeration order cannot determine the retained prefix. `total_entries`
|
||||
and `total_bytes` describe all visible entries in the current scan, including
|
||||
keys at/before `after`, not only the page or remaining suffix. A byte-bounded
|
||||
page may contain fewer than `limit` entries and still provide continuation.
|
||||
A zero-limit request returns totals and truncation but no continuation.
|
||||
List uses Read capability and read/enumeration scope, and **does not ignore-filter**
|
||||
`.gitignore`/`.ignore` entries or hide dotfiles; Glob/Grep keep their own policy.
|
||||
Cancellation and the 100,000-entry traversal ceiling apply to the complete scan,
|
||||
including excluded entries. External DTOs retain their existing item/path bounds
|
||||
and also validate cursor paths and continuation response bytes.
|
||||
|
||||
Each page observes a live directory, not a snapshot, and no cross-page
|
||||
consistency is promised. Insertions after the cursor may appear; earlier keys
|
||||
are not revisited. Removed entries disappear, including the cursor entry.
|
||||
Renames or kind changes can move an entry across the cursor and cause omission
|
||||
or repetition. Even one scan is not transactional against external editors.
|
||||
|
||||
## Scoped checkout search boundary
|
||||
|
||||
`CheckoutSearchRequest::new(CheckoutSearchOperation::{List, Glob, Grep}(request))`
|
||||
@@ -110,7 +141,8 @@ are `scope_layers: Vec<Vec<WorkdirToolScopeRule>>` and `output_root: WorkdirPath
|
||||
Rules union within each layer; layers intersect with each other, the current
|
||||
provider authority, and the output-root boundary. Wrappers contribute their own
|
||||
scope; caller-supplied layers can only narrow, never replace it. Each wrapper
|
||||
translates all incoming coordinates once and forwards the composed output root.
|
||||
translates incoming provider coordinates once and forwards the composed output root.
|
||||
List cursors remain output-root-relative, just like result paths.
|
||||
Provider-rendered Grep text and every typed result path are already relative to
|
||||
that final root; wrappers must not prefix cwd onto returned paths again.
|
||||
|
||||
|
||||
@@ -167,6 +167,19 @@ impl CheckoutSearchRequest {
|
||||
}
|
||||
let valid = match &self.operation {
|
||||
CheckoutSearchOperation::List(r) => {
|
||||
if let Some(after) = &r.after {
|
||||
validate_path(&after.path)?;
|
||||
if after.path.is_root()
|
||||
|| std::path::Path::new(after.path.as_str()).parent()
|
||||
!= std::path::Path::new(r.path.as_str())
|
||||
.strip_prefix(self.output_root.as_str())
|
||||
.ok()
|
||||
{
|
||||
return Err(WorkdirError::InvalidArgument(
|
||||
"List cursor must name a direct child of the listed directory".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
r.limit <= crate::external::MAX_EXTERNAL_RESULT_ITEMS
|
||||
}
|
||||
CheckoutSearchOperation::Glob(r) => {
|
||||
@@ -793,6 +806,7 @@ mod tests {
|
||||
for provider in [&local as &dyn WorkdirSession, &external] {
|
||||
let listed = provider
|
||||
.list(ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::root(),
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
@@ -60,6 +60,7 @@ fn fixture() -> tempfile::TempDir {
|
||||
async fn verify_sparse(session: &dyn WorkdirSession) {
|
||||
let listed = session
|
||||
.list(ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::root(),
|
||||
limit: 100,
|
||||
})
|
||||
@@ -75,6 +76,7 @@ async fn verify_sparse(session: &dyn WorkdirSession) {
|
||||
);
|
||||
let empty = session
|
||||
.list(ListRequest {
|
||||
after: None,
|
||||
path: path("denied"),
|
||||
limit: 100,
|
||||
})
|
||||
@@ -108,6 +110,7 @@ async fn verify_sparse(session: &dyn WorkdirSession) {
|
||||
let CheckoutSearchResult::List(native) = session
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||
ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::root(),
|
||||
limit: 100,
|
||||
},
|
||||
@@ -161,6 +164,7 @@ async fn checkout_search_nested_sparse_nonrecursive_scopes_rebase_and_exclude_co
|
||||
.tool_session()
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||
ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::root(),
|
||||
limit: 100
|
||||
}
|
||||
@@ -196,6 +200,7 @@ async fn checkout_search_composes_actual_nested_wrappers_and_output_root() {
|
||||
let CheckoutSearchResult::List(result) = stacked
|
||||
.checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List(
|
||||
ListRequest {
|
||||
after: None,
|
||||
path: path("keep"),
|
||||
limit: 100,
|
||||
},
|
||||
@@ -229,6 +234,7 @@ async fn checkout_search_composes_actual_nested_wrappers_and_output_root() {
|
||||
let entries = second
|
||||
.tool_session()
|
||||
.list(ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::root(),
|
||||
limit: 100,
|
||||
})
|
||||
@@ -246,6 +252,7 @@ async fn checkout_search_composes_actual_nested_wrappers_and_output_root() {
|
||||
second
|
||||
.tool_session()
|
||||
.list(ListRequest {
|
||||
after: None,
|
||||
path: path("deep"),
|
||||
limit: 100
|
||||
})
|
||||
@@ -292,6 +299,7 @@ async fn checkout_search_scope_layers_intersect_before_list_and_grep() {
|
||||
root.path().to_path_buf(),
|
||||
);
|
||||
let mut request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
|
||||
after: None,
|
||||
path: path("child"),
|
||||
limit: 100,
|
||||
}));
|
||||
@@ -325,6 +333,7 @@ async fn checkout_search_wire_dto_bounds_and_provider_capabilities() {
|
||||
root.path().to_path_buf(),
|
||||
);
|
||||
let request = CheckoutSearchRequest::new(CheckoutSearchOperation::List(ListRequest {
|
||||
after: None,
|
||||
path: path("child/keep"),
|
||||
limit: 100,
|
||||
}));
|
||||
@@ -795,6 +804,7 @@ async fn checkout_search_preserves_current_provider_scope_and_rejects_symlink_es
|
||||
let CheckoutSearchResult::List(result) = source
|
||||
.checkout_search(CheckoutSearchRequest {
|
||||
operation: CheckoutSearchOperation::List(ListRequest {
|
||||
after: None,
|
||||
path: path("child/denied"),
|
||||
limit: 100,
|
||||
}),
|
||||
|
||||
@@ -155,7 +155,11 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
|
||||
<= MAX_EXTERNAL_SCOPE_RULES
|
||||
&& validate_external_operation(&match &request.operation {
|
||||
crate::CheckoutSearchOperation::List(r) => {
|
||||
WorkdirSessionOperation::List(r.clone())
|
||||
// request.validate() checks the output_root-relative
|
||||
// cursor; validate the provider-coordinate path here.
|
||||
let mut list = r.clone();
|
||||
list.after = None;
|
||||
WorkdirSessionOperation::List(list)
|
||||
}
|
||||
crate::CheckoutSearchOperation::Glob(r) => {
|
||||
WorkdirSessionOperation::Glob(r.clone())
|
||||
@@ -212,7 +216,14 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
|
||||
.contains(&request.max_bytes)
|
||||
}
|
||||
WorkdirSessionOperation::List(request) => {
|
||||
is_root_relative(&request.path) && request.limit <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
is_root_relative(&request.path)
|
||||
&& request.limit <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
&& request.after.as_ref().is_none_or(|after| {
|
||||
is_root_relative(&after.path)
|
||||
&& !after.path.is_root()
|
||||
&& Path::new(after.path.as_str()).parent()
|
||||
== Some(Path::new(request.path.as_str()))
|
||||
})
|
||||
}
|
||||
WorkdirSessionOperation::Glob(request) => {
|
||||
is_root_relative(&request.path)
|
||||
@@ -395,7 +406,20 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
|
||||
.entries
|
||||
.iter()
|
||||
.all(|entry| is_root_relative(&entry.path))
|
||||
&& result_paths_fit(result.entries.iter().map(|entry| &entry.path))
|
||||
&& result.next_after.as_ref().is_none_or(|after| {
|
||||
is_root_relative(&after.path)
|
||||
&& result.truncated
|
||||
&& result.entries.last().is_some_and(|entry| {
|
||||
entry.kind == after.kind && entry.path == after.path
|
||||
})
|
||||
})
|
||||
&& result_paths_fit(
|
||||
result
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| &entry.path)
|
||||
.chain(result.next_after.iter().map(|after| &after.path)),
|
||||
)
|
||||
}
|
||||
WorkdirSessionOperationResult::Glob(result) => {
|
||||
result.paths.len() <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
@@ -917,6 +941,7 @@ mod tests {
|
||||
assert!(serde_json::from_value::<ExternalWorkdirServerFrame>(nested).is_err());
|
||||
|
||||
let unbounded = WorkdirSessionOperation::List(ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::root(),
|
||||
limit: MAX_EXTERNAL_RESULT_ITEMS + 1,
|
||||
});
|
||||
|
||||
@@ -33,9 +33,9 @@ use tokio::sync::broadcast;
|
||||
|
||||
pub use fs_operation::{
|
||||
BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath,
|
||||
GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest,
|
||||
ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest,
|
||||
StatResult, WriteRequest, WriteResult,
|
||||
GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListCursor, ListEntry,
|
||||
ListRequest, ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult,
|
||||
StatRequest, StatResult, WriteRequest, WriteResult,
|
||||
};
|
||||
pub use http::dispatch_workdir_session_operation;
|
||||
pub use local::{
|
||||
|
||||
@@ -2695,6 +2695,7 @@ mod tests {
|
||||
let listed = WorkdirSession::list(
|
||||
&workdir,
|
||||
ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::new("notes").unwrap(),
|
||||
limit: 10,
|
||||
},
|
||||
@@ -3385,6 +3386,7 @@ mod tests {
|
||||
let list = WorkdirSession::list(
|
||||
&workdir,
|
||||
ListRequest {
|
||||
after: None,
|
||||
path: WorkdirPath::new("yoi.local").unwrap(),
|
||||
limit: 10,
|
||||
},
|
||||
|
||||
@@ -1005,6 +1005,9 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
.await?;
|
||||
self.ensure_read(&path, request.operation.capability())?;
|
||||
*request.operation.path_mut() = path;
|
||||
// List cursors stay output_root-relative, just like source results.
|
||||
// Composing output_root below handles their coordinates without stat
|
||||
// of a key that may have disappeared between live pages.
|
||||
request.output_root = self.resolve_path(&request.output_root)?;
|
||||
for layer in &mut request.scope_layers {
|
||||
for rule in layer {
|
||||
@@ -1873,6 +1876,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
parent
|
||||
.list(ListRequest {
|
||||
after: None,
|
||||
path: fs_path("child"),
|
||||
limit: 100
|
||||
})
|
||||
@@ -1890,6 +1894,7 @@ mod tests {
|
||||
child
|
||||
.tool_session()
|
||||
.list(ListRequest {
|
||||
after: None,
|
||||
path: FsPath::root(),
|
||||
limit: 100
|
||||
})
|
||||
@@ -1901,6 +1906,7 @@ mod tests {
|
||||
.tool_session()
|
||||
.checkout_search(crate::CheckoutSearchRequest::new(
|
||||
crate::CheckoutSearchOperation::List(ListRequest {
|
||||
after: None,
|
||||
path: FsPath::root(),
|
||||
limit: 100
|
||||
})
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
#![cfg(target_os = "linux")]
|
||||
use std::sync::Arc;
|
||||
use workdir::external::{
|
||||
ExternalWorkdirOperation, ExternalWorkdirOperationResult, MAX_EXTERNAL_RESULT_ITEMS,
|
||||
};
|
||||
use workdir::http::{WorkdirSessionOperation as Op, WorkdirSessionOperationResult as Output};
|
||||
use workdir::*;
|
||||
|
||||
fn path(value: &str) -> WorkdirPath {
|
||||
WorkdirPath::new(value).unwrap()
|
||||
}
|
||||
fn rule(value: &str, recursive: bool) -> WorkdirToolScopeRule {
|
||||
WorkdirToolScopeRule {
|
||||
target: path(value),
|
||||
recursive,
|
||||
permission: WorkdirToolScopePermission::Read,
|
||||
symlink_policy: manifest::SymlinkPolicy::Logical,
|
||||
}
|
||||
}
|
||||
fn scope(rules: Vec<WorkdirToolScopeRule>, cwd: &str) -> WorkdirToolScope {
|
||||
WorkdirToolScope {
|
||||
rules,
|
||||
cwd: path(cwd),
|
||||
command: false,
|
||||
}
|
||||
}
|
||||
fn request(value: &str, limit: usize, after: Option<ListCursor>) -> ListRequest {
|
||||
ListRequest {
|
||||
path: path(value),
|
||||
limit,
|
||||
after,
|
||||
}
|
||||
}
|
||||
fn names(result: &ListResult) -> Vec<&str> {
|
||||
result
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.path.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_scoped_external_pages_rebase_cursor_and_preserve_sparse_authority() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(root.path().join("child/deep")).unwrap();
|
||||
for name in ["child/a", "child/z", "child/deep/secret", "outside"] {
|
||||
std::fs::write(root.path().join(name), b"x").unwrap();
|
||||
}
|
||||
let source = LocalWorkdirSession::external_read_only(
|
||||
Workdir::new("pagination"),
|
||||
root.path(),
|
||||
BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||
)
|
||||
.unwrap();
|
||||
let broker = WorkdirToolBroker::new(Arc::new(source));
|
||||
let lease = broker
|
||||
.scope(scope(vec![rule("child", false)], "child"))
|
||||
.await
|
||||
.unwrap();
|
||||
// Two actual wrappers must not duplicate child/ in the continuation.
|
||||
let stacked = WorkdirToolBroker::new(lease.tool_session());
|
||||
let first = stacked.list(request("", 1, None)).await.unwrap();
|
||||
assert_eq!(names(&first), ["deep"]);
|
||||
assert_eq!(first.next_after.as_ref().unwrap().path, path("deep"));
|
||||
assert_eq!(
|
||||
first.next_after.as_ref().unwrap().kind,
|
||||
EntryKind::Directory
|
||||
);
|
||||
assert_eq!(first.total_entries, 3);
|
||||
assert!(
|
||||
stacked
|
||||
.list(request("deep", 1, None))
|
||||
.await
|
||||
.unwrap()
|
||||
.entries
|
||||
.is_empty()
|
||||
);
|
||||
let second = stacked
|
||||
.list(request("", 1, first.next_after))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(names(&second), ["a"]);
|
||||
std::fs::remove_file(root.path().join("child/a")).unwrap();
|
||||
std::fs::write(root.path().join("child/b"), b"xx").unwrap();
|
||||
let third = stacked
|
||||
.list(request("", 10, second.next_after))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(names(&third), ["b", "z"]);
|
||||
assert!(!third.truncated);
|
||||
assert!(third.next_after.is_none());
|
||||
assert_eq!(third.total_entries, 3);
|
||||
lease.close().await.unwrap();
|
||||
assert!(matches!(
|
||||
stacked.list(request("", 1, None)).await,
|
||||
Err(WorkdirError::SessionClosed)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_checkout_search_cursor_uses_output_root_coordinates_across_nested_wrappers() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(root.path().join("child/keep")).unwrap();
|
||||
for name in ["a", "b", "c"] {
|
||||
std::fs::write(root.path().join("child/keep").join(name), b"x").unwrap();
|
||||
}
|
||||
let source = LocalWorkdirSession::external_read_only(
|
||||
Workdir::new("rebased-pagination"),
|
||||
root.path(),
|
||||
BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||
)
|
||||
.unwrap();
|
||||
let broker = WorkdirToolBroker::new(Arc::new(source));
|
||||
let lease = broker
|
||||
.scope(scope(vec![rule("child", true)], "child"))
|
||||
.await
|
||||
.unwrap();
|
||||
let stacked = WorkdirToolBroker::new(lease.tool_session());
|
||||
let mut search =
|
||||
CheckoutSearchRequest::new(CheckoutSearchOperation::List(request("keep", 1, None)));
|
||||
search.output_root = path("keep");
|
||||
let CheckoutSearchResult::List(first) = stacked.checkout_search(search.clone()).await.unwrap()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(names(&first), ["a"]);
|
||||
if let CheckoutSearchOperation::List(list) = &mut search.operation {
|
||||
list.after = first.next_after;
|
||||
}
|
||||
let CheckoutSearchResult::List(second) = stacked.checkout_search(search).await.unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(names(&second), ["b"]);
|
||||
assert_eq!(second.next_after.unwrap().path, path("b"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_read_capability_suffices_without_glob_and_missing_read_is_denied() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(root.path().join("a"), b"x").unwrap();
|
||||
let source = LocalWorkdirSession::materialized(
|
||||
root.path().to_path_buf(),
|
||||
root.path().to_path_buf(),
|
||||
manifest::SharedScope::new(manifest::Scope::writable(root.path()).unwrap()),
|
||||
WorkdirSessionCapabilities::from_capabilities([WorkdirSessionCapability::Read]),
|
||||
);
|
||||
let broker = WorkdirToolBroker::new(Arc::new(source));
|
||||
let lease = broker.scope(scope(vec![rule("", true)], "")).await.unwrap();
|
||||
assert_eq!(
|
||||
names(
|
||||
&lease
|
||||
.tool_session()
|
||||
.list(request("", 1, None))
|
||||
.await
|
||||
.unwrap()
|
||||
),
|
||||
["a"]
|
||||
);
|
||||
let denied = LocalWorkdirSession::external_with_capabilities_pinned(
|
||||
Workdir::new("no-read"),
|
||||
ExternalWorkdirRoot::pin(root.path()).unwrap(),
|
||||
BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||
WorkdirSessionCapabilities::COMMAND_ONLY,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
denied.list(request("", 1, None)).await,
|
||||
Err(WorkdirError::Unsupported(WorkdirSessionCapability::Read))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_external_wire_preserves_continuation_and_rejects_unbounded_cursor() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(root.path().join("child")).unwrap();
|
||||
for name in ["a", "b"] {
|
||||
std::fs::write(root.path().join("child").join(name), b"x").unwrap();
|
||||
}
|
||||
let source = LocalWorkdirSession::external_read_only(
|
||||
Workdir::new("wire-list"),
|
||||
root.path(),
|
||||
BoundedReadLimits::EXTERNAL_DEFAULT,
|
||||
)
|
||||
.unwrap();
|
||||
let first = source.list(request("child", 1, None)).await.unwrap();
|
||||
let wire = serde_json::to_value(
|
||||
ExternalWorkdirOperationResult::try_from(Output::List(first.clone())).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(wire["result"]["next_after"]["path"], "child/a");
|
||||
let decoded: ExternalWorkdirOperationResult = serde_json::from_value(wire.clone()).unwrap();
|
||||
assert_eq!(decoded.into_inner(), Output::List(first.clone()));
|
||||
let operation =
|
||||
ExternalWorkdirOperation::try_from(Op::List(request("child", 1, first.next_after)))
|
||||
.unwrap();
|
||||
let decoded: ExternalWorkdirOperation =
|
||||
serde_json::from_value(serde_json::to_value(operation).unwrap()).unwrap();
|
||||
let Output::List(next) = dispatch_workdir_session_operation(&source, decoded.into_inner())
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(names(&next), ["child/b"]);
|
||||
assert!(next.next_after.is_none());
|
||||
for bad_path in ["/host".to_owned(), "x".repeat(4097), "outside/a".to_owned()] {
|
||||
let mut bad = serde_json::to_value(Op::List(request("child", 1, None))).unwrap();
|
||||
bad["request"]["after"] = serde_json::json!({"kind": "file", "path": bad_path});
|
||||
assert!(serde_json::from_value::<ExternalWorkdirOperation>(bad).is_err());
|
||||
let mut bad_result = wire.clone();
|
||||
bad_result["result"]["next_after"]["path"] = serde_json::json!(bad_path);
|
||||
assert!(serde_json::from_value::<ExternalWorkdirOperationResult>(bad_result).is_err());
|
||||
}
|
||||
assert!(
|
||||
ExternalWorkdirOperation::try_from(Op::List(request(
|
||||
"child",
|
||||
MAX_EXTERNAL_RESULT_ITEMS + 1,
|
||||
None
|
||||
)))
|
||||
.is_err()
|
||||
);
|
||||
// Search cursors use result coordinates, not provider path coordinates.
|
||||
let mut search = CheckoutSearchRequest::new(CheckoutSearchOperation::List(request(
|
||||
"child",
|
||||
1,
|
||||
Some(ListCursor {
|
||||
kind: EntryKind::File,
|
||||
path: path("a"),
|
||||
}),
|
||||
)));
|
||||
search.output_root = path("child");
|
||||
let bounded = ExternalWorkdirOperation::try_from(Op::CheckoutSearch(search)).unwrap();
|
||||
let Output::CheckoutSearch(CheckoutSearchResult::List(next)) =
|
||||
dispatch_workdir_session_operation(&source, bounded.into_inner())
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(names(&next), ["b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_external_result_counts_cursor_toward_aggregate_path_bound() {
|
||||
let entries: Vec<_> = (0..252)
|
||||
.map(|n| ListEntry {
|
||||
path: path(&format!("{n:04}{}", "x".repeat(4092))),
|
||||
kind: EntryKind::File,
|
||||
size: 1,
|
||||
})
|
||||
.collect();
|
||||
let after = ListCursor {
|
||||
kind: EntryKind::File,
|
||||
path: entries.last().unwrap().path.clone(),
|
||||
};
|
||||
let result = ListResult {
|
||||
entries,
|
||||
total_entries: 253,
|
||||
total_bytes: 253,
|
||||
truncated: true,
|
||||
next_after: None,
|
||||
};
|
||||
let mut wire = serde_json::to_value(Output::List(result)).unwrap();
|
||||
assert!(serde_json::from_value::<ExternalWorkdirOperationResult>(wire.clone()).is_ok());
|
||||
wire["result"]["next_after"] = serde_json::to_value(after).unwrap();
|
||||
assert!(serde_json::from_value::<ExternalWorkdirOperationResult>(wire).is_err());
|
||||
}
|
||||
@@ -31,6 +31,7 @@ decodal.workspace = true
|
||||
flow = { path = "../flow" }
|
||||
fs4 = { workspace = true, optional = true }
|
||||
manifest.workspace = true
|
||||
libc.workspace = true
|
||||
protocol.workspace = true
|
||||
runtime-api.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -42,7 +42,11 @@ pub enum RuntimeAuthError {
|
||||
#[error("runtime request proof contains an invalid `{0}` claim")]
|
||||
InvalidClaim(&'static str),
|
||||
#[error("runtime request proof does not match the HTTP request")]
|
||||
ClaimMismatch,
|
||||
ClaimMismatch(RuntimeRequestClaim),
|
||||
#[error("runtime request proof was issued in the future")]
|
||||
RequestIssuedInFuture { iat: i64, exp: i64, now: i64 },
|
||||
#[error("runtime request proof has expired")]
|
||||
RequestExpired { iat: i64, exp: i64, now: i64 },
|
||||
#[error("unknown token issuer `{0}`")]
|
||||
UnknownIssuer(String),
|
||||
#[error("invalid token signature")]
|
||||
@@ -171,6 +175,19 @@ pub struct RuntimeAuthContext {
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
/// The first mismatching signed claim, in verification order. No input values.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RuntimeRequestClaim {
|
||||
Issuer,
|
||||
Audience,
|
||||
Workspace,
|
||||
Worker,
|
||||
Permission,
|
||||
Method,
|
||||
Path,
|
||||
BodyDigest,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeRequestSourceClaims {
|
||||
pub iss: String,
|
||||
@@ -293,19 +310,54 @@ pub fn verify_runtime_request_source(
|
||||
public_key,
|
||||
)?;
|
||||
let claims = signed.claims;
|
||||
if claims.iss != expected.identity_id
|
||||
|| claims.aud != expected.audience
|
||||
|| claims.workspace_id != expected.workspace_id
|
||||
|| claims.worker_id.as_deref() != expected.worker_id
|
||||
|| claims.permission != expected.permission
|
||||
|| claims.method != expected.method
|
||||
|| claims.path != expected.path
|
||||
|| claims.body_digest != expected.body_digest
|
||||
{
|
||||
return Err(RuntimeAuthError::ClaimMismatch);
|
||||
for (matches, claim) in [
|
||||
(
|
||||
claims.iss == expected.identity_id,
|
||||
RuntimeRequestClaim::Issuer,
|
||||
),
|
||||
(
|
||||
claims.aud == expected.audience,
|
||||
RuntimeRequestClaim::Audience,
|
||||
),
|
||||
(
|
||||
claims.workspace_id == expected.workspace_id,
|
||||
RuntimeRequestClaim::Workspace,
|
||||
),
|
||||
(
|
||||
claims.worker_id.as_deref() == expected.worker_id,
|
||||
RuntimeRequestClaim::Worker,
|
||||
),
|
||||
(
|
||||
claims.permission == expected.permission,
|
||||
RuntimeRequestClaim::Permission,
|
||||
),
|
||||
(
|
||||
claims.method == expected.method,
|
||||
RuntimeRequestClaim::Method,
|
||||
),
|
||||
(claims.path == expected.path, RuntimeRequestClaim::Path),
|
||||
(
|
||||
claims.body_digest == expected.body_digest,
|
||||
RuntimeRequestClaim::BodyDigest,
|
||||
),
|
||||
] {
|
||||
if !matches {
|
||||
return Err(RuntimeAuthError::ClaimMismatch(claim));
|
||||
}
|
||||
}
|
||||
if claims.iat > expected.now_unix || claims.exp < expected.now_unix {
|
||||
return Err(RuntimeAuthError::Expired);
|
||||
if claims.iat > expected.now_unix {
|
||||
return Err(RuntimeAuthError::RequestIssuedInFuture {
|
||||
iat: claims.iat,
|
||||
exp: claims.exp,
|
||||
now: expected.now_unix,
|
||||
});
|
||||
}
|
||||
if claims.exp < expected.now_unix {
|
||||
return Err(RuntimeAuthError::RequestExpired {
|
||||
iat: claims.iat,
|
||||
exp: claims.exp,
|
||||
now: expected.now_unix,
|
||||
});
|
||||
}
|
||||
Ok(claims)
|
||||
}
|
||||
@@ -705,7 +757,9 @@ mod tests {
|
||||
};
|
||||
assert!(matches!(
|
||||
verify_runtime_request_source(&proof, &trusted.public_key, &changed_body),
|
||||
Err(RuntimeAuthError::ClaimMismatch)
|
||||
Err(RuntimeAuthError::ClaimMismatch(
|
||||
RuntimeRequestClaim::BodyDigest
|
||||
))
|
||||
));
|
||||
let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
|
||||
assert!(matches!(
|
||||
@@ -714,6 +768,183 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_request_proof_preserves_inclusive_time_bounds_and_reports_exact_rejections() {
|
||||
let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
|
||||
let signer = RuntimeRequestSourceSigner::from_identity(&runtime);
|
||||
let proof = signer
|
||||
.issue(
|
||||
"server",
|
||||
"workspace",
|
||||
None,
|
||||
"permission",
|
||||
"POST",
|
||||
"/request",
|
||||
b"{}",
|
||||
90,
|
||||
10,
|
||||
)
|
||||
.unwrap();
|
||||
let digest = request_body_digest(b"{}");
|
||||
let expected = RuntimeRequestSourceExpectation {
|
||||
identity_id: "runtime-main",
|
||||
audience: "server",
|
||||
workspace_id: "workspace",
|
||||
worker_id: None,
|
||||
permission: "permission",
|
||||
method: "POST",
|
||||
path: "/request",
|
||||
body_digest: &digest,
|
||||
now_unix: 90,
|
||||
};
|
||||
for now in [90, 99, 100] {
|
||||
assert!(
|
||||
verify_runtime_request_source(
|
||||
&proof,
|
||||
&runtime.public_key,
|
||||
&RuntimeRequestSourceExpectation {
|
||||
now_unix: now,
|
||||
..expected.clone()
|
||||
}
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
for (now, future) in [(89, true), (101, false)] {
|
||||
let error = verify_runtime_request_source(
|
||||
&proof,
|
||||
&runtime.public_key,
|
||||
&RuntimeRequestSourceExpectation {
|
||||
now_unix: now,
|
||||
..expected.clone()
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
match error {
|
||||
RuntimeAuthError::RequestIssuedInFuture {
|
||||
iat,
|
||||
exp,
|
||||
now: verified,
|
||||
} if future => assert_eq!((iat, exp, verified), (90, 100, now)),
|
||||
RuntimeAuthError::RequestExpired {
|
||||
iat,
|
||||
exp,
|
||||
now: verified,
|
||||
} if !future => assert_eq!((iat, exp, verified), (90, 100, now)),
|
||||
other => panic!("unexpected rejection: {other:?}"),
|
||||
}
|
||||
}
|
||||
for (claim, expectation) in [
|
||||
(
|
||||
RuntimeRequestClaim::Issuer,
|
||||
RuntimeRequestSourceExpectation {
|
||||
identity_id: "other",
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
RuntimeRequestClaim::Audience,
|
||||
RuntimeRequestSourceExpectation {
|
||||
audience: "other",
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
RuntimeRequestClaim::Workspace,
|
||||
RuntimeRequestSourceExpectation {
|
||||
workspace_id: "other",
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
RuntimeRequestClaim::Worker,
|
||||
RuntimeRequestSourceExpectation {
|
||||
worker_id: Some("other"),
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
RuntimeRequestClaim::Permission,
|
||||
RuntimeRequestSourceExpectation {
|
||||
permission: "other",
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
RuntimeRequestClaim::Method,
|
||||
RuntimeRequestSourceExpectation {
|
||||
method: "GET",
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
RuntimeRequestClaim::Path,
|
||||
RuntimeRequestSourceExpectation {
|
||||
path: "/other",
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
(
|
||||
RuntimeRequestClaim::BodyDigest,
|
||||
RuntimeRequestSourceExpectation {
|
||||
body_digest: "other",
|
||||
..expected.clone()
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
matches!(verify_runtime_request_source(&proof, &runtime.public_key, &expectation), Err(RuntimeAuthError::ClaimMismatch(actual)) if actual == claim)
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
verify_runtime_request_source(
|
||||
&proof,
|
||||
&runtime.public_key,
|
||||
&RuntimeRequestSourceExpectation {
|
||||
audience: "other",
|
||||
now_unix: 101,
|
||||
..expected.clone()
|
||||
}
|
||||
),
|
||||
Err(RuntimeAuthError::ClaimMismatch(
|
||||
RuntimeRequestClaim::Audience
|
||||
))
|
||||
));
|
||||
let spoofed = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
|
||||
assert!(matches!(
|
||||
verify_runtime_request_source(
|
||||
&proof,
|
||||
&spoofed.public_key,
|
||||
&RuntimeRequestSourceExpectation {
|
||||
now_unix: 101,
|
||||
..expected.clone()
|
||||
}
|
||||
),
|
||||
Err(RuntimeAuthError::InvalidSignature)
|
||||
));
|
||||
let mut claims = decode_runtime_request_source_claims(&proof).unwrap();
|
||||
claims.iat = 101;
|
||||
claims.exp = 89;
|
||||
let contradictory = sign_json_token(
|
||||
RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
|
||||
RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
|
||||
&runtime.signing_key().unwrap(),
|
||||
&claims,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
verify_runtime_request_source(
|
||||
&contradictory,
|
||||
&runtime.public_key,
|
||||
&RuntimeRequestSourceExpectation {
|
||||
now_unix: 100,
|
||||
..expected.clone()
|
||||
}
|
||||
),
|
||||
Err(RuntimeAuthError::RequestIssuedInFuture { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_request_source_proof_rejects_wrong_scope_and_expiry() {
|
||||
let runtime = RuntimeIdentityMaterial::generate("runtime-main").unwrap();
|
||||
@@ -749,7 +980,9 @@ mod tests {
|
||||
};
|
||||
assert!(matches!(
|
||||
verify_runtime_request_source(&proof, &runtime.public_key, &wrong_workspace),
|
||||
Err(RuntimeAuthError::ClaimMismatch)
|
||||
Err(RuntimeAuthError::ClaimMismatch(
|
||||
RuntimeRequestClaim::Workspace
|
||||
))
|
||||
));
|
||||
let expired = RuntimeRequestSourceExpectation {
|
||||
now_unix: 101,
|
||||
@@ -757,7 +990,11 @@ mod tests {
|
||||
};
|
||||
assert!(matches!(
|
||||
verify_runtime_request_source(&proof, &runtime.public_key, &expired),
|
||||
Err(RuntimeAuthError::Expired)
|
||||
Err(RuntimeAuthError::RequestExpired {
|
||||
iat: 90,
|
||||
exp: 100,
|
||||
now: 101
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2486,6 +2486,8 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
|
||||
if matches!(
|
||||
diagnostic.code.as_str(),
|
||||
"repository_ref_provider_unavailable"
|
||||
| "working_directory_cleanup_mount_check_unavailable"
|
||||
| "working_directory_cleanup_storage_unavailable"
|
||||
| "repository_ref_provider_timeout"
|
||||
| "repository_access_provider_unavailable"
|
||||
) =>
|
||||
@@ -2504,6 +2506,23 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode {
|
||||
{
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
RuntimeError::WorkingDirectory(diagnostic)
|
||||
if matches!(
|
||||
diagnostic.code.as_str(),
|
||||
"working_directory_cleanup_mount_present"
|
||||
| "working_directory_cleanup_resource_busy"
|
||||
| "working_directory_cleanup_ownership_unknown"
|
||||
| "working_directory_cleanup_changes_present"
|
||||
| "working_directory_cleanup_changes_unknown"
|
||||
) =>
|
||||
{
|
||||
StatusCode::CONFLICT
|
||||
}
|
||||
RuntimeError::WorkingDirectory(diagnostic)
|
||||
if diagnostic.code == "working_directory_cleanup_permission_denied" =>
|
||||
{
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
RuntimeError::WorkingDirectory(diagnostic)
|
||||
if diagnostic.code == "repository_ref_not_found" =>
|
||||
{
|
||||
@@ -4322,6 +4341,37 @@ mod tests {
|
||||
assert!(!error.to_string().contains('/'));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_cause_codes_survive_http_error_serialization() {
|
||||
let cases = [
|
||||
("mount_present", StatusCode::CONFLICT),
|
||||
("mount_check_unavailable", StatusCode::SERVICE_UNAVAILABLE),
|
||||
("permission_denied", StatusCode::FORBIDDEN),
|
||||
("resource_busy", StatusCode::CONFLICT),
|
||||
("storage_unavailable", StatusCode::SERVICE_UNAVAILABLE),
|
||||
("ownership_unknown", StatusCode::CONFLICT),
|
||||
("changes_present", StatusCode::CONFLICT),
|
||||
("changes_unknown", StatusCode::CONFLICT),
|
||||
];
|
||||
for (cause, status) in cases {
|
||||
let code = format!("working_directory_cleanup_{cause}");
|
||||
let diagnostic = crate::working_directory::WorkingDirectoryDiagnostic::rejected(
|
||||
&code,
|
||||
"Resolve the current blocker and retry removal.",
|
||||
);
|
||||
let response =
|
||||
RuntimeHttpRestError::runtime(RuntimeError::WorkingDirectory(diagnostic))
|
||||
.into_response();
|
||||
assert_eq!(response.status(), status);
|
||||
let body = axum::body::to_bytes(response.into_body(), 2048)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(json["error"]["code"], code, "{json}");
|
||||
assert!(json["error"]["message"].as_str().unwrap().contains("retry"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_runtime_errors_preserve_diagnostic_code() {
|
||||
let cases = [
|
||||
|
||||
@@ -819,23 +819,30 @@ impl Runtime {
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
) -> Result<CatalogWorkingDirectoryStatus, RuntimeError> {
|
||||
let backend = {
|
||||
let state = self.lock()?;
|
||||
state.ensure_running()?;
|
||||
if let Some(worker_id) = state.worker_id_for_workdir(working_directory_id) {
|
||||
return Err(RuntimeError::InvalidRequest(format!(
|
||||
"working directory {working_directory_id} is assigned to worker {worker_id}"
|
||||
)));
|
||||
// Hold occupancy authority through the side effect, preventing an
|
||||
// attachment from entering between the check and physical removal.
|
||||
// This synchronous provider call must not reenter Runtime. The Git
|
||||
// materializer does filesystem/Git work only. The global guard also
|
||||
// delays unrelated state operations during hashing/unlink; releasing it
|
||||
// would require equivalent per-workdir exclusion on ALL attachment paths,
|
||||
// not just moving the occupied check before the provider call.
|
||||
let state = self.lock()?;
|
||||
state.ensure_running()?;
|
||||
if let Some(worker_id) = state.worker_id_for_workdir(working_directory_id) {
|
||||
return Err(RuntimeError::InvalidRequest(format!(
|
||||
"working directory {working_directory_id} is assigned to worker {worker_id}"
|
||||
)));
|
||||
}
|
||||
let backend = state.execution_backend.clone().ok_or_else(|| {
|
||||
RuntimeError::ExecutionBackendUnavailable {
|
||||
message: "working directory cleanup requires an execution backend".to_string(),
|
||||
}
|
||||
state.execution_backend.clone().ok_or_else(|| {
|
||||
RuntimeError::ExecutionBackendUnavailable {
|
||||
message: "working directory cleanup requires an execution backend".to_string(),
|
||||
}
|
||||
})?
|
||||
};
|
||||
backend
|
||||
})?;
|
||||
let result = backend
|
||||
.cleanup_working_directory(working_directory_id)
|
||||
.map_err(RuntimeError::from)
|
||||
.map_err(RuntimeError::from);
|
||||
drop(state);
|
||||
result
|
||||
}
|
||||
|
||||
fn annotate_working_directory_statuses(
|
||||
@@ -7665,6 +7672,7 @@ mod tests {
|
||||
repository_accesses: Mutex<Vec<WorkingDirectoryRepositoryAccessRequest>>,
|
||||
repository_access_available: AtomicBool,
|
||||
working_directory_requests: Mutex<Vec<WorkingDirectoryRequest>>,
|
||||
cleanup_occupancy_probe: Mutex<Option<Arc<Mutex<RuntimeState>>>>,
|
||||
preserve_submission_acknowledgement_id: AtomicBool,
|
||||
#[cfg(feature = "ws-server")]
|
||||
snapshots: Mutex<BTreeMap<WorkerId, protocol::Event>>,
|
||||
@@ -7705,6 +7713,22 @@ mod tests {
|
||||
}
|
||||
|
||||
impl WorkerExecutionBackend for TestExecutionBackend {
|
||||
fn cleanup_working_directory(
|
||||
&self,
|
||||
_id: &str,
|
||||
) -> Result<CatalogWorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||
if let Some(state) = self.cleanup_occupancy_probe.lock().unwrap().as_ref() {
|
||||
assert!(
|
||||
matches!(state.try_lock(), Err(std::sync::TryLockError::WouldBlock)),
|
||||
"occupancy must remain exclusive during physical cleanup"
|
||||
);
|
||||
}
|
||||
Err(WorkingDirectoryDiagnostic::rejected(
|
||||
"working_directory_cleanup_resource_busy",
|
||||
"Retry removal after releasing the resource",
|
||||
))
|
||||
}
|
||||
|
||||
fn backend_id(&self) -> &str {
|
||||
"test-execution-backend"
|
||||
}
|
||||
@@ -8043,6 +8067,22 @@ mod tests {
|
||||
.block_on(subscription.recv())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_removal_keeps_occupancy_exclusive_during_provider_side_effect() {
|
||||
let (runtime, backend) = runtime_and_backend();
|
||||
*backend.cleanup_occupancy_probe.lock().unwrap() = Some(runtime.inner.clone());
|
||||
let error = runtime
|
||||
.cleanup_working_directory("workdir-test")
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, RuntimeError::WorkingDirectory(ref diagnostic)
|
||||
if diagnostic.code == "working_directory_cleanup_resource_busy")
|
||||
);
|
||||
// The lock is released on failure, permitting normal attachment/retry.
|
||||
assert!(runtime.inner.try_lock().is_ok());
|
||||
*backend.cleanup_occupancy_probe.lock().unwrap() = None;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_typed_intent_is_rejected_before_backend_dispatch() {
|
||||
let (runtime, backend) = runtime_and_backend();
|
||||
|
||||
@@ -19,7 +19,6 @@ use crate::catalog::{
|
||||
CreateWorkerRequest, ProfileSourceArchiveSource, RepositoryRefObservation,
|
||||
RepositoryRefObservationRequest, WorkingDirectoryAttachmentStatus,
|
||||
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
|
||||
WorkingDirectoryStatusKind,
|
||||
};
|
||||
use crate::config_bundle::{ConfigBundle, workspace_config_etag};
|
||||
#[cfg(feature = "ws-server")]
|
||||
@@ -3773,28 +3772,24 @@ where
|
||||
)
|
||||
})?;
|
||||
for previous in &request.previous_workdir_attachments {
|
||||
let current = materializer
|
||||
.working_directory_status(
|
||||
&previous.working_directory.summary.working_directory_id,
|
||||
)
|
||||
.map_err(|message| {
|
||||
WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::Restore,
|
||||
format!(
|
||||
"Persisted Worker Workdir attachment `{}` is unavailable: {message}",
|
||||
previous.alias
|
||||
),
|
||||
)
|
||||
})?;
|
||||
if current.summary.status != WorkingDirectoryStatusKind::Active {
|
||||
return Err(WorkerExecutionResult::rejected(
|
||||
let relative_cwd = request
|
||||
.request
|
||||
.workdir_attachments
|
||||
.iter()
|
||||
.find(|claim| claim.alias == previous.alias)
|
||||
.and_then(|claim| claim.relative_cwd.as_deref());
|
||||
// Recovery attachments remain CleanupPending across restart.
|
||||
// Validate current bindability/identity, not the old state name.
|
||||
// Preflight is read-only; actual restore binds again below.
|
||||
materializer.preflight_bind_working_directory(
|
||||
&previous.working_directory.summary.working_directory_id,
|
||||
relative_cwd,
|
||||
).map_err(|message| {
|
||||
WorkerExecutionResult::rejected(
|
||||
WorkerExecutionOperation::Restore,
|
||||
format!(
|
||||
"Persisted Worker Workdir attachment `{}` is not active",
|
||||
previous.alias
|
||||
),
|
||||
));
|
||||
}
|
||||
format!("Persisted Worker Workdir attachment `{}` is unavailable: {message}", previous.alias),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
if request.previous_workdir_attachments.is_empty()
|
||||
@@ -4637,7 +4632,7 @@ mod tests {
|
||||
ConfigBundleRef, CreateWorkerRequest, LogicalWorkdirAttachment, MaterializerKind,
|
||||
ProfileSelector, RepositorySelector, WorkingDirectoryAttachmentClaim,
|
||||
WorkingDirectoryAttachmentRequest, WorkingDirectoryRepository, WorkingDirectoryRequest,
|
||||
WorkspaceApiRef,
|
||||
WorkingDirectoryStatusKind, WorkspaceApiRef,
|
||||
};
|
||||
use crate::execution::WorkerExecutionContext;
|
||||
use crate::identity::WorkerId;
|
||||
@@ -8919,6 +8914,160 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pending_owner_restore_fixture() -> (
|
||||
tempfile::TempDir,
|
||||
WorkerRuntimeExecutionBackend<MockFactory>,
|
||||
WorkerExecutionRestoreRequest,
|
||||
PathBuf,
|
||||
Arc<Mutex<Vec<PathBuf>>>,
|
||||
) {
|
||||
use crate::working_directory::{WorkingDirectoryMaterializer, fail_workdir_cleanup_once};
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let repo = create_clean_repo();
|
||||
let workdirs = root.path().join("workdirs");
|
||||
let materializer = RuntimeGitMaterializer::new(&workdirs);
|
||||
let binding = materializer
|
||||
.create(&working_directory_request(repo.path()))
|
||||
.unwrap();
|
||||
let id = binding.working_directory.id.clone();
|
||||
fail_workdir_cleanup_once(&materializer, &id);
|
||||
// The cleanup owner has legitimately reattached to preserve these changes.
|
||||
fs::write(binding.root.join("README.md"), "owner recovery in progress").unwrap();
|
||||
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||
let factory = || MockFactory {
|
||||
client: MockClient::new(simple_text_events()),
|
||||
runtime_base: root.path().join("runtime"),
|
||||
cwd: root.path().to_path_buf(),
|
||||
store_dir: root.path().join("sessions"),
|
||||
worker_metadata_dir: root.path().join("workers"),
|
||||
observed_cwds: observed.clone(),
|
||||
observed_workspace_clients: Arc::new(Mutex::new(Vec::new())),
|
||||
};
|
||||
let backend = Arc::new(
|
||||
WorkerRuntimeExecutionBackend::new(factory())
|
||||
.unwrap()
|
||||
.with_working_directory_materializer(materializer),
|
||||
);
|
||||
let runtime =
|
||||
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), backend.clone())
|
||||
.unwrap();
|
||||
runtime.store_config_bundle(test_bundle()).unwrap();
|
||||
let mut request = create_request("cleanup-owner");
|
||||
request.workdir_attachments = vec![WorkingDirectoryAttachmentClaim {
|
||||
alias: WorkdirAttachmentAlias::new("checkout").unwrap(),
|
||||
working_directory_id: id,
|
||||
relative_cwd: None,
|
||||
capabilities: WorkdirSessionCapabilities::ALL,
|
||||
}];
|
||||
let detail = runtime.create_worker(request.clone()).unwrap();
|
||||
runtime.stop_worker(&detail.worker_ref, None).unwrap();
|
||||
let restore = WorkerExecutionRestoreRequest {
|
||||
operation_id: crate::execution::WorkerLifecycleOperationId::new(),
|
||||
worker_ref: detail.worker_ref.clone(),
|
||||
request,
|
||||
workspace_scope: None,
|
||||
context: test_execution_context(detail.worker_ref),
|
||||
previous_workdir_attachments: detail.workdir_attachments,
|
||||
logical_workdir_attachments: Vec::new(),
|
||||
workdir_attachments: BTreeMap::new(),
|
||||
config_bundle: None,
|
||||
};
|
||||
drop(runtime);
|
||||
drop(backend);
|
||||
// A fresh backend/materializer loads the durable pending identity evidence.
|
||||
let restarted = WorkerRuntimeExecutionBackend::new(factory())
|
||||
.unwrap()
|
||||
.with_working_directory_materializer(RuntimeGitMaterializer::new(&workdirs));
|
||||
(root, restarted, restore, binding.root, observed)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn cleanup_pending_owner_restores_after_restart_with_validated_dirty_checkout() {
|
||||
let (root, backend, request, checkout, observed) = pending_owner_restore_fixture();
|
||||
let before = persisted_files(&root.path().join("workdirs"));
|
||||
backend.preflight_restore(&request).unwrap();
|
||||
assert_eq!(
|
||||
persisted_files(&root.path().join("workdirs")),
|
||||
before,
|
||||
"preflight must not rewrite removal or Git authority"
|
||||
);
|
||||
let restored = match backend.restore_worker(request.clone()) {
|
||||
WorkerExecutionSpawnResult::Connected {
|
||||
workdir_attachments,
|
||||
..
|
||||
} => workdir_attachments,
|
||||
other => panic!("cleanup-owner restore failed: {other:?}"),
|
||||
};
|
||||
assert_eq!(restored.len(), 1);
|
||||
assert_eq!(
|
||||
restored[0].working_directory.summary.status,
|
||||
WorkingDirectoryStatusKind::CleanupPending
|
||||
);
|
||||
assert_eq!(
|
||||
restored[0].working_directory.summary.cleanliness.as_deref(),
|
||||
Some("dirty")
|
||||
);
|
||||
assert_eq!(
|
||||
observed.lock().unwrap().as_slice(),
|
||||
&[checkout.clone(), checkout.clone()]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(checkout.join("README.md")).unwrap(),
|
||||
"owner recovery in progress"
|
||||
);
|
||||
assert!(backend.stop_worker(&request.worker_ref).is_accepted());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn cleanup_pending_owner_restore_rejects_incomplete_replaced_and_raced_checkouts() {
|
||||
for damage in ["missing_git", "replacement", "missing_after_preflight"] {
|
||||
let (root, backend, request, checkout, observed) = pending_owner_restore_fixture();
|
||||
if damage == "missing_after_preflight" {
|
||||
backend.preflight_restore(&request).unwrap();
|
||||
}
|
||||
if damage == "replacement" {
|
||||
fs::rename(&checkout, root.path().join("original-checkout")).unwrap();
|
||||
fs::create_dir(&checkout).unwrap();
|
||||
fs::create_dir(checkout.join(".git")).unwrap();
|
||||
} else {
|
||||
fs::remove_dir_all(checkout.join(".git")).unwrap();
|
||||
}
|
||||
let before = persisted_files(root.path());
|
||||
let error = backend.preflight_restore(&request).unwrap_err();
|
||||
assert_eq!(
|
||||
error.outcome,
|
||||
crate::execution::WorkerExecutionOutcome::Rejected,
|
||||
"{damage}"
|
||||
);
|
||||
assert!(
|
||||
error
|
||||
.message_or_default()
|
||||
.contains("working_directory_cleanup_"),
|
||||
"{error:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
backend.restore_worker(request),
|
||||
WorkerExecutionSpawnResult::Rejected(_)
|
||||
),
|
||||
"{damage}"
|
||||
);
|
||||
assert_eq!(
|
||||
observed.lock().unwrap().len(),
|
||||
1,
|
||||
"must not start a Controller on invalid checkout: {damage}"
|
||||
);
|
||||
assert_eq!(
|
||||
persisted_files(root.path()),
|
||||
before,
|
||||
"must preserve damaged/replaced checkout: {damage}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_spawn_receives_materialized_workspace_cwd_instead_of_source_repo() {
|
||||
let client = MockClient::new(simple_text_events());
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
|
||||
use crate::workspace_request::{
|
||||
RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient, RuntimeWorkspaceRequestError,
|
||||
};
|
||||
use worker::{
|
||||
WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution,
|
||||
WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
|
||||
WorkspaceServerOperation,
|
||||
WorkspaceBinaryRequest, WorkspaceBinaryResponse, WorkspaceClient, WorkspaceClientError,
|
||||
WorkspacePromptCatalogResolution, WorkspacePromptProjection, WorkspaceRequest,
|
||||
WorkspaceRequestMethod, WorkspaceResponse, WorkspaceServerOperation,
|
||||
};
|
||||
|
||||
use crate::auth::{
|
||||
@@ -381,6 +383,64 @@ impl RuntimeOwnedWorkspaceClient {
|
||||
self
|
||||
}
|
||||
|
||||
fn execute_binary_with_optional_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
let method = match request.method {
|
||||
WorkspaceRequestMethod::Get => reqwest::Method::GET,
|
||||
WorkspaceRequestMethod::Post => reqwest::Method::POST,
|
||||
WorkspaceRequestMethod::Put => reqwest::Method::PUT,
|
||||
WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
|
||||
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
|
||||
};
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
if request.body.is_some() {
|
||||
headers.insert(
|
||||
reqwest::header::CONTENT_TYPE,
|
||||
reqwest::header::HeaderValue::from_static("application/octet-stream"),
|
||||
);
|
||||
}
|
||||
let response = self
|
||||
.request_client
|
||||
.execute_binary_blocking(RuntimeWorkspaceRequest {
|
||||
method,
|
||||
path_and_query: request.path,
|
||||
body: request.body.unwrap_or_default(),
|
||||
headers,
|
||||
permission: WORKSPACE_REQUEST_PERMISSION.to_string(),
|
||||
worker_id: Some(self.worker_id.clone()),
|
||||
timeout,
|
||||
max_response_bytes: request.max_response_bytes,
|
||||
})
|
||||
.map_err(|error| {
|
||||
// Never expose request URLs, signed proofs, or binary bodies in transport errors.
|
||||
let message = match error {
|
||||
RuntimeWorkspaceRequestError::InvalidRequest(_) => {
|
||||
"invalid Workspace binary request"
|
||||
}
|
||||
RuntimeWorkspaceRequestError::Sign(_) => {
|
||||
"Workspace binary request authorization failed"
|
||||
}
|
||||
RuntimeWorkspaceRequestError::ResponseTooLarge { .. } => {
|
||||
"Workspace binary response exceeded limit"
|
||||
}
|
||||
RuntimeWorkspaceRequestError::Transport { timeout: true, .. } => {
|
||||
"Workspace binary request timed out"
|
||||
}
|
||||
RuntimeWorkspaceRequestError::Transport { .. } => {
|
||||
"Workspace binary request failed"
|
||||
}
|
||||
};
|
||||
WorkspaceClientError::Request(message.to_string())
|
||||
})?;
|
||||
Ok(WorkspaceBinaryResponse {
|
||||
status: response.status.as_u16(),
|
||||
body: response.body,
|
||||
})
|
||||
}
|
||||
|
||||
fn execute_with_optional_timeout(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
@@ -481,6 +541,21 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
self.execute_with_optional_timeout(request, self.request_timeout)
|
||||
}
|
||||
|
||||
fn execute_binary(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.execute_binary_with_optional_timeout(request, self.request_timeout)
|
||||
}
|
||||
|
||||
fn execute_binary_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.execute_binary_with_optional_timeout(request, Some(timeout))
|
||||
}
|
||||
|
||||
fn execute_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
@@ -789,6 +864,216 @@ mod tests {
|
||||
request_body_digest, verify_worker_mutation_source_proof,
|
||||
};
|
||||
|
||||
fn binary_loopback(response: Vec<u8>) -> (String, std::thread::JoinHandle<(String, Vec<u8>)>) {
|
||||
use std::io::{Read, Write};
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut head = Vec::new();
|
||||
while !head.ends_with(b"\r\n\r\n") {
|
||||
let mut byte = [0];
|
||||
stream.read_exact(&mut byte).unwrap();
|
||||
head.push(byte[0]);
|
||||
assert!(head.len() < 16 * 1024, "request headers too large");
|
||||
}
|
||||
let head = String::from_utf8(head).unwrap();
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let mut body = vec![0; length];
|
||||
stream.read_exact(&mut body).unwrap();
|
||||
// Oversize-response tests may close the connection before all bytes are sent.
|
||||
let _ = stream.write_all(&response);
|
||||
(head, body)
|
||||
});
|
||||
(base_url, server)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_roundtrip_signs_exact_query_method_worker_and_raw_body() {
|
||||
use crate::auth::{RuntimeRequestSourceExpectation, verify_runtime_request_source};
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
|
||||
for (method, wire_method, body, reply) in [
|
||||
(
|
||||
WorkspaceRequestMethod::Get,
|
||||
"GET",
|
||||
None,
|
||||
vec![0, 255, 128, 13],
|
||||
),
|
||||
(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"POST",
|
||||
Some(vec![0, 255, 128, 13, 10]),
|
||||
vec![255, 0],
|
||||
),
|
||||
(
|
||||
WorkspaceRequestMethod::Put,
|
||||
"PUT",
|
||||
Some(vec![255; 16 * 1024 * 1024]),
|
||||
vec![0],
|
||||
),
|
||||
] {
|
||||
let path = "/api/w/workspace-a/drive/entries/a/content?offset=0&limit=65536&name=a%2Fb";
|
||||
let mut response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
reply.len()
|
||||
)
|
||||
.into_bytes();
|
||||
response.extend_from_slice(&reply);
|
||||
let (base_url, server) = binary_loopback(response);
|
||||
let client =
|
||||
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a")
|
||||
.with_runtime_request_source(&identity, "server-a");
|
||||
let result = client
|
||||
.execute_binary_with_timeout(
|
||||
WorkspaceBinaryRequest {
|
||||
method,
|
||||
path: path.into(),
|
||||
body: body.clone(),
|
||||
max_response_bytes: reply.len(),
|
||||
},
|
||||
Duration::from_secs(5),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.status, 200);
|
||||
assert_eq!(result.body, reply);
|
||||
let (head, received) = server.join().unwrap();
|
||||
assert_eq!(received, body.unwrap_or_default());
|
||||
assert!(head.starts_with(&format!("{wire_method} {path} HTTP/1.1\r\n")));
|
||||
let header = |name: &str| {
|
||||
head.lines().find_map(|line| {
|
||||
let (key, value) = line.split_once(':')?;
|
||||
key.eq_ignore_ascii_case(name).then(|| value.trim())
|
||||
})
|
||||
};
|
||||
assert_eq!(header("x-yoi-runtime-id"), Some("runtime-a"));
|
||||
assert_eq!(header("x-yoi-worker-id"), Some("worker-a"));
|
||||
assert_eq!(header("authorization"), None);
|
||||
if method != WorkspaceRequestMethod::Get {
|
||||
assert_eq!(header("content-type"), Some("application/octet-stream"));
|
||||
}
|
||||
let digest = request_body_digest(&received);
|
||||
let expected = RuntimeRequestSourceExpectation {
|
||||
identity_id: "runtime-a",
|
||||
audience: "server-a",
|
||||
workspace_id: "workspace-a",
|
||||
worker_id: Some("worker-a"),
|
||||
permission: WORKSPACE_REQUEST_PERMISSION,
|
||||
method: wire_method,
|
||||
path,
|
||||
body_digest: &digest,
|
||||
now_unix: unix_now_seconds() as i64,
|
||||
};
|
||||
let token = header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER).unwrap();
|
||||
verify_runtime_request_source(token, &identity.public_key, &expected).unwrap();
|
||||
let altered = RuntimeRequestSourceExpectation {
|
||||
body_digest: &request_body_digest(b"altered"),
|
||||
..expected.clone()
|
||||
};
|
||||
assert!(verify_runtime_request_source(token, &identity.public_key, &altered).is_err());
|
||||
let altered = RuntimeRequestSourceExpectation {
|
||||
path: "/api/w/workspace-a/drive/entries/a/content?offset=1",
|
||||
..expected
|
||||
};
|
||||
assert!(verify_runtime_request_source(token, &identity.public_key, &altered).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_responses_enforce_declared_and_streamed_limits_with_fixed_errors() {
|
||||
for response in [
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nabcde".to_vec(),
|
||||
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n2\r\nab\r\n3\r\ncde\r\n0\r\n\r\n".to_vec(),
|
||||
b"HTTP/1.1 500 Error\r\nConnection: close\r\n\r\nabcde".to_vec(),
|
||||
] {
|
||||
let (base_url, server) = binary_loopback(response);
|
||||
let client = RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a");
|
||||
let error = client.execute_binary(WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Get,
|
||||
path: "/api/w/workspace-a/drive/entries/a/content?private=secret".into(),
|
||||
body: None, max_response_bytes: 4,
|
||||
}).unwrap_err();
|
||||
server.join().unwrap();
|
||||
assert_eq!(error.to_string(), "workspace request failed: Workspace binary response exceeded limit");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_redirect_returns_status_without_forwarding_authentication() {
|
||||
let (base_url, server) = binary_loopback(b"HTTP/1.1 307 Temporary Redirect\r\nLocation: http://127.0.0.1:1/private\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec());
|
||||
let client =
|
||||
RuntimeOwnedWorkspaceClient::new("workspace-a", base_url, "runtime-a", "worker-a");
|
||||
let result = client
|
||||
.execute_binary(WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Post,
|
||||
path: "/api/w/workspace-a/drive/upload".into(),
|
||||
body: Some(vec![0, 255]),
|
||||
max_response_bytes: 0,
|
||||
})
|
||||
.unwrap();
|
||||
server.join().unwrap();
|
||||
assert_eq!(result.status, 307);
|
||||
assert!(result.body.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_invalid_or_normalized_targets_fail_with_fixed_errors_before_http() {
|
||||
let client = RuntimeOwnedWorkspaceClient::new(
|
||||
"workspace-a",
|
||||
"http://127.0.0.1:1",
|
||||
"runtime-a",
|
||||
"worker-a",
|
||||
);
|
||||
for path in [
|
||||
"https://example.com/secret",
|
||||
"//example.com/secret",
|
||||
"/a/../secret",
|
||||
"/a#secret",
|
||||
"/a\\secret",
|
||||
"/a b",
|
||||
] {
|
||||
let error = client
|
||||
.execute_binary(WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Get,
|
||||
path: path.into(),
|
||||
body: None,
|
||||
max_response_bytes: 4,
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"workspace request failed: invalid Workspace binary request"
|
||||
);
|
||||
}
|
||||
let error = client
|
||||
.execute_binary_with_timeout(
|
||||
WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Post,
|
||||
path: "/secret?private=value".into(),
|
||||
body: Some(vec![255]),
|
||||
max_response_bytes: 4,
|
||||
},
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"workspace request failed: Workspace binary request failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_prompt_projection_uses_the_shared_runtime_cache_without_http() {
|
||||
let cache = Arc::new(WorkspacePromptProjectionCache::default());
|
||||
|
||||
@@ -22,6 +22,11 @@ use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use workdir::WorkdirSessionResource;
|
||||
|
||||
mod cleanup;
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
pub(crate) use cleanup::tests::fail_once as fail_workdir_cleanup_once;
|
||||
|
||||
const CHECKOUT_DIR: &str = "checkout";
|
||||
const MATERIALIZATION_RECORD: &str = "materialization.json";
|
||||
const REPOSITORY_ACCESS_DIR: &str = ".repository-access";
|
||||
@@ -127,7 +132,11 @@ impl WorkingDirectoryBinding {
|
||||
working_directory.status = WorkingDirectoryStatusKind::Corrupted;
|
||||
}
|
||||
let mut summary = working_directory.status_summary();
|
||||
summary.cleanliness = if summary.status == WorkingDirectoryStatusKind::Active {
|
||||
summary.cleanliness = if matches!(
|
||||
summary.status,
|
||||
WorkingDirectoryStatusKind::Active | WorkingDirectoryStatusKind::CleanupPending
|
||||
) && binding_paths_are_available(self)
|
||||
{
|
||||
let (current_selector, current_ref, current_tree) = binding_current_revision(self);
|
||||
summary.current_selector = current_selector;
|
||||
summary.current_ref = current_ref;
|
||||
@@ -201,6 +210,24 @@ pub trait WorkingDirectoryMaterializer: Send + Sync + 'static {
|
||||
relative_cwd: Option<&str>,
|
||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic>;
|
||||
|
||||
/// Read-only bind validation: do not consume Repository credentials or open
|
||||
/// session resources during restore preflight. Providers must opt in with
|
||||
/// current usable-checkout validation to support non-active attachments.
|
||||
fn preflight_bind_working_directory(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
_relative_cwd: Option<&str>,
|
||||
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||
let current = self.working_directory_status(working_directory_id)?;
|
||||
if current.summary.status != WorkingDirectoryStatusKind::Active {
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_not_active",
|
||||
"Persisted Workdir is not active and its provider cannot validate recovery binding",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list_working_directories(
|
||||
&self,
|
||||
) -> Result<Vec<WorkingDirectoryStatus>, WorkingDirectoryDiagnostic>;
|
||||
@@ -627,6 +654,29 @@ impl RuntimeGitMaterializer {
|
||||
})
|
||||
}
|
||||
|
||||
fn checked_binding(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
relative_cwd: Option<&str>,
|
||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
||||
validate_working_directory_id(working_directory_id)?;
|
||||
let binding = self.read_binding(working_directory_id)?;
|
||||
if !matches!(
|
||||
binding.working_directory.status,
|
||||
WorkingDirectoryStatusKind::Active | WorkingDirectoryStatusKind::CleanupPending
|
||||
) {
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_not_active",
|
||||
"working directory working_directory is not active",
|
||||
));
|
||||
}
|
||||
if binding.working_directory.status == WorkingDirectoryStatusKind::CleanupPending {
|
||||
cleanup::validate_recovery_binding(self, working_directory_id, &binding)?;
|
||||
}
|
||||
let cwd = validate_relative_cwd(binding.root(), relative_cwd)?;
|
||||
Ok(WorkingDirectoryBinding { cwd, ..binding })
|
||||
}
|
||||
|
||||
fn read_binding(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
@@ -1137,17 +1187,17 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
|
||||
working_directory_id: &str,
|
||||
relative_cwd: Option<&str>,
|
||||
) -> Result<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> {
|
||||
validate_working_directory_id(working_directory_id)?;
|
||||
let binding = self.read_binding(working_directory_id)?;
|
||||
if binding.working_directory.status != WorkingDirectoryStatusKind::Active {
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_not_active",
|
||||
"working directory working_directory is not active",
|
||||
));
|
||||
}
|
||||
let binding = self.bind_repository_access(working_directory_id, binding)?;
|
||||
let cwd = validate_relative_cwd(binding.root(), relative_cwd)?;
|
||||
Ok(WorkingDirectoryBinding { cwd, ..binding })
|
||||
let binding = self.checked_binding(working_directory_id, relative_cwd)?;
|
||||
self.bind_repository_access(working_directory_id, binding)
|
||||
}
|
||||
|
||||
fn preflight_bind_working_directory(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
relative_cwd: Option<&str>,
|
||||
) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||
self.checked_binding(working_directory_id, relative_cwd)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn list_working_directories(
|
||||
@@ -1178,6 +1228,10 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(status) = cleanup::remaining_status(self, &working_directory_id) {
|
||||
statuses.push(status);
|
||||
continue;
|
||||
}
|
||||
match self.read_binding(&working_directory_id) {
|
||||
Ok(binding) => statuses.push(binding.status()),
|
||||
Err(_) => statuses.push(self.corrupted_status(&working_directory_id)),
|
||||
@@ -1197,11 +1251,24 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
|
||||
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||
validate_working_directory_id(working_directory_id)?;
|
||||
let working_directory_root = self.working_directory_root(working_directory_id);
|
||||
if !working_directory_root.exists() {
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_not_found",
|
||||
"working directory working_directory was not found",
|
||||
));
|
||||
match fs::symlink_metadata(&working_directory_root) {
|
||||
Ok(_) => {}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_not_found",
|
||||
"Runtime working directory was not found",
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(cleanup::os_failure(
|
||||
working_directory_id,
|
||||
"lookup_cleanup_root",
|
||||
error,
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(status) = cleanup::remaining_status(self, working_directory_id) {
|
||||
return Ok(status);
|
||||
}
|
||||
match self.read_binding(working_directory_id) {
|
||||
Ok(binding) => Ok(binding.status()),
|
||||
@@ -1214,80 +1281,13 @@ impl WorkingDirectoryMaterializer for RuntimeGitMaterializer {
|
||||
working_directory_id: &str,
|
||||
) -> Result<WorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
|
||||
validate_working_directory_id(working_directory_id)?;
|
||||
let status = self.working_directory_status(working_directory_id)?;
|
||||
if status.summary.status == WorkingDirectoryStatusKind::Corrupted {
|
||||
let working_directory_root = self.working_directory_root(working_directory_id);
|
||||
if working_directory_root.exists() {
|
||||
fs::remove_dir_all(&working_directory_root).map_err(|_| {
|
||||
WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_corrupted_cleanup_failed",
|
||||
"failed to remove corrupted working directory; backend-private path details were omitted",
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let mut summary = status.summary;
|
||||
summary.status = WorkingDirectoryStatusKind::NotFound;
|
||||
return Ok(WorkingDirectoryStatus { summary });
|
||||
}
|
||||
let binding = self.read_binding(working_directory_id)?;
|
||||
self.cleanup(&binding)?;
|
||||
let mut summary = binding.working_directory.status_summary();
|
||||
summary.status = WorkingDirectoryStatusKind::NotFound;
|
||||
summary.cleanliness = Some("unknown".to_string());
|
||||
if binding.working_directory_root.exists() {
|
||||
fs::remove_dir_all(&binding.working_directory_root).map_err(|_| {
|
||||
WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_record_cleanup_failed",
|
||||
"failed to remove working directory record; backend-private path details were omitted",
|
||||
)
|
||||
})?;
|
||||
}
|
||||
Ok(WorkingDirectoryStatus { summary })
|
||||
cleanup::remove(self, working_directory_id)
|
||||
}
|
||||
|
||||
fn cleanup(&self, binding: &WorkingDirectoryBinding) -> Result<(), WorkingDirectoryDiagnostic> {
|
||||
let mut working_directory = binding.working_directory.clone();
|
||||
let working_directory_root = binding.working_directory_root.canonicalize().map_err(|_| {
|
||||
WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_cleanup_target_invalid",
|
||||
"working directory working directory root is unavailable; backend-private path details were omitted",
|
||||
)
|
||||
})?;
|
||||
let root = binding.root.canonicalize().map_err(|_| {
|
||||
WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_cleanup_target_invalid",
|
||||
"working directory root is unavailable; backend-private path details were omitted",
|
||||
)
|
||||
})?;
|
||||
if !root.starts_with(&working_directory_root) {
|
||||
return Err(WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_cleanup_escape_rejected",
|
||||
"working directory cleanup target is outside the working directory root",
|
||||
));
|
||||
}
|
||||
let remove_result = fs::remove_dir_all(&working_directory_root).map_err(|_| {
|
||||
WorkingDirectoryDiagnostic::new(
|
||||
"working_directory_cleanup_failed",
|
||||
"failed to remove working directory; backend-private path details were omitted",
|
||||
)
|
||||
});
|
||||
if remove_result.is_err() {
|
||||
working_directory.status = WorkingDirectoryStatusKind::CleanupPending;
|
||||
let updated = WorkingDirectoryBinding {
|
||||
working_directory,
|
||||
root: binding.root.clone(),
|
||||
cwd: binding.cwd.clone(),
|
||||
working_directory_root: binding.working_directory_root.clone(),
|
||||
command_environment: BTreeMap::new(),
|
||||
session_resources: Vec::new(),
|
||||
};
|
||||
let _ = self.write_record(&updated);
|
||||
} else {
|
||||
let _ = self
|
||||
.repository_access
|
||||
.remove_pending(&binding.working_directory.id);
|
||||
}
|
||||
remove_result
|
||||
// Reload current authority rather than trusting a stale in-memory binding.
|
||||
self.cleanup_working_directory(&binding.working_directory.id)
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2826,6 +2826,7 @@ where
|
||||
I: IntoIterator<Item = &'a str>,
|
||||
{
|
||||
let output = Command::new("git")
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.arg("-C")
|
||||
.arg(repository_path)
|
||||
.args(args)
|
||||
@@ -3094,7 +3095,7 @@ mod tests {
|
||||
assert!(scheduler_inner.upgrade().is_none());
|
||||
}
|
||||
|
||||
fn git(path: &Path, args: &[&str]) {
|
||||
pub(super) fn git(path: &Path, args: &[&str]) {
|
||||
let status = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(path)
|
||||
@@ -3104,7 +3105,7 @@ mod tests {
|
||||
assert!(status.success(), "git {:?} failed", args);
|
||||
}
|
||||
|
||||
fn create_clean_repo() -> tempfile::TempDir {
|
||||
pub(super) fn create_clean_repo() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
git(dir.path(), &["init"]);
|
||||
git(
|
||||
@@ -3118,7 +3119,7 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
fn request(repo: &Path) -> WorkingDirectoryRequest {
|
||||
pub(super) fn request(repo: &Path) -> WorkingDirectoryRequest {
|
||||
WorkingDirectoryRequest {
|
||||
repository: WorkingDirectoryRepository {
|
||||
id: "repo-main".to_string(),
|
||||
@@ -3678,6 +3679,8 @@ mod tests {
|
||||
.evidence
|
||||
.host_trust_revision = Some(1);
|
||||
materializer.write_record(&ssh_backed_binding).unwrap();
|
||||
#[cfg(target_os = "linux")]
|
||||
fail_workdir_cleanup_once(&materializer, &id);
|
||||
let mut session_materialization = initial_materialization.clone();
|
||||
let session_delivery_expiry = repository_access_now_epoch_seconds() + 1;
|
||||
session_materialization
|
||||
@@ -3691,6 +3694,17 @@ mod tests {
|
||||
materialization: session_materialization,
|
||||
})
|
||||
.unwrap();
|
||||
// Restore preflight must validate without consuming this one-shot SSH
|
||||
// delivery or creating agent/session resources needed by actual bind.
|
||||
let record_before =
|
||||
fs::read(runtime_root.path().join(&id).join(MATERIALIZATION_RECORD)).unwrap();
|
||||
materializer
|
||||
.preflight_bind_working_directory(&id, None)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fs::read(runtime_root.path().join(&id).join(MATERIALIZATION_RECORD)).unwrap(),
|
||||
record_before
|
||||
);
|
||||
assert_eq!(materializer.list_working_directories().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
fs::read_dir(runtime_root.path().join(".repository-agents"))
|
||||
@@ -3916,6 +3930,22 @@ mod tests {
|
||||
restored.bind_working_directory(&id, None).unwrap_err().code,
|
||||
"working_directory_remote_repository_access_required"
|
||||
);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Real SSH reauthorization updated access evidence repeatedly, but
|
||||
// did not replace this clean materialization or require manual recovery.
|
||||
assert_eq!(
|
||||
restored
|
||||
.working_directory_status(&id)
|
||||
.unwrap()
|
||||
.summary
|
||||
.cleanliness
|
||||
.as_deref(),
|
||||
Some("clean")
|
||||
);
|
||||
restored.cleanup_working_directory(&id).unwrap();
|
||||
assert!(!restored.working_directory_root(&id).exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4393,7 +4423,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_working_directory_record_can_be_removed() {
|
||||
fn corrupted_working_directory_without_removal_authority_is_retained() {
|
||||
let runtime_root = tempfile::tempdir().unwrap();
|
||||
let materializer = RuntimeGitMaterializer::new(runtime_root.path());
|
||||
let working_directory_id = "workdir-corrupted";
|
||||
@@ -4406,11 +4436,11 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(status.summary.status, WorkingDirectoryStatusKind::Corrupted);
|
||||
|
||||
let removed = materializer
|
||||
let error = materializer
|
||||
.cleanup_working_directory(working_directory_id)
|
||||
.unwrap();
|
||||
assert_eq!(removed.summary.status, WorkingDirectoryStatusKind::NotFound);
|
||||
assert!(!root.exists());
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "working_directory_cleanup_ownership_unknown");
|
||||
assert!(root.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -255,7 +255,7 @@ impl RuntimeWorkspaceRequestClient {
|
||||
request: RuntimeWorkspaceRequest,
|
||||
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
|
||||
let client = self.clone();
|
||||
std::thread::spawn(move || client.execute_blocking_inner(request))
|
||||
std::thread::spawn(move || client.execute_blocking_inner(request, true))
|
||||
.join()
|
||||
.map_err(|_| RuntimeWorkspaceRequestError::Transport {
|
||||
message: "Workspace request thread panicked".to_string(),
|
||||
@@ -263,15 +263,45 @@ impl RuntimeWorkspaceRequestClient {
|
||||
})?
|
||||
}
|
||||
|
||||
fn execute_blocking_inner(
|
||||
/// Binary requests must retain their exact target and never follow redirects.
|
||||
pub(crate) fn execute_binary_blocking(
|
||||
&self,
|
||||
request: RuntimeWorkspaceRequest,
|
||||
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
|
||||
let client = self.clone();
|
||||
std::thread::spawn(move || client.execute_blocking_inner(request, false))
|
||||
.join()
|
||||
.map_err(|_| RuntimeWorkspaceRequestError::Transport {
|
||||
message: "Workspace binary request thread panicked".to_string(),
|
||||
timeout: false,
|
||||
})?
|
||||
}
|
||||
|
||||
fn execute_blocking_inner(
|
||||
&self,
|
||||
request: RuntimeWorkspaceRequest,
|
||||
follow_redirects: bool,
|
||||
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
|
||||
let prepared = self.prepare(&request)?;
|
||||
if !follow_redirects {
|
||||
let mut target = prepared.url.path().to_string();
|
||||
if let Some(query) = prepared.url.query() {
|
||||
target.push('?');
|
||||
target.push_str(query);
|
||||
}
|
||||
if prepared.url.fragment().is_some() || target != request.path_and_query {
|
||||
return Err(RuntimeWorkspaceRequestError::InvalidRequest(
|
||||
"binary request target must be exact".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
// The blocking client defaults to 30 seconds. Apply None explicitly so
|
||||
// provider-owned command waits are not cut short by an HTTP deadline.
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(request.timeout)
|
||||
let mut client_builder = reqwest::blocking::Client::builder().timeout(request.timeout);
|
||||
if !follow_redirects {
|
||||
client_builder = client_builder.redirect(reqwest::redirect::Policy::none());
|
||||
}
|
||||
let client = client_builder
|
||||
.build()
|
||||
.map_err(RuntimeWorkspaceRequestError::transport)?;
|
||||
let mut builder = client
|
||||
|
||||
@@ -54,6 +54,7 @@ base64 = { workspace = true }
|
||||
wip-client = { workspace = true }
|
||||
wip-http = { workspace = true }
|
||||
wip-protocol = { workspace = true }
|
||||
wip-text-view = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
axum = { workspace = true }
|
||||
|
||||
@@ -90,7 +90,7 @@ manifests of already-running Workers.
|
||||
|
||||
Public integration seams: `WorkspaceConfigFeature::for_workspace`,
|
||||
`workspace_config::wip::mount_workspace_config_wip`, and
|
||||
`WipRuntime::from_mounts` plus `discover`/`inspect`/`call`. Worker tests exercise the
|
||||
`WipRuntime::from_mounts` plus `tree`/`inspect`/`invoke`. Worker tests exercise the
|
||||
real Feature registry, native mount and WIP Client/Host through a typed in-process
|
||||
contract router: deep discovery, slash/native attach, read/edit/write,
|
||||
create/delete, atomic multi-source updates, grant restrictions/revocation,
|
||||
|
||||
@@ -83,6 +83,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_read_input_tokens,
|
||||
..
|
||||
} => {
|
||||
println!(
|
||||
"[usage] in={} (cache_read={}) out={}",
|
||||
|
||||
+274
-152
@@ -11,11 +11,12 @@ use manifest::{ToolPermissionAction, ToolPermissionConfig};
|
||||
use serde_json::{Value as Json, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use wip_protocol::{
|
||||
Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration,
|
||||
ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value,
|
||||
Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object,
|
||||
OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
|
||||
ReturnDeclaration, TypeExpr, Value,
|
||||
};
|
||||
use workdir::{
|
||||
CheckoutObservation, EntryKind, ListRequest, WorkdirPath, WorkdirSessionCapability as Cap,
|
||||
CheckoutObservation, EntryKind, WorkdirPath, WorkdirSessionCapability as Cap,
|
||||
WorkdirSessionRouter,
|
||||
};
|
||||
|
||||
@@ -23,8 +24,8 @@ use crate::feature::builtin::manage_workdir::wip::{decode_identity, encode_ident
|
||||
use crate::permission::permission_action_for;
|
||||
use crate::wip::{
|
||||
WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, WipSubtreeMount, WipSubtreeProvider,
|
||||
json_to_wip, wip_to_json,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, WipPublication, WipSubtreeMount,
|
||||
WipSubtreeProvider, json_to_wip, wip_to_json,
|
||||
};
|
||||
|
||||
const ROOT: &str = "/checkouts";
|
||||
@@ -126,11 +127,15 @@ impl Provider {
|
||||
}
|
||||
self.project(
|
||||
ROOT,
|
||||
descriptor(vec![operation(
|
||||
"list",
|
||||
"List currently accessible checkouts",
|
||||
vec![],
|
||||
)]),
|
||||
descriptor(vec![{
|
||||
let mut op = operation(
|
||||
"list",
|
||||
"List accessible checkout entrances (not filesystem contents)",
|
||||
vec![],
|
||||
);
|
||||
op.returns.r#type = TypeExpr::Json;
|
||||
op
|
||||
}]),
|
||||
state.finalize().to_vec(),
|
||||
"collection",
|
||||
Arc::new(Collection {
|
||||
@@ -146,8 +151,10 @@ impl Provider {
|
||||
kind: &str,
|
||||
handler: Arc<dyn WipOperationHandler>,
|
||||
) -> WipProjection {
|
||||
let encoded: String = path.as_bytes().iter().map(|b| format!("{b:02x}")).collect();
|
||||
let interface = format!("yoi.checkout/{}/{kind}/v1/@/{encoded}", self.incarnation);
|
||||
let interface = crate::wip::contextual_reference(
|
||||
&format!("yoi.checkout/{}/{kind}/v1", self.incarnation),
|
||||
path,
|
||||
);
|
||||
let interface_validator = Sha256::digest(format!("{descriptor:?}")).to_vec();
|
||||
WipProjection {
|
||||
route: path.into(),
|
||||
@@ -167,8 +174,14 @@ impl Provider {
|
||||
}
|
||||
}
|
||||
async fn node(self: &Arc<Self>, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||
Ok(self.publication_inner(path).await?.map(|p| p.projection))
|
||||
}
|
||||
async fn publication_inner(
|
||||
self: &Arc<Self>,
|
||||
path: &str,
|
||||
) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
if path == ROOT {
|
||||
return Ok(Some(self.collection()));
|
||||
return WipPublication::self_scoped(self.collection()).map(Some);
|
||||
}
|
||||
let Some((alias, target)) = self.parse(path) else {
|
||||
return Ok(None);
|
||||
@@ -200,39 +213,86 @@ impl Provider {
|
||||
if observation.kind == EntryKind::File && !self.allowed("Read", &permission_input) {
|
||||
return Ok(None);
|
||||
}
|
||||
let workdir = selected.session.workdir().id().as_str().to_owned();
|
||||
let root = checkout_root(&alias);
|
||||
// The router guard pins this connection across both observations. Root
|
||||
// lifetime is attachment lifetime, not directory-content validator state.
|
||||
let scope = if target.is_root() {
|
||||
None
|
||||
} else {
|
||||
let root_observation =
|
||||
match selected.session.checkout_observe(WorkdirPath::root()).await {
|
||||
Ok(o) => o,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
self.entry_projection(
|
||||
&root,
|
||||
&alias,
|
||||
selected.generation,
|
||||
&workdir,
|
||||
WorkdirPath::root(),
|
||||
root_observation,
|
||||
)
|
||||
};
|
||||
let Some(projection) = self.entry_projection(
|
||||
path,
|
||||
&alias,
|
||||
selected.generation,
|
||||
&workdir,
|
||||
target,
|
||||
observation,
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if path == root {
|
||||
return WipPublication::self_scoped(projection).map(Some);
|
||||
}
|
||||
let Some(scope) = scope else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(WipPublication {
|
||||
projection,
|
||||
scope: Some(scope.object),
|
||||
}))
|
||||
}
|
||||
fn entry_projection(
|
||||
self: &Arc<Self>,
|
||||
path: &str,
|
||||
alias: &str,
|
||||
generation: u64,
|
||||
workdir: &str,
|
||||
target: WorkdirPath,
|
||||
observation: CheckoutObservation,
|
||||
) -> Option<WipProjection> {
|
||||
let ops = self.operations(&observation);
|
||||
if ops.is_empty() {
|
||||
return Ok(None);
|
||||
return None;
|
||||
}
|
||||
let kind = if observation.kind == EntryKind::File {
|
||||
"file"
|
||||
} else {
|
||||
"directory"
|
||||
};
|
||||
let workdir = selected.session.workdir().id().as_str().to_owned();
|
||||
let validator = self.wrap_validator(
|
||||
&alias,
|
||||
selected.generation,
|
||||
&workdir,
|
||||
&observation.validator,
|
||||
);
|
||||
let validator = self.wrap_validator(alias, generation, workdir, &observation.validator);
|
||||
let handler = Arc::new(FileHandler {
|
||||
provider: self.clone(),
|
||||
alias,
|
||||
generation: selected.generation,
|
||||
workdir,
|
||||
alias: alias.into(),
|
||||
generation,
|
||||
workdir: workdir.into(),
|
||||
target,
|
||||
observation,
|
||||
});
|
||||
let family = format!("{kind}-g{}", selected.generation);
|
||||
Ok(Some(self.project(
|
||||
path,
|
||||
descriptor(ops),
|
||||
validator,
|
||||
&family,
|
||||
handler,
|
||||
)))
|
||||
let family = format!("{kind}-g{generation}");
|
||||
let mut projection = self.project(path, descriptor(ops), validator, &family, handler);
|
||||
// Keep path-qualified local names (permissions/capabilities may differ
|
||||
// per target) while binding every content Interface to its live entrance.
|
||||
projection.interface.scope = checkout_root(alias);
|
||||
projection.interface.name.push_str("#target/");
|
||||
projection.interface.name.push_str(&encode_identity(path));
|
||||
projection.object.interfaces = vec![projection.interface.clone()];
|
||||
Some(projection)
|
||||
}
|
||||
|
||||
fn operations(&self, o: &CheckoutObservation) -> Vec<OperationDeclaration> {
|
||||
let mut ops = Vec::new();
|
||||
let has = |cap, name| o.capabilities.supports(cap) && self.may_allow(name);
|
||||
@@ -259,6 +319,13 @@ impl Provider {
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if has(Cap::Read, "List") {
|
||||
ops.push(operation(
|
||||
"list",
|
||||
"List direct contents, directories first then lexical path. Bounded live pages, not a snapshot; pass returned after to continue. Limit defaults to 100, maximum 1000. List permission and provider Read/enumeration authority apply. Includes hidden/ignored names per provider List policy, unlike Glob/Grep.",
|
||||
vec![parameter("limit", false, TypeExpr::Integer), parameter("after", false, cursor_type())],
|
||||
));
|
||||
}
|
||||
if has(Cap::Glob, "Glob") {
|
||||
ops.push(operation(
|
||||
"glob",
|
||||
@@ -286,7 +353,16 @@ impl Provider {
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for Arc<Provider> {
|
||||
async fn projection(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||
fn interface_target(&self, reference: &wip_protocol::InterfaceReference) -> Option<String> {
|
||||
if reference.scope == ROOT {
|
||||
return Some(ROOT.into());
|
||||
}
|
||||
let (_, encoded) = reference.name.rsplit_once("#target/")?;
|
||||
let path = decode_identity(encoded)?;
|
||||
let (alias, _) = self.parse(&path)?;
|
||||
(reference.scope == checkout_root(&alias)).then_some(path)
|
||||
}
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
@@ -296,7 +372,7 @@ impl WipSubtreeProvider for Arc<Provider> {
|
||||
)
|
||||
})?
|
||||
.map_err(|_| error(ProtocolErrorCode::Internal, "checkout unavailable"))?;
|
||||
tokio::time::timeout(self.deadline, self.node(path))
|
||||
tokio::time::timeout(self.deadline, self.publication_inner(path))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
error(
|
||||
@@ -351,70 +427,9 @@ impl Provider {
|
||||
}
|
||||
return Ok(paths);
|
||||
}
|
||||
let Some((alias, target)) = self.parse(path) else {
|
||||
return Err(error(ProtocolErrorCode::NotFound, "checkout not published"));
|
||||
};
|
||||
let selected = self
|
||||
.router
|
||||
.resolve(Some(&alias))
|
||||
.map_err(|_| error(ProtocolErrorCode::NotFound, "attachment expired"))?;
|
||||
let o = selected
|
||||
.session
|
||||
.checkout_observe(target.clone())
|
||||
.await
|
||||
.map_err(|_| error(ProtocolErrorCode::NotFound, "checkout not published"))?;
|
||||
if o.kind != EntryKind::Directory {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if !self.allowed(
|
||||
"Glob",
|
||||
&json!({"target_workdir":alias,"path":target.as_str(),"pattern":"*"}),
|
||||
) && !self.allowed(
|
||||
"Grep",
|
||||
&json!({"target_workdir":alias,"path":target.as_str(),"pattern":""}),
|
||||
) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let listing = selected
|
||||
.session
|
||||
.checkout_search(workdir::CheckoutSearchRequest::new(
|
||||
workdir::CheckoutSearchOperation::List(ListRequest {
|
||||
path: target,
|
||||
limit: MAX_NODES + 1,
|
||||
}),
|
||||
))
|
||||
.await
|
||||
.map_err(|_| error(ProtocolErrorCode::Internal, "checkout listing unavailable"))?;
|
||||
let workdir::CheckoutSearchResult::List(listing) = listing else {
|
||||
return Err(error(
|
||||
ProtocolErrorCode::Internal,
|
||||
"mismatched checkout listing",
|
||||
));
|
||||
};
|
||||
if listing.truncated || listing.entries.len() > MAX_NODES {
|
||||
return Err(error(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"directory observation exceeds node bound",
|
||||
));
|
||||
}
|
||||
let mut paths = Vec::new();
|
||||
for entry in listing.entries {
|
||||
let p = object_path(&alias, &entry.path);
|
||||
if self.node(&p).await?.is_some() {
|
||||
paths.push(p);
|
||||
}
|
||||
}
|
||||
if !self
|
||||
.router
|
||||
.resolve(Some(&alias))
|
||||
.is_ok_and(|current| current.generation == selected.generation)
|
||||
{
|
||||
return Err(error(
|
||||
ProtocolErrorCode::ValidatorMismatch,
|
||||
"attachment changed",
|
||||
));
|
||||
}
|
||||
Ok(paths)
|
||||
// Contents are addressable, but never indexable. Host observe already
|
||||
// resolves the requested Object; no provider content List is needed.
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,6 +503,76 @@ impl FileHandler {
|
||||
))
|
||||
}
|
||||
}
|
||||
/// Classify post-success publication failures separately from pre-effect
|
||||
/// provider errors; result coordinates remain lazy for all operations.
|
||||
fn publish_result(
|
||||
&self,
|
||||
tool: &str,
|
||||
result: tools::CheckoutToolOutput,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
// Coordinates are not observations. Never prefetch unselected results;
|
||||
// a vanished entry cannot fail a successful discovery Operation.
|
||||
self.check_connection().map_err(|error| {
|
||||
if matches!(tool, "Write" | "Edit" | "Create") {
|
||||
// The provider has already succeeded: connection loss while
|
||||
// publishing cannot prove that a committed effect was rejected.
|
||||
WipOperationError::OutcomeUnknown(
|
||||
"completed file operation lost its attachment before result publication".into(),
|
||||
)
|
||||
} else {
|
||||
error
|
||||
}
|
||||
})?;
|
||||
let mut fields = BTreeMap::from([("summary".into(), Value::String(result.output.summary))]);
|
||||
if let Some(content) = result.output.content {
|
||||
fields.insert("content".into(), Value::String(content));
|
||||
}
|
||||
let items = if let Some(listing) = result.listing {
|
||||
fields.insert("truncated".into(), Value::Boolean(listing.truncated));
|
||||
if let Some(after) = listing.next_after {
|
||||
fields.insert("after".into(), cursor_value(&self.alias, &after));
|
||||
}
|
||||
listing
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|item| {
|
||||
Value::Record(BTreeMap::from([
|
||||
(
|
||||
"entry".into(),
|
||||
Value::String(object_path(&self.alias, &item.path)),
|
||||
),
|
||||
("kind".into(), Value::String(kind_name(item.kind).into())),
|
||||
(
|
||||
"size".into(),
|
||||
Value::Integer(i64::try_from(item.size).unwrap_or(i64::MAX)),
|
||||
),
|
||||
]))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
result
|
||||
.paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
Value::Record(BTreeMap::from([(
|
||||
"entry".into(),
|
||||
Value::String(object_path(&self.alias, path)),
|
||||
)]))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
fields.insert("items".into(), Value::List(items));
|
||||
let value = Value::Record(fields);
|
||||
if let Some(raw) = result.validator {
|
||||
Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.provider
|
||||
.wrap_validator(&self.alias, self.generation, &self.workdir, &raw),
|
||||
))
|
||||
} else {
|
||||
Ok(WipOperationOutput::native(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for FileHandler {
|
||||
@@ -498,6 +583,7 @@ impl WipOperationHandler for FileHandler {
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
let tool = match operation {
|
||||
"list" => "List",
|
||||
"read" => "Read",
|
||||
"edit" => "Edit",
|
||||
"write" => "Write",
|
||||
@@ -525,7 +611,34 @@ impl WipOperationHandler for FileHandler {
|
||||
}
|
||||
let mut permission_input = args.clone();
|
||||
permission_input.insert("target_workdir".into(), json!(self.alias));
|
||||
if tool == "Glob" || tool == "Grep" {
|
||||
if tool == "List" {
|
||||
permission_input.insert("path".into(), json!(self.target.as_str()));
|
||||
if let Some(after) = args.get_mut("after") {
|
||||
let entry = after.get("entry").and_then(Json::as_str).ok_or_else(|| {
|
||||
protocol(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"after requires an entry",
|
||||
)
|
||||
})?;
|
||||
let Some((alias, path)) = self.provider.parse(entry) else {
|
||||
return Err(protocol(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"invalid continuation entry",
|
||||
));
|
||||
};
|
||||
if alias != self.alias || object_path(&alias, &path) != entry {
|
||||
return Err(protocol(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"continuation must belong to this checkout",
|
||||
));
|
||||
}
|
||||
let fields = after.as_object_mut().ok_or_else(|| {
|
||||
protocol(ProtocolErrorCode::InvalidArguments, "invalid continuation")
|
||||
})?;
|
||||
fields.remove("entry");
|
||||
fields.insert("path".into(), json!(path.as_str()));
|
||||
}
|
||||
} else if tool == "Glob" || tool == "Grep" {
|
||||
let relative = args.get("path").and_then(Json::as_str).unwrap_or("");
|
||||
permission_input.insert("path".into(), json!(join_under(&self.target, relative)?));
|
||||
} else if tool == "Create" {
|
||||
@@ -585,58 +698,7 @@ impl WipOperationHandler for FileHandler {
|
||||
}
|
||||
})?
|
||||
.map_err(map_tool_error)?;
|
||||
let links = tokio::time::timeout_at(deadline, async {
|
||||
let mut links = Vec::new();
|
||||
self.check_connection()?;
|
||||
for path in &result.paths {
|
||||
let route = object_path(&self.alias, path);
|
||||
// References are validated only against the captured live connection.
|
||||
if self
|
||||
.provider
|
||||
.node(&route)
|
||||
.await
|
||||
.map_err(WipOperationError::Protocol)?
|
||||
.is_some()
|
||||
{
|
||||
links.push(json!({"path":route}));
|
||||
}
|
||||
self.check_connection()?;
|
||||
}
|
||||
Ok::<_, WipOperationError>(links)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
if matches!(tool, "Write" | "Edit" | "Create") {
|
||||
WipOperationError::OutcomeUnknown("completed operation response deadline".into())
|
||||
} else {
|
||||
protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"checkout result deadline",
|
||||
)
|
||||
}
|
||||
})?
|
||||
.map_err(|e| {
|
||||
if matches!(tool, "Write" | "Edit" | "Create") {
|
||||
WipOperationError::OutcomeUnknown("completed operation response unavailable".into())
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
let value = json_to_wip(
|
||||
&json!({"summary":result.output.summary,"content":result.output.content,"items":links}),
|
||||
)
|
||||
.map_err(|_| {
|
||||
WipOperationError::OutcomeUnknown("invalid completed file operation result".into())
|
||||
})?;
|
||||
if let Some(raw) = result.validator {
|
||||
Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.provider
|
||||
.wrap_validator(&self.alias, self.generation, &self.workdir, &raw),
|
||||
))
|
||||
} else {
|
||||
Ok(WipOperationOutput::native(value))
|
||||
}
|
||||
self.publish_result(tool, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,10 +752,70 @@ fn operation(
|
||||
parameters,
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Json,
|
||||
r#type: result_type(name == "list"),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn field(name: &str, required: bool, r#type: TypeExpr) -> FieldDeclaration {
|
||||
FieldDeclaration {
|
||||
name: name.into(),
|
||||
required,
|
||||
documentation: None,
|
||||
r#type,
|
||||
}
|
||||
}
|
||||
fn cursor_type() -> TypeExpr {
|
||||
TypeExpr::Record {
|
||||
fields: vec![
|
||||
field("kind", true, TypeExpr::String),
|
||||
field("entry", true, TypeExpr::Entry),
|
||||
],
|
||||
}
|
||||
}
|
||||
fn result_type(list: bool) -> TypeExpr {
|
||||
let mut item = vec![field("entry", true, TypeExpr::Entry)];
|
||||
if list {
|
||||
item.extend([
|
||||
field("kind", true, TypeExpr::String),
|
||||
field("size", true, TypeExpr::Integer),
|
||||
]);
|
||||
}
|
||||
let mut fields = vec![
|
||||
field("summary", true, TypeExpr::String),
|
||||
field("content", false, TypeExpr::String),
|
||||
field(
|
||||
"items",
|
||||
true,
|
||||
TypeExpr::List {
|
||||
items: Box::new(TypeExpr::Record { fields: item }),
|
||||
},
|
||||
),
|
||||
];
|
||||
if list {
|
||||
fields.extend([
|
||||
field("truncated", true, TypeExpr::Boolean),
|
||||
field("after", false, cursor_type()),
|
||||
]);
|
||||
}
|
||||
TypeExpr::Record { fields }
|
||||
}
|
||||
fn kind_name(kind: EntryKind) -> &'static str {
|
||||
match kind {
|
||||
EntryKind::Directory => "directory",
|
||||
EntryKind::File => "file",
|
||||
EntryKind::Symlink => "symlink",
|
||||
EntryKind::Other => "other",
|
||||
}
|
||||
}
|
||||
fn cursor_value(alias: &str, cursor: &workdir::ListCursor) -> Value {
|
||||
Value::Record(BTreeMap::from([
|
||||
("kind".into(), Value::String(kind_name(cursor.kind).into())),
|
||||
(
|
||||
"entry".into(),
|
||||
Value::String(object_path(alias, &cursor.path)),
|
||||
),
|
||||
]))
|
||||
}
|
||||
fn descriptor(operations: Vec<OperationDeclaration>) -> InterfaceDescriptor {
|
||||
InterfaceDescriptor{format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation{summary:"AI-oriented attached Workdir operations".into(),details:Some("The route binds attachment and target. Line offsets are zero-based; text output numbers lines from one. Read-before-write and provider validators are both required. Paths are Worldspace references, never host paths.".into())}),types:Vec::new(),operations}
|
||||
}
|
||||
|
||||
@@ -320,8 +320,8 @@ fn native_runtime(remote: Arc<RemoteWorkdirSession>) -> (WipRuntime, Arc<Workdir
|
||||
(runtime, router)
|
||||
}
|
||||
|
||||
async fn observe_interface(runtime: &WipRuntime, path: &str) -> String {
|
||||
runtime.discover(path.into(), 0, true).await.unwrap();
|
||||
async fn observe_interface(runtime: &WipRuntime, path: &str) -> wip_protocol::InterfaceReference {
|
||||
runtime.tree(path.into(), 0, true).await.unwrap();
|
||||
// Select from the real Client's Known Space, not a direct handler invocation.
|
||||
let interface = {
|
||||
let state = runtime.state.lock().unwrap();
|
||||
@@ -337,7 +337,7 @@ async fn observe_interface(runtime: &WipRuntime, path: &str) -> String {
|
||||
.interfaces[0]
|
||||
.clone()
|
||||
};
|
||||
runtime.inspect(interface.clone(), true).await.unwrap();
|
||||
runtime.inspect(path.into(), true).await.unwrap();
|
||||
interface
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ fn first_link(value: &Value) -> &str {
|
||||
panic!("expected typed link List")
|
||||
};
|
||||
assert_eq!(items.len(), 1);
|
||||
let Value::String(path) = field(&items[0], "path") else {
|
||||
let Value::String(path) = field(&items[0], "entry") else {
|
||||
panic!("expected typed link path")
|
||||
};
|
||||
path
|
||||
@@ -669,3 +669,89 @@ async fn checkout_http_malformed_response_after_write_is_unknown_without_retry()
|
||||
async fn checkout_http_lost_response_after_write_is_unknown_without_retry() {
|
||||
watchdog(committed_write_unknown(WriteResponseFault::LostBody)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_checkout_list_pages_and_direct_inspect_read_use_provider_without_tree_enumeration()
|
||||
{
|
||||
use super::checkout_wip_tests::{json_content, model_inspect, model_invoke, model_operation};
|
||||
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await;
|
||||
std::fs::create_dir(fixture.dir.path().join("empty")).unwrap();
|
||||
let remote = fixture.open(TOKEN).await.unwrap();
|
||||
let (runtime, _) = native_runtime(Arc::new(remote));
|
||||
let runtime = Arc::new(runtime);
|
||||
let tree = json_content(
|
||||
model_operation(
|
||||
runtime.clone(),
|
||||
"Tree",
|
||||
json!({"path":"/checkouts", "depth":8}),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(tree["tree"]["children"][0]["children"], json!([]));
|
||||
assert!(
|
||||
!fixture
|
||||
.state
|
||||
.operations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|op| matches!(
|
||||
op,
|
||||
WorkdirSessionOperation::CheckoutSearch(_) | WorkdirSessionOperation::List(_)
|
||||
))
|
||||
);
|
||||
let entrance = model_inspect(runtime.clone(), "/checkouts/main").await;
|
||||
let first = json_content(
|
||||
model_invoke(runtime.clone(), &entrance, "list", json!({"limit":1}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(first["items"][0]["entry"], "/checkouts/main/empty");
|
||||
assert_eq!(first["items"][0]["kind"], "directory");
|
||||
assert_eq!(first["truncated"], true);
|
||||
let next = json_content(
|
||||
model_invoke(
|
||||
runtime.clone(),
|
||||
&entrance,
|
||||
"list",
|
||||
json!({"limit":1,"after":first["after"]}),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(next["items"][0]["entry"], "/checkouts/main/src");
|
||||
assert_eq!(next["truncated"], false);
|
||||
let deep = model_inspect(runtime.clone(), "/checkouts/main/src/deep").await;
|
||||
for (operation, args) in [
|
||||
("list", json!({})),
|
||||
("glob", json!({"pattern":"*.txt"})),
|
||||
(
|
||||
"grep",
|
||||
json!({"pattern":"needle","output_mode":"files_with_matches"}),
|
||||
),
|
||||
] {
|
||||
let found = json_content(
|
||||
model_invoke(runtime.clone(), &deep, operation, args)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let file = found["items"][0]["entry"].as_str().unwrap();
|
||||
assert_eq!(file, "/checkouts/main/src/deep/a.txt");
|
||||
let inspected = model_inspect(runtime.clone(), file).await;
|
||||
let read = json_content(
|
||||
model_invoke(runtime.clone(), &inspected, "read", json!({}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(read["content"].as_str().unwrap().contains("needle"));
|
||||
fixture.public_output(&found.to_string());
|
||||
}
|
||||
let operations = fixture.state.operations.lock().unwrap();
|
||||
assert!(operations.iter().any(|op| matches!(op, WorkdirSessionOperation::CheckoutSearch(r) if matches!(&r.operation, CheckoutSearchOperation::List(l) if l.after.is_some()))));
|
||||
assert!(
|
||||
!operations
|
||||
.iter()
|
||||
.any(|op| matches!(op, WorkdirSessionOperation::List(_)))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ use tempfile::TempDir;
|
||||
use workdir::{
|
||||
CheckoutRequest, CheckoutResult, CommandHandle, CommandOutput, CommandOutputRequest,
|
||||
CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest,
|
||||
GrepResult, ListResult, LocalWorkdirSession, ReadBytesRequest, ReadBytesResult, ReadRequest,
|
||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirAttachmentAlias, WorkdirError,
|
||||
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
GrepResult, ListRequest, ListResult, LocalWorkdirSession, ReadBytesRequest, ReadBytesResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirAttachmentAlias,
|
||||
WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ struct HookedSession {
|
||||
observe_hook: Mutex<Option<ObserveHook>>,
|
||||
stall_path: Mutex<Option<WorkdirPath>>,
|
||||
stalled_observations: AtomicUsize,
|
||||
searches: AtomicUsize,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for HookedSession {
|
||||
@@ -52,6 +53,7 @@ impl HookedSession {
|
||||
observe_hook: Mutex::new(None),
|
||||
stall_path: Mutex::new(None),
|
||||
stalled_observations: AtomicUsize::new(0),
|
||||
searches: AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,6 +125,7 @@ impl WorkdirSession for HookedSession {
|
||||
&self,
|
||||
request: workdir::CheckoutSearchRequest,
|
||||
) -> Result<workdir::CheckoutSearchResult, WorkdirError> {
|
||||
self.searches.fetch_add(1, Ordering::SeqCst);
|
||||
self.local.checkout_search(request).await
|
||||
}
|
||||
|
||||
@@ -484,7 +487,7 @@ async fn collection_list_and_root_enumeration_bound_stalled_observation_and_rele
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_permit() {
|
||||
async fn grep_returns_typed_entry_without_observing_stalled_result() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "needle\n").unwrap();
|
||||
let session = HookedSession::new(&dir);
|
||||
@@ -499,8 +502,8 @@ async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_perm
|
||||
let root = checkout_root("main");
|
||||
let projection = p.node(&root).await.unwrap().unwrap();
|
||||
|
||||
// Directory observations and the normal local grep/checkout execution can
|
||||
// complete. Only publishing the typed search result path gets stuck.
|
||||
// Directory/search can complete. The result path must never be observed
|
||||
// during result publication, even when it would block indefinitely.
|
||||
session.stall_observation(Some(WorkdirPath::new("a.txt").unwrap()));
|
||||
let arguments = BTreeMap::from([
|
||||
("pattern".into(), Value::String("needle".into())),
|
||||
@@ -514,13 +517,17 @@ async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_perm
|
||||
projection.handler.call("grep", &arguments, context()),
|
||||
)
|
||||
.await
|
||||
.expect("post-search link observations must share the operation deadline");
|
||||
assert_deadline(result, "checkout result deadline");
|
||||
assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 1);
|
||||
.expect("completed search must return without acquiring its entries");
|
||||
let output = result.unwrap_or_else(|_| panic!("successful search must not observe each entry"));
|
||||
assert_eq!(
|
||||
wip_to_json(&output.value).unwrap()["items"],
|
||||
json!([{"entry": format!("{root}/a.txt")}])
|
||||
);
|
||||
assert_eq!(session.stalled_observations.load(Ordering::SeqCst), 0);
|
||||
assert_permit_released(&p);
|
||||
|
||||
// A successful retry both checks cancellation cleanup and proves that the
|
||||
// delegated search produces the typed path consumed by post-processing.
|
||||
// A second explicit search produces the same typed coordinate, without
|
||||
// acquiring it even when it could resolve successfully.
|
||||
session.stall_observation(None);
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
@@ -533,6 +540,230 @@ async fn grep_post_search_link_observation_is_deadline_bounded_and_releases_perm
|
||||
Err(_) => panic!("grep must succeed once the link observation is unstalled"),
|
||||
};
|
||||
let value = wip_to_json(&output.value).unwrap();
|
||||
assert_eq!(value["items"], json!([{"path": format!("{root}/a.txt")}]));
|
||||
assert_eq!(value["items"], json!([{"entry": format!("{root}/a.txt")}]));
|
||||
assert_permit_released(&p);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_content_children_never_call_provider_list_even_for_deep_paths() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("nested/deep")).unwrap();
|
||||
for n in 0..1100 {
|
||||
std::fs::write(dir.path().join(format!("file-{n}")), "fixture").unwrap();
|
||||
}
|
||||
let session = HookedSession::new(&dir);
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let subtree = Arc::new(provider(router));
|
||||
assert_eq!(
|
||||
subtree.children(ROOT).await.unwrap(),
|
||||
vec![checkout_root("main")]
|
||||
);
|
||||
for path in ["/checkouts/main", "/checkouts/main/nested/deep"] {
|
||||
assert!(subtree.publication(path).await.unwrap().is_some());
|
||||
assert!(subtree.children(path).await.unwrap().is_empty());
|
||||
}
|
||||
assert_eq!(session.searches.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
/// Deterministic adapter fixture for the production post-success publication
|
||||
/// boundary. Execute through the real provider/Tools, then detach after the
|
||||
/// routed operation guard has finished, before publishing the committed result.
|
||||
/// No race timing or production-only test hook is needed.
|
||||
struct DetachBeforePublication {
|
||||
file: FileHandler,
|
||||
calls: Arc<AtomicUsize>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for DetachBeforePublication {
|
||||
async fn call(
|
||||
&self,
|
||||
operation: &str,
|
||||
arguments: &BTreeMap<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
let tool = match operation {
|
||||
"write" => "Write",
|
||||
"edit" => "Edit",
|
||||
"create_file" => "Create",
|
||||
_ => panic!("mutation fixture only"),
|
||||
};
|
||||
let result = tools::execute_checkout_tool(
|
||||
self.file.provider.router.clone(),
|
||||
self.file.provider.tracker.clone(),
|
||||
&self.file.alias,
|
||||
self.file.generation,
|
||||
self.file.target.clone(),
|
||||
self.file.observation.validator.clone(),
|
||||
tool,
|
||||
wip_to_json(&Value::Record(arguments.clone())).unwrap(),
|
||||
context.execution,
|
||||
)
|
||||
.await
|
||||
.map_err(map_tool_error)?;
|
||||
self.file
|
||||
.provider
|
||||
.router
|
||||
.detach(&WorkdirAttachmentAlias::new(&self.file.alias).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
self.file.publish_result(tool, result)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn committed_checkout_mutation_losing_connection_is_unknown_not_rejected_or_replayed() {
|
||||
use crate::wip::{WipAuditOutcome, WipRuntime};
|
||||
for (operation, arguments, target, result_path, expected) in [
|
||||
(
|
||||
"write",
|
||||
json!({"content":"saved\n"}),
|
||||
"a.txt",
|
||||
"a.txt",
|
||||
"saved\n",
|
||||
),
|
||||
(
|
||||
"edit",
|
||||
json!({"old_string":"old", "new_string":"changed"}),
|
||||
"a.txt",
|
||||
"a.txt",
|
||||
"changed\n",
|
||||
),
|
||||
(
|
||||
"create_file",
|
||||
json!({"path":"new.txt", "content":"created\n"}),
|
||||
"dir",
|
||||
"dir/new.txt",
|
||||
"created\n",
|
||||
),
|
||||
] {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir(dir.path().join("dir")).unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "old\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
HookedSession::new(&dir),
|
||||
)
|
||||
.unwrap();
|
||||
let p = provider(router.clone());
|
||||
if operation != "create_file" {
|
||||
p.node("/checkouts/main/a.txt")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.handler
|
||||
.call("read", &BTreeMap::new(), context())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("prior Read failed"));
|
||||
}
|
||||
let selected = router.resolve(Some("main")).unwrap();
|
||||
let target = WorkdirPath::new(target).unwrap();
|
||||
let observation = selected
|
||||
.session
|
||||
.checkout_observe(target.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let file = FileHandler {
|
||||
provider: p.clone(),
|
||||
alias: "main".into(),
|
||||
generation: selected.generation,
|
||||
workdir: selected.session.workdir().id().as_str().into(),
|
||||
target,
|
||||
observation,
|
||||
};
|
||||
drop(selected);
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let route = object_path("main", &file.target);
|
||||
let mut projection = p.node(&route).await.unwrap().unwrap();
|
||||
let interface = projection.interface.clone();
|
||||
projection.handler = Arc::new(DetachBeforePublication {
|
||||
file,
|
||||
calls: calls.clone(),
|
||||
});
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry
|
||||
.allocate_namespace("checkout", "checkouts")
|
||||
.unwrap();
|
||||
// Static observation metadata isolates the response boundary; real
|
||||
// execution still uses checked provider authority and prior Read.
|
||||
registry
|
||||
.mount(p.node("/checkouts/main").await.unwrap().unwrap())
|
||||
.unwrap();
|
||||
registry.mount(projection).unwrap();
|
||||
let runtime = WipRuntime::from_mounts(registry, "post-commit-test".into()).unwrap();
|
||||
let error = runtime
|
||||
.invoke(
|
||||
route,
|
||||
interface,
|
||||
operation.into(),
|
||||
arguments,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("outcome unknown"),
|
||||
"{operation}: {error}"
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.audit().last().unwrap().outcome,
|
||||
WipAuditOutcome::OutcomeUnknown
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join(result_path)).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
calls.load(Ordering::SeqCst),
|
||||
1,
|
||||
"completed mutation must never replay"
|
||||
);
|
||||
assert_eq!(runtime.metrics().operation_round_trips, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readonly_checkout_result_losing_connection_keeps_validator_mismatch() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
let alias = WorkdirAttachmentAlias::new("main").unwrap();
|
||||
router
|
||||
.attach(alias.clone(), HookedSession::new(&dir))
|
||||
.unwrap();
|
||||
let selected = router.resolve(Some("main")).unwrap();
|
||||
let file = FileHandler {
|
||||
provider: provider(router.clone()),
|
||||
alias: "main".into(),
|
||||
generation: selected.generation,
|
||||
workdir: selected.session.workdir().id().as_str().into(),
|
||||
target: WorkdirPath::root(),
|
||||
observation: selected
|
||||
.session
|
||||
.checkout_observe(WorkdirPath::root())
|
||||
.await
|
||||
.unwrap(),
|
||||
};
|
||||
drop(selected);
|
||||
router.detach(&alias).await.unwrap();
|
||||
for tool in ["Read", "List", "Glob", "Grep"] {
|
||||
let result = tools::CheckoutToolOutput {
|
||||
output: agen::tool::ToolOutput {
|
||||
summary: "finished".into(),
|
||||
content: None,
|
||||
attachments: vec![],
|
||||
},
|
||||
paths: vec![],
|
||||
listing: None,
|
||||
validator: None,
|
||||
};
|
||||
assert_validator_mismatch(file.publish_result(tool, result));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +33,10 @@ fn runtime(
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
async fn interface(r: &WipRuntime, path: &str) -> String {
|
||||
r.discover(path.into(), 0, true).await.unwrap();
|
||||
async fn interface(r: &WipRuntime, path: &str) -> wip_protocol::InterfaceReference {
|
||||
r.tree(path.into(), 0, true).await.unwrap();
|
||||
let p = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
r.inspect(p.interface.clone(), true).await.unwrap();
|
||||
r.inspect(path.into(), true).await.unwrap();
|
||||
p.interface
|
||||
}
|
||||
async fn call(
|
||||
@@ -113,7 +113,7 @@ async fn checkout_wip_search_preserves_root_and_nested_ignore_rules_and_links()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|item| item["path"].as_str().unwrap().to_string())
|
||||
.map(|item| item["entry"].as_str().unwrap().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
// Grep retains traversal order; Glob sorts its result paths.
|
||||
paths.sort();
|
||||
@@ -159,8 +159,8 @@ async fn checkout_wip_native_roundtrip_search_read_edit_write_create() {
|
||||
.await
|
||||
.unwrap();
|
||||
let grep_result: Json = serde_json::from_str(grep.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(grep_result["items"][0]["path"], file);
|
||||
let followed = grep_result["items"][0]["path"].as_str().unwrap();
|
||||
assert_eq!(grep_result["items"][0]["entry"], file);
|
||||
let followed = grep_result["items"][0]["entry"].as_str().unwrap();
|
||||
assert_eq!(followed, file);
|
||||
assert!(
|
||||
call(
|
||||
@@ -588,8 +588,9 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
|
||||
.observe_live("/checkouts/child", 2)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed.children.unwrap().len(), 1);
|
||||
assert!(observed.children.unwrap().is_empty());
|
||||
for (op, args) in [
|
||||
("list", json!({"limit":1})),
|
||||
("glob", json!({"pattern":"**/*"})),
|
||||
(
|
||||
"grep",
|
||||
@@ -654,3 +655,339 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
|
||||
"changed"
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn json_content(output: ToolOutput) -> Json {
|
||||
serde_json::from_str(output.content.as_deref().unwrap()).unwrap()
|
||||
}
|
||||
pub(super) async fn model_operation(
|
||||
r: Arc<WipRuntime>,
|
||||
name: &str,
|
||||
args: Json,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let (_, tool) = wip_tool_definitions(r)
|
||||
.into_iter()
|
||||
.map(|definition| definition())
|
||||
.find(|(meta, _)| meta.name == name)
|
||||
.unwrap();
|
||||
tool.execute(&args.to_string(), Default::default()).await
|
||||
}
|
||||
pub(super) async fn model_inspect(r: Arc<WipRuntime>, path: &str) -> Json {
|
||||
json_content(
|
||||
model_operation(r, "Inspect", json!({"path":path}))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
pub(super) async fn model_invoke(
|
||||
r: Arc<WipRuntime>,
|
||||
inspected: &Json,
|
||||
operation: &str,
|
||||
arguments: Json,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
model_operation(r, "Invoke", json!({"path":inspected["path"], "interface":inspected["interfaces"][0]["reference"], "operation":operation, "arguments":arguments})).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_depth_boundaries_are_independent_of_empty_small_or_large_contents() {
|
||||
for count in [0, 2, 1100] {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("nested/deep")).unwrap();
|
||||
for n in 0..count {
|
||||
std::fs::write(dir.path().join(format!("file-{n}")), "fixture").unwrap();
|
||||
}
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session(&dir, true),
|
||||
)
|
||||
.unwrap();
|
||||
let r = runtime(router, None);
|
||||
let one = r.host.observe_live("/checkouts", 1).await.unwrap();
|
||||
assert!(one.children.unwrap()[0].children.is_none());
|
||||
for depth in [2, 32] {
|
||||
let observation = r.host.observe_live("/checkouts", depth).await.unwrap();
|
||||
let entrances = observation.children.unwrap();
|
||||
assert_eq!(entrances.len(), 1);
|
||||
assert_eq!(entrances[0].children, Some(vec![]));
|
||||
}
|
||||
let world = r.host.observe_live("/", 32).await.unwrap();
|
||||
assert_eq!(
|
||||
world.children.unwrap()[0].children.as_ref().unwrap()[0].children,
|
||||
Some(vec![])
|
||||
);
|
||||
for path in ["/checkouts/main", "/checkouts/main/nested/deep"] {
|
||||
assert!(
|
||||
r.host
|
||||
.observe_live(path, 0)
|
||||
.await
|
||||
.unwrap()
|
||||
.children
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
r.host.observe_live(path, 1).await.unwrap().children,
|
||||
Some(vec![])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_edges() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("empty")).unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("nested/deep")).unwrap();
|
||||
std::fs::write(dir.path().join("nested/deep/a.txt"), "needle\n").unwrap();
|
||||
std::fs::write(dir.path().join("z.txt"), "needle\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session(&dir, true),
|
||||
)
|
||||
.unwrap();
|
||||
let r = Arc::new(runtime(router, None));
|
||||
let entrance = model_inspect(r.clone(), "/checkouts/main").await;
|
||||
assert_eq!(entrance["path"], "/checkouts/main");
|
||||
assert_eq!(
|
||||
entrance["interfaces"][0]["reference"]["scope"],
|
||||
"/checkouts/main"
|
||||
);
|
||||
let projection = r
|
||||
.host
|
||||
.projection_live("/checkouts/main")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
entrance["object_signature"],
|
||||
wip_text_view::render_object(&projection.object).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
entrance["interfaces"][0]["signature"],
|
||||
wip_text_view::render_interface(&wip_text_view::Interface {
|
||||
reference: &projection.interface,
|
||||
descriptor: &projection.descriptor
|
||||
})
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
entrance["interfaces"][0]["signature"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("entry")
|
||||
);
|
||||
let mut after = None;
|
||||
let mut items = vec![];
|
||||
loop {
|
||||
let mut args = json!({"limit":1});
|
||||
if let Some(cursor) = after {
|
||||
args["after"] = cursor;
|
||||
}
|
||||
let page = json_content(
|
||||
model_invoke(r.clone(), &entrance, "list", args)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(page["items"].as_array().unwrap().len(), 1);
|
||||
items.push(page["items"][0].clone());
|
||||
if page["truncated"] == false {
|
||||
assert!(page.get("after").is_none());
|
||||
break;
|
||||
}
|
||||
after = Some(page["after"].clone());
|
||||
}
|
||||
assert_eq!(
|
||||
items
|
||||
.iter()
|
||||
.map(|item| item["entry"].clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
json!("/checkouts/main/empty"),
|
||||
json!("/checkouts/main/nested"),
|
||||
json!("/checkouts/main/z.txt")
|
||||
]
|
||||
);
|
||||
assert_eq!(items[0]["kind"], "directory");
|
||||
assert_eq!(items[2]["kind"], "file");
|
||||
let empty = model_inspect(r.clone(), items[0]["entry"].as_str().unwrap()).await;
|
||||
assert_eq!(
|
||||
json_content(
|
||||
model_invoke(r.clone(), &empty, "list", json!({}))
|
||||
.await
|
||||
.unwrap()
|
||||
)["items"],
|
||||
json!([])
|
||||
);
|
||||
let deep = model_inspect(r.clone(), "/checkouts/main/nested/deep").await;
|
||||
assert_eq!(
|
||||
deep["interfaces"][0]["reference"]["scope"],
|
||||
"/checkouts/main"
|
||||
);
|
||||
for (operation, args) in [
|
||||
("list", json!({})),
|
||||
("glob", json!({"pattern":"*.txt"})),
|
||||
(
|
||||
"grep",
|
||||
json!({"pattern":"needle", "output_mode":"files_with_matches"}),
|
||||
),
|
||||
] {
|
||||
let found = json_content(
|
||||
model_invoke(r.clone(), &deep, operation, args)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let path = found["items"][0]["entry"].as_str().unwrap();
|
||||
assert_eq!(path, "/checkouts/main/nested/deep/a.txt");
|
||||
// No result-wide eager acquisition by Host or Client.
|
||||
if operation == "list" {
|
||||
let state = r.state.lock().unwrap();
|
||||
assert!(state.client.object(&state.session, path).is_none());
|
||||
}
|
||||
let inspected = model_inspect(r.clone(), path).await;
|
||||
let read = json_content(
|
||||
model_invoke(r.clone(), &inspected, "read", json!({}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(read["content"].as_str().unwrap().contains("needle"));
|
||||
}
|
||||
let tree = json_content(
|
||||
model_operation(r.clone(), "Tree", json!({"path":"/checkouts", "depth":8}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(tree["tree"]["children"][0]["children"], json!([]));
|
||||
std::fs::remove_file(dir.path().join("nested/deep/a.txt")).unwrap();
|
||||
assert!(
|
||||
model_operation(
|
||||
r,
|
||||
"Inspect",
|
||||
json!({"path":"/checkouts/main/nested/deep/a.txt", "refresh":true})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_list_permission_cannot_be_bypassed_with_glob_or_command_identity() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "fixture").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session(&dir, true),
|
||||
)
|
||||
.unwrap();
|
||||
let r = runtime(
|
||||
router,
|
||||
Some(ToolPermissionConfig {
|
||||
default_action: ToolPermissionAction::Allow,
|
||||
rules: vec![manifest::ToolPermissionRule {
|
||||
tool: "List".into(),
|
||||
pattern: "*".into(),
|
||||
action: ToolPermissionAction::Deny,
|
||||
}],
|
||||
}),
|
||||
);
|
||||
let p = r
|
||||
.host
|
||||
.projection_live("/checkouts/main")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!p.descriptor.operations.iter().any(|op| op.name == "list"));
|
||||
assert!(p.descriptor.operations.iter().any(|op| op.name == "glob"));
|
||||
let denied = p
|
||||
.handler
|
||||
.call(
|
||||
"list",
|
||||
&BTreeMap::new(),
|
||||
WipCallContext {
|
||||
execution: Default::default(),
|
||||
security_context: "fixture".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(denied, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::PermissionDenied)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connection_before_read() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "same\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
let main = WorkdirAttachmentAlias::new("main").unwrap();
|
||||
router.attach(main.clone(), session(&dir, true)).unwrap();
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main-extra").unwrap(),
|
||||
session(&dir, true),
|
||||
)
|
||||
.unwrap();
|
||||
let r = runtime(router.clone(), None);
|
||||
let path = "/checkouts/main/a.txt";
|
||||
let p = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
let fetched = r.host.fetch_interface_live(&p.interface).await.unwrap();
|
||||
assert!(
|
||||
fetched.scope_ref.is_none(),
|
||||
"ref-less publication is legitimate"
|
||||
);
|
||||
// Ordinary content changes do not terminate the entrance's Interface lifetime.
|
||||
std::fs::write(dir.path().join("new.txt"), "fixture").unwrap();
|
||||
assert_eq!(
|
||||
r.host
|
||||
.fetch_interface_live(&p.interface)
|
||||
.await
|
||||
.unwrap()
|
||||
.interface,
|
||||
p.interface
|
||||
);
|
||||
let request = |reference, scope_ref| CallOperationRequest {
|
||||
target: wip_protocol::Target {
|
||||
path: path.into(),
|
||||
validator: None,
|
||||
},
|
||||
interface: wip_protocol::InterfaceTarget {
|
||||
reference,
|
||||
scope_ref,
|
||||
validator: None,
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
};
|
||||
for scope in ["/checkouts/main-extra", "/checkouts/main/a"] {
|
||||
let mut wrong = p.interface.clone();
|
||||
wrong.scope = scope.into();
|
||||
assert!(r.host.fetch_interface_live(&wrong).await.is_err());
|
||||
let forged = request(wrong, None);
|
||||
let result = r
|
||||
.host
|
||||
.call(
|
||||
forged,
|
||||
WipCallContext {
|
||||
execution: Default::default(),
|
||||
security_context: "fixture".into(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result,Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch)
|
||||
);
|
||||
}
|
||||
let forged = request(p.interface.clone(), Some("old".into()));
|
||||
assert!(
|
||||
matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch)
|
||||
);
|
||||
router.detach(&main).await.unwrap();
|
||||
router.attach(main, session(&dir, true)).unwrap();
|
||||
let forged = request(p.interface.clone(), None);
|
||||
assert!(
|
||||
matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,7 +178,10 @@ impl WorkerHandle {
|
||||
(entries, entry_rx, in_flight)
|
||||
};
|
||||
let mut session =
|
||||
session_store::public_snapshot::project_current_session_snapshot(&entries);
|
||||
session_store::public_snapshot::project_current_session_snapshot_with_accounting(
|
||||
self.artifact_store.as_ref(),
|
||||
&entries,
|
||||
);
|
||||
session.pending_submissions = self
|
||||
.pending_activations
|
||||
.lock()
|
||||
@@ -1286,14 +1289,8 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
||||
});
|
||||
});
|
||||
|
||||
let tx = working_event_tx.clone();
|
||||
worker.on_usage(move |event| {
|
||||
let _ = tx.send(Event::Usage {
|
||||
input_tokens: event.input_tokens,
|
||||
output_tokens: event.output_tokens,
|
||||
cache_read_input_tokens: event.cache_read_input_tokens,
|
||||
});
|
||||
});
|
||||
// Usage is published from committed LlmUsage records through the session-log
|
||||
// lane. Snapshot and live usage therefore share one exact, gap-free boundary.
|
||||
|
||||
let tx = working_event_tx.clone();
|
||||
worker.on_error(move |event| {
|
||||
@@ -1631,6 +1628,15 @@ where
|
||||
);
|
||||
}
|
||||
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
|
||||
let drive_feature = crate::feature::builtin::drive::DriveFeature::configured(
|
||||
worker.workspace_client_handle(),
|
||||
feature_config.drive.enabled,
|
||||
worker.workdir_sessions(),
|
||||
)
|
||||
.map(|feature| feature.with_permissions(worker.manifest().permissions.clone()));
|
||||
if let Some(module) = &drive_feature {
|
||||
feature_registry.add_module(module.clone());
|
||||
}
|
||||
let workspace_config_feature =
|
||||
crate::feature::builtin::workspace_config::WorkspaceConfigFeature::configured(
|
||||
worker.workspace_client_handle(),
|
||||
@@ -1731,6 +1737,15 @@ where
|
||||
let host_worker_observation_provider = worker.worker_observation_provider();
|
||||
let wip_permissions = worker.manifest().permissions.clone();
|
||||
let mut wip_mount_registry = crate::wip::WipMountRegistry::new();
|
||||
if wip_mode && let Some(feature) = &drive_feature {
|
||||
crate::feature::builtin::drive::wip::mount_drive_wip(&mut wip_mount_registry, feature)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("mount Drive WIP projection: {error}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if let Some(feature) = &workspace_config_feature {
|
||||
crate::feature::builtin::workspace_config::wip::mount_workspace_config_wip(
|
||||
&mut wip_mount_registry,
|
||||
@@ -3969,6 +3984,149 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DriveWorkspaceHost {
|
||||
available: bool,
|
||||
workspace: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl crate::worker::WorkspaceClient for DriveWorkspaceHost {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
self.workspace
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"drive-test"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
self.available
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: crate::worker::WorkspaceRequest,
|
||||
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError>
|
||||
{
|
||||
// Native discovery must not enumerate or perform any mutation.
|
||||
assert_eq!(request.path, "/api/w/workspace-1/drive/root");
|
||||
Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({
|
||||
"entry": {"workspace_id": "workspace-1", "node_id": "1"},
|
||||
"parent": null, "name": "", "kind": "folder", "revision": "1",
|
||||
"size": null, "content_type": null, "updated_by": "test",
|
||||
"updated_at": "2026-10-08T00:00:00Z",
|
||||
"latest_url": "/api/w/workspace-1/drive/download?entry_workspace_id=workspace-1&id=1"
|
||||
}).to_string() })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_controller_wiring_requires_activation_and_workspace_host_in_both_modes() {
|
||||
use crate::feature::FeatureModule as _;
|
||||
for mode in [manifest::WorkerMode::Tools, manifest::WorkerMode::Wip] {
|
||||
for (enabled, available, workspace) in [
|
||||
(false, true, Some("workspace-1")),
|
||||
(true, false, Some("workspace-1")),
|
||||
(true, true, None),
|
||||
(true, true, Some("workspace-1")),
|
||||
] {
|
||||
let root = TempDir::new().unwrap();
|
||||
let mut manifest = manifest::WorkerManifest::from_toml(
|
||||
"[worker]\nname = 'drive-wiring'\n[model]\nscheme = 'anthropic'\nmodel_id = 'test'\n[engine]\n[scope]\n"
|
||||
).unwrap();
|
||||
manifest.worker.mode = mode;
|
||||
manifest.feature.drive.enabled = enabled;
|
||||
// Isolate Drive discovery from the independent Workspace catalog.
|
||||
manifest.feature.workdir_catalog.enabled = false;
|
||||
let client = ScriptedClient::new(vec![finished_response()]);
|
||||
let requests = client.requests.clone();
|
||||
let engine = Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client);
|
||||
let store = session_store::CombinedStore::new(
|
||||
session_store::FsStore::new(root.path().join("sessions")).unwrap(),
|
||||
session_store::FsWorkerStore::new(root.path().join("workers")).unwrap(),
|
||||
);
|
||||
let host: Arc<dyn crate::worker::WorkspaceClient> =
|
||||
Arc::new(DriveWorkspaceHost {
|
||||
available,
|
||||
workspace,
|
||||
});
|
||||
let context = crate::worker::WorkerWorkspaceContext::with_client(
|
||||
workspace.map(|id| crate::worker::WorkspaceId::new(id).unwrap()),
|
||||
host.clone(),
|
||||
);
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
engine,
|
||||
store,
|
||||
context,
|
||||
crate::worker::WorkerFilesystemAuthority::local(
|
||||
root.path().into(),
|
||||
root.path().into(),
|
||||
),
|
||||
manifest::Scope::writable(root.path()).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
worker.set_system_prompt_template(
|
||||
crate::SystemPromptTemplate::parse(
|
||||
"default",
|
||||
crate::PromptCatalogSource::builtins_only(),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let descriptor = crate::feature::builtin::drive::DriveFeature::new(
|
||||
host,
|
||||
worker.workdir_sessions(),
|
||||
)
|
||||
.descriptor();
|
||||
register_worker_tools(
|
||||
&mut worker,
|
||||
root.path().join("bash-output"),
|
||||
root.path().join("runtime"),
|
||||
SpawnedWorkerRegistry::new_for_internal_services(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let configured = enabled && available && workspace.is_some();
|
||||
let tools = worker.engine().tool_server_handle();
|
||||
tools.flush_pending();
|
||||
for tool in &descriptor.tools {
|
||||
assert_eq!(
|
||||
tools.get_tool(&tool.name).is_some(),
|
||||
configured && mode == manifest::WorkerMode::Tools,
|
||||
"{mode:?}, enabled={enabled}, available={available}, workspace={workspace:?}, tool={}",
|
||||
tool.name
|
||||
);
|
||||
}
|
||||
if mode == manifest::WorkerMode::Wip {
|
||||
let (_, tree) = tools.get_tool("Tree").unwrap();
|
||||
let output = tree
|
||||
.execute(
|
||||
r#"{"path":"/drive","depth":1}"#,
|
||||
ToolExecutionContext::new("drive-wiring-tree", "batch", 0),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(output.is_ok(), configured, "{output:?}");
|
||||
if let Ok(output) = output {
|
||||
let tree: serde_json::Value =
|
||||
serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||
assert_eq!(tree["path"], "/drive");
|
||||
// A self-scoped entrance is an enumeration boundary,
|
||||
// not an observed empty Workspace hierarchy.
|
||||
assert_eq!(tree["children"], serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
worker.run_text("Inspect enabled features").await.unwrap();
|
||||
let requests = requests.lock().unwrap();
|
||||
let prompt = requests[0].system_prompt.as_deref().unwrap_or("");
|
||||
assert_eq!(
|
||||
prompt.contains("## Workspace Drive"),
|
||||
configured,
|
||||
"{mode:?}, enabled={enabled}, available={available}, workspace={workspace:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LargeOutputTool;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
||||
@@ -368,11 +368,9 @@ fn apply_profile_launch_policy(
|
||||
apply_scope_launch_defaults(&mut manifest.scope, default_scope);
|
||||
manifest.delegation_scope = ScopeConfig::default();
|
||||
}
|
||||
Some(TicketRole::Coder) => {
|
||||
let default_scope = workspace_scope(workspace_root, Permission::Write, &[]);
|
||||
apply_scope_launch_defaults(&mut manifest.scope, default_scope);
|
||||
manifest.delegation_scope = ScopeConfig::default();
|
||||
}
|
||||
// Work assignment is not a filesystem or delegation grant. The selected
|
||||
// Profile and explicit resource bindings remain the authority.
|
||||
Some(TicketRole::Worker) => {}
|
||||
None => {
|
||||
let worktree_root = workspace_root.join(".worktree");
|
||||
let default_scope = workspace_scope(
|
||||
@@ -872,6 +870,41 @@ language = "override"
|
||||
assert_scope_contains(&manifest.scope.allow, &workspace, Permission::Write);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_worker_role_preserves_profile_scope_and_delegation_without_adding_grants() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let workspace = tmp.path().join("workspace");
|
||||
let explicit_scope = ScopeConfig {
|
||||
allow: vec![scope_rule(&workspace.join("documents"), Permission::Write)],
|
||||
deny: vec![scope_rule(
|
||||
&workspace.join("documents/private"),
|
||||
Permission::Write,
|
||||
)],
|
||||
};
|
||||
let explicit_delegation = ScopeConfig {
|
||||
allow: vec![scope_rule(&workspace.join("reference"), Permission::Read)],
|
||||
deny: vec![scope_rule(
|
||||
&workspace.join("reference/private"),
|
||||
Permission::Read,
|
||||
)],
|
||||
};
|
||||
for (scope, delegation) in [
|
||||
(ScopeConfig::default(), ScopeConfig::default()),
|
||||
(explicit_scope, explicit_delegation),
|
||||
] {
|
||||
let (mut manifest, _) = load_builtin_default_manifest("ticket-worker").unwrap();
|
||||
manifest.scope = scope.clone();
|
||||
manifest.delegation_scope = delegation.clone();
|
||||
|
||||
apply_profile_launch_policy(&mut manifest, &workspace, Some("worker")).unwrap();
|
||||
|
||||
assert_eq!(manifest.scope.allow, scope.allow);
|
||||
assert_eq!(manifest.scope.deny, scope.deny);
|
||||
assert_eq!(manifest.delegation_scope.allow, delegation.allow);
|
||||
assert_eq!(manifest.delegation_scope.deny, delegation.deny);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orchestrator_direct_startup_gets_read_root_and_worktree_delegation_from_launch_policy() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
pub mod backend_job_result;
|
||||
pub mod chat_invocation;
|
||||
pub mod drive;
|
||||
pub mod flow_transition;
|
||||
pub mod manage_workdir;
|
||||
pub mod manage_worker;
|
||||
|
||||
@@ -0,0 +1,718 @@
|
||||
//! Worker Drive operations use only the injected Workspace API. Observations
|
||||
//! below are bounded preconditions, never a hierarchy replica or a grant cache.
|
||||
pub mod backend;
|
||||
pub(crate) mod delegation;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub mod wip;
|
||||
|
||||
use crate::{
|
||||
feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||
ToolContribution, ToolDeclaration,
|
||||
},
|
||||
worker::WorkspaceClient,
|
||||
};
|
||||
use agen::tool::{
|
||||
Attachment, ImageAttachment, Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta,
|
||||
ToolOutput,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use backend::{DriveBackend, DriveError};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, de::DeserializeOwned};
|
||||
use serde_json::{Value, json};
|
||||
use server_api::*;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use workdir::{WorkdirPath, WorkdirSessionCapability, WorkdirSessionRouter};
|
||||
|
||||
pub const FEATURE_ID: &str = "drive";
|
||||
const MAX_OBSERVATIONS: usize = 256;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Observations {
|
||||
entries: HashMap<String, DriveEntry>,
|
||||
order: VecDeque<String>,
|
||||
}
|
||||
impl Observations {
|
||||
fn insert(&mut self, entry: DriveEntry) {
|
||||
let key = entry.entry.node_id.clone();
|
||||
self.order.retain(|old| old != &key);
|
||||
self.order.push_back(key.clone());
|
||||
self.entries.insert(key, entry);
|
||||
while self.order.len() > MAX_OBSERVATIONS {
|
||||
if let Some(old) = self.order.pop_front() {
|
||||
self.entries.remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn remove(&mut self, key: &str) {
|
||||
self.entries.remove(key);
|
||||
self.order.retain(|old| old != key);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DriveFeature {
|
||||
pub(super) backend: DriveBackend,
|
||||
pub(super) router: Arc<WorkdirSessionRouter>,
|
||||
observations: Arc<Mutex<Observations>>,
|
||||
nonce: Arc<str>,
|
||||
pub(super) permissions: Option<manifest::ToolPermissionConfig>,
|
||||
}
|
||||
impl DriveFeature {
|
||||
pub fn configured(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
enabled: bool,
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
) -> Option<Self> {
|
||||
(enabled
|
||||
&& client.is_available()
|
||||
&& client
|
||||
.workspace_id()
|
||||
.is_some_and(|id| !id.is_empty() && !id.chars().any(char::is_control)))
|
||||
.then(|| Self::new(client, router))
|
||||
}
|
||||
pub fn new(client: Arc<dyn WorkspaceClient>, router: Arc<WorkdirSessionRouter>) -> Self {
|
||||
Self {
|
||||
backend: DriveBackend::new(client),
|
||||
router,
|
||||
observations: Arc::new(Mutex::new(Observations::default())),
|
||||
nonce: uuid::Uuid::now_v7().to_string().into(),
|
||||
permissions: None,
|
||||
}
|
||||
}
|
||||
pub fn with_permissions(mut self, permissions: Option<manifest::ToolPermissionConfig>) -> Self {
|
||||
self.permissions = permissions;
|
||||
self
|
||||
}
|
||||
pub fn tools(&self) -> Vec<(&'static str, ToolDefinition)> {
|
||||
SPECS
|
||||
.iter()
|
||||
.map(|spec| {
|
||||
let feature = self.clone();
|
||||
let spec = *spec;
|
||||
let definition: ToolDefinition = Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(spec.name)
|
||||
.description(spec.description)
|
||||
.input_schema((spec.schema)()),
|
||||
Arc::new(DriveTool {
|
||||
feature: feature.clone(),
|
||||
name: spec.name,
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
});
|
||||
(spec.name, definition)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn observe(&self, entry: DriveEntry) -> Result<(), DriveError> {
|
||||
self.backend.validate_entry(&entry)?;
|
||||
self.observations
|
||||
.lock()
|
||||
.map_err(|_| DriveError::Unavailable)?
|
||||
.insert(entry);
|
||||
Ok(())
|
||||
}
|
||||
fn observed(
|
||||
&self,
|
||||
id: &DriveEntryRef,
|
||||
pinned: Option<&DriveEntry>,
|
||||
) -> Result<DriveEntry, DriveError> {
|
||||
self.backend.validate_ref(id)?;
|
||||
if let Some(pinned) = pinned {
|
||||
if pinned.entry != *id {
|
||||
return Err(DriveError::Denied);
|
||||
}
|
||||
return Ok(pinned.clone());
|
||||
}
|
||||
self.observations.lock().map_err(|_| DriveError::Unavailable)?.entries.get(&id.node_id).cloned().ok_or_else(|| DriveError::Invalid("read or inspect metadata for this entry before mutation; observations are bounded and not persisted authority".into()))
|
||||
}
|
||||
fn request_id(&self, context: &ToolExecutionContext) -> String {
|
||||
digest(format!("{}:{}", self.nonce, context.execution_id()).as_bytes())
|
||||
}
|
||||
pub(super) async fn execute(
|
||||
&self,
|
||||
name: &str,
|
||||
input: &str,
|
||||
context: &ToolExecutionContext,
|
||||
pinned: Option<&DriveEntry>,
|
||||
) -> Result<DriveOutput, DriveError> {
|
||||
self.backend.workspace()?;
|
||||
let request_id = self.request_id(context);
|
||||
let mut attachments = Vec::new();
|
||||
let mut mutated_id = None;
|
||||
let value = match name {
|
||||
"DriveRoot" => {
|
||||
let _: EmptyArgs = decode(input)?;
|
||||
let entry = self.backend.root().await?;
|
||||
self.observe(entry.clone())?;
|
||||
entry_value(entry)
|
||||
}
|
||||
"DriveMetadata" => {
|
||||
let args: EntryArgs = decode(input)?;
|
||||
let entry = self.backend.metadata(&args.entry).await?;
|
||||
self.observe(entry.clone())?;
|
||||
entry_value(entry)
|
||||
}
|
||||
"DriveList" => {
|
||||
let args: ListArgs = decode(input)?;
|
||||
if args
|
||||
.limit
|
||||
.is_some_and(|n| n == 0 || n > DRIVE_PAGE_MAX_LIMIT)
|
||||
{
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
let parent = match args.parent {
|
||||
Some(parent) => parent,
|
||||
None => self.backend.root().await?.entry,
|
||||
};
|
||||
let response = self.backend.list(&parent, args.limit, args.after).await?;
|
||||
page_value(response)
|
||||
}
|
||||
"DriveSearch" => {
|
||||
let args: DriveSearchQuery = decode(input)?;
|
||||
if args.query.len() > 512 || args.after.as_ref().is_some_and(|s| s.len() > 2048) {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
page_value(self.backend.search(args).await?)
|
||||
}
|
||||
"DriveRead" => {
|
||||
let args: ReadArgs = decode(input)?;
|
||||
if args.max_bytes == 0 || args.max_bytes > DRIVE_CHUNK_MAX_BYTES {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
let read = self.backend.read(&args.entry, args.max_bytes).await?;
|
||||
if pinned.is_some_and(|entry| entry.revision != read.entry.revision) {
|
||||
return Err(DriveError::Conflict);
|
||||
}
|
||||
self.observe(read.entry.clone())?;
|
||||
json!({"entry": entry_value(read.entry), "text": read.text, "truncated":read.truncated})
|
||||
}
|
||||
"DriveViewImage" => {
|
||||
let args: EntryArgs = decode(input)?;
|
||||
let entry = if let Some(entry) = pinned {
|
||||
self.observed(&args.entry, Some(entry))?
|
||||
} else {
|
||||
self.backend.metadata(&args.entry).await?
|
||||
};
|
||||
let bytes = self
|
||||
.backend
|
||||
.bytes(&entry, tools::view_image::MAX_IMAGE_BYTES)
|
||||
.await?;
|
||||
let media = tools::view_image::detect_image_mime(&bytes).ok_or_else(|| {
|
||||
DriveError::Invalid("supported image bytes required: PNG/JPEG/GIF/WebP".into())
|
||||
})?;
|
||||
let size = bytes.len();
|
||||
attachments.push(Attachment::Image(ImageAttachment::new(
|
||||
media,
|
||||
Arc::<[u8]>::from(bytes),
|
||||
)));
|
||||
self.observe(entry.clone())?;
|
||||
json!({"entry":entry_value(entry), "attached":true, "mime_type":media, "bytes":size})
|
||||
}
|
||||
"DriveCreateFolder" => {
|
||||
let args: FolderArgs = decode(input)?;
|
||||
self.backend.validate_ref(&args.parent)?;
|
||||
mutation_value(
|
||||
self.backend
|
||||
.mutate(DriveMutationRequest {
|
||||
request_id,
|
||||
mutation: DriveMutation::CreateFolder {
|
||||
parent: args.parent,
|
||||
name: args.name,
|
||||
},
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
"DriveCreateText" => {
|
||||
let args: CreateArgs = decode(input)?;
|
||||
self.backend.validate_ref(&args.parent)?;
|
||||
let text = checked_text(args.content)?;
|
||||
mutation_value(
|
||||
self.backend
|
||||
.mutate(DriveMutationRequest {
|
||||
request_id,
|
||||
mutation: DriveMutation::CreateText {
|
||||
parent: args.parent,
|
||||
name: args.name,
|
||||
text,
|
||||
content_type: args.content_type,
|
||||
},
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
"DriveWrite" => {
|
||||
let args: WriteArgs = decode(input)?;
|
||||
let entry = self.observed(&args.entry, pinned)?;
|
||||
let text = checked_text(args.content)?;
|
||||
mutated_id = Some(entry.entry.node_id.clone());
|
||||
mutation_value(
|
||||
self.backend
|
||||
.mutate(DriveMutationRequest {
|
||||
request_id,
|
||||
mutation: DriveMutation::UpdateText {
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
text,
|
||||
content_type: entry.content_type.unwrap_or_else(default_media),
|
||||
},
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
"DriveEdit" => {
|
||||
let args: EditArgs = decode(input)?;
|
||||
let entry = self.observed(&args.entry, pinned)?;
|
||||
let edit = fs_operation::text::EditArgs {
|
||||
old_string: args.old_string,
|
||||
new_string: args.new_string,
|
||||
replace_all: args.replace_all,
|
||||
};
|
||||
edit.validate(text_limits()).map_err(text_error)?;
|
||||
let read = self
|
||||
.backend
|
||||
.read(&entry.entry, DRIVE_TEXT_MAX_BYTES as u32)
|
||||
.await?;
|
||||
if read.entry.revision != entry.revision {
|
||||
return Err(DriveError::Conflict);
|
||||
}
|
||||
if read.truncated {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
let text = fs_operation::text::edit(&read.text, &edit, text_limits())
|
||||
.map_err(text_error)?
|
||||
.content;
|
||||
mutated_id = Some(entry.entry.node_id.clone());
|
||||
mutation_value(
|
||||
self.backend
|
||||
.mutate(DriveMutationRequest {
|
||||
request_id,
|
||||
mutation: DriveMutation::UpdateText {
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
text,
|
||||
content_type: entry.content_type.unwrap_or_else(default_media),
|
||||
},
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
"DriveRelocate" => {
|
||||
let args: RelocateArgs = decode(input)?;
|
||||
self.backend.validate_ref(&args.parent)?;
|
||||
let entry = self.observed(&args.entry, pinned)?;
|
||||
mutated_id = Some(entry.entry.node_id.clone());
|
||||
mutation_value(
|
||||
self.backend
|
||||
.mutate(DriveMutationRequest {
|
||||
request_id,
|
||||
mutation: DriveMutation::Relocate {
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
parent: args.parent,
|
||||
name: args.name,
|
||||
},
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
"DriveDelete" => {
|
||||
let args: EntryArgs = decode(input)?;
|
||||
let entry = self.observed(&args.entry, pinned)?;
|
||||
mutated_id = Some(entry.entry.node_id.clone());
|
||||
mutation_value(
|
||||
self.backend
|
||||
.mutate(DriveMutationRequest {
|
||||
request_id,
|
||||
mutation: DriveMutation::Delete {
|
||||
id: entry.entry,
|
||||
expected_revision: entry.revision,
|
||||
},
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
"DriveSaveWorkdir" => {
|
||||
let args: SaveArgs = decode(input)?;
|
||||
self.backend.validate_ref(&args.parent)?;
|
||||
// Drive API rechecks write at publication; source uses the selected
|
||||
// Workdir read broker, with no OS-path or command fallback.
|
||||
let selected = self
|
||||
.router
|
||||
.resolve(Some(&args.target_workdir))
|
||||
.map_err(|_| DriveError::Denied)?;
|
||||
if !selected
|
||||
.session
|
||||
.capabilities()
|
||||
.supports(WorkdirSessionCapability::Read)
|
||||
{
|
||||
return Err(DriveError::Denied);
|
||||
}
|
||||
if args.path.starts_with('/')
|
||||
|| args.path.contains('\\')
|
||||
|| args.path.split('/').any(|part| part == "..")
|
||||
{
|
||||
return Err(DriveError::Invalid(
|
||||
"logical Workdir-relative path required".into(),
|
||||
));
|
||||
}
|
||||
let path = WorkdirPath::new(&args.path).map_err(|_| {
|
||||
DriveError::Invalid("logical Workdir-relative path required".into())
|
||||
})?;
|
||||
let bytes = tools::view_image::read_workdir_bytes(
|
||||
selected.session.as_ref(),
|
||||
path,
|
||||
DRIVE_FILE_MAX_BYTES as usize,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
DriveError::Invalid(
|
||||
"Workdir source denied, changed, oversized or unavailable".into(),
|
||||
)
|
||||
})?;
|
||||
let query = DriveUploadQuery {
|
||||
operation: DriveUploadOperation::Create,
|
||||
request_id,
|
||||
entry_workspace_id: args.parent.workspace_id,
|
||||
parent_id: Some(args.parent.node_id),
|
||||
name: Some(args.name),
|
||||
id: None,
|
||||
expected_revision: None,
|
||||
content_type: args.content_type,
|
||||
size: bytes.len() as u32,
|
||||
sha256: digest(&bytes),
|
||||
};
|
||||
mutation_value(self.backend.upload(query, bytes).await?)
|
||||
}
|
||||
"DriveRequestStatus" => {
|
||||
let args: StatusArgs = decode(input)?;
|
||||
serde_json::to_value(self.backend.status(&args.request_id).await?)
|
||||
.map_err(|_| DriveError::Unavailable)?
|
||||
}
|
||||
_ => return Err(DriveError::Invalid("unknown operation".into())),
|
||||
};
|
||||
// A successful mutation does not silently substitute its new revision
|
||||
// into a prior observation. The caller observes explicitly next time.
|
||||
if let Some(id) = mutated_id {
|
||||
self.observations
|
||||
.lock()
|
||||
.map_err(|_| DriveError::Unavailable)?
|
||||
.remove(&id);
|
||||
}
|
||||
Ok(DriveOutput { value, attachments })
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct DriveOutput {
|
||||
pub value: Value,
|
||||
pub attachments: Vec<Attachment>,
|
||||
}
|
||||
impl DriveOutput {
|
||||
fn tool(self, name: &str) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput {
|
||||
summary: format!("{name} completed through Workspace Drive API"),
|
||||
content: Some(
|
||||
serde_json::to_string(&self.value)
|
||||
.map_err(|_| ToolError::Internal("Drive result encoding failed".into()))?,
|
||||
),
|
||||
attachments: self.attachments,
|
||||
})
|
||||
}
|
||||
}
|
||||
struct DriveTool {
|
||||
feature: DriveFeature,
|
||||
name: &'static str,
|
||||
}
|
||||
#[async_trait]
|
||||
impl Tool for DriveTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input: &str,
|
||||
context: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
self.feature
|
||||
.execute(self.name, input, &context, None)
|
||||
.await
|
||||
.map_err(tool_error)?
|
||||
.tool(self.name)
|
||||
}
|
||||
}
|
||||
fn tool_error(error: DriveError) -> ToolError {
|
||||
match error {
|
||||
DriveError::Conflict => ToolError::StructuredConflict {
|
||||
code: "drive_conflict".into(),
|
||||
message: error.to_string(),
|
||||
},
|
||||
DriveError::Invalid(_) | DriveError::Limit => ToolError::InvalidArgument(error.to_string()),
|
||||
_ => ToolError::ExecutionFailed(error.to_string()),
|
||||
}
|
||||
}
|
||||
impl FeatureModule for DriveFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID,"Workspace Drive").with_description("Explicit Backend-authorized Drive documents, images and artifact publication; no Workdir required").with_instruction(drive_instruction());
|
||||
for spec in SPECS {
|
||||
descriptor = descriptor.with_tool(ToolDeclaration::new(spec.name, spec.description));
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
context
|
||||
.instructions()
|
||||
.register(crate::feature::FeatureInstructionContribution::new(
|
||||
drive_instruction(),
|
||||
))?;
|
||||
for (name, definition) in self.tools() {
|
||||
context
|
||||
.tools()
|
||||
.register(ToolContribution::new(name, definition))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn drive_instruction() -> crate::feature::FeatureInstructionDeclaration {
|
||||
crate::feature::FeatureInstructionDeclaration::new(
|
||||
crate::feature::FeatureInstructionId::builtin("drive-workflow"),
|
||||
"common.drive",
|
||||
"Authorized Drive operation guidance",
|
||||
)
|
||||
.expect("static Drive instruction")
|
||||
}
|
||||
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EmptyArgs {}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EntryArgs {
|
||||
entry: DriveEntryRef,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ListArgs {
|
||||
#[serde(default)]
|
||||
parent: Option<DriveEntryRef>,
|
||||
#[serde(default)]
|
||||
#[schemars(range(min = 1, max = 200))]
|
||||
limit: Option<u32>,
|
||||
#[serde(default)]
|
||||
after: Option<String>,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ReadArgs {
|
||||
entry: DriveEntryRef,
|
||||
#[serde(default = "default_read")]
|
||||
#[schemars(range(min = 1, max = 65536))]
|
||||
max_bytes: u32,
|
||||
}
|
||||
fn default_read() -> u32 {
|
||||
DRIVE_CHUNK_MAX_BYTES
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct FolderArgs {
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CreateArgs {
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
content: String,
|
||||
#[serde(default = "default_media")]
|
||||
content_type: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WriteArgs {
|
||||
entry: DriveEntryRef,
|
||||
content: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct EditArgs {
|
||||
entry: DriveEntryRef,
|
||||
old_string: String,
|
||||
new_string: String,
|
||||
#[serde(default)]
|
||||
replace_all: bool,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RelocateArgs {
|
||||
entry: DriveEntryRef,
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SaveArgs {
|
||||
target_workdir: String,
|
||||
path: String,
|
||||
parent: DriveEntryRef,
|
||||
name: String,
|
||||
#[serde(default = "default_binary_media")]
|
||||
content_type: String,
|
||||
}
|
||||
fn default_binary_media() -> String {
|
||||
"application/octet-stream".into()
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StatusArgs {
|
||||
request_id: String,
|
||||
}
|
||||
fn default_media() -> String {
|
||||
"text/markdown".into()
|
||||
}
|
||||
fn decode<T: DeserializeOwned>(input: &str) -> Result<T, DriveError> {
|
||||
if input.len() > 512 * 1024 {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
serde_json::from_str(input)
|
||||
.map_err(|_| DriveError::Invalid("input shape or bound rejected".into()))
|
||||
}
|
||||
fn text_limits() -> fs_operation::text::TextLimits {
|
||||
fs_operation::text::TextLimits {
|
||||
max_input_bytes: Some(DRIVE_TEXT_MAX_BYTES),
|
||||
max_output_bytes: Some(DRIVE_TEXT_MAX_BYTES),
|
||||
max_replacements: None,
|
||||
}
|
||||
}
|
||||
fn checked_text(text: String) -> Result<String, DriveError> {
|
||||
fs_operation::text::write(text, text_limits())
|
||||
.map(|out| out.content)
|
||||
.map_err(text_error)
|
||||
}
|
||||
fn text_error(error: fs_operation::text::TextError) -> DriveError {
|
||||
DriveError::Invalid(error.to_string())
|
||||
}
|
||||
fn digest(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
pub(super) fn entry_path(entry: &DriveEntryRef) -> String {
|
||||
format!(
|
||||
"/drive/{}/{}",
|
||||
crate::feature::builtin::manage_workdir::wip::encode_identity(&entry.workspace_id),
|
||||
entry.node_id
|
||||
)
|
||||
}
|
||||
fn entry_value(entry: DriveEntry) -> Value {
|
||||
json!({"path":entry_path(&entry.entry),"metadata":entry})
|
||||
}
|
||||
fn page_value(page: DriveListResponse) -> Value {
|
||||
json!({"entries":page.entries.into_iter().map(entry_value).collect::<Vec<_>>(),"next_after":page.next_after})
|
||||
}
|
||||
fn mutation_value(response: DriveMutationResponse) -> Value {
|
||||
json!({"request_id":response.request_id,"entry":response.entry.map(entry_value)})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Spec {
|
||||
pub name: &'static str,
|
||||
pub operation: &'static str,
|
||||
pub description: &'static str,
|
||||
pub schema: fn() -> Value,
|
||||
}
|
||||
fn schema<T: JsonSchema>() -> Value {
|
||||
serde_json::to_value(schemars::schema_for!(T)).expect("static Drive argument schema")
|
||||
}
|
||||
pub(super) const SPECS: &[Spec] = &[
|
||||
Spec {
|
||||
name: "DriveRoot",
|
||||
operation: "root",
|
||||
description: "Discover the authorized Workspace Drive root. Profile activation is not a grant; no Workdir is required.",
|
||||
schema: schema::<EmptyArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveMetadata",
|
||||
operation: "metadata",
|
||||
description: "Inspect one Workspace-bound stable entry ID and observe its revision for mutation.",
|
||||
schema: schema::<EntryArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveList",
|
||||
operation: "list",
|
||||
description: "List a bounded folder page (default root), up to 200 entries. Results do not expand the WIP tree or observe revisions for writes.",
|
||||
schema: schema::<ListArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveSearch",
|
||||
operation: "search",
|
||||
description: "Search names and optionally bounded text through Backend paged search; no embedding/index. Use the returned cursor to continue.",
|
||||
schema: schema::<DriveSearchQuery>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveRead",
|
||||
operation: "read",
|
||||
description: "Read up to 64 KiB UTF-8 text and observe entry revision. Past Session text remains an immutable observation.",
|
||||
schema: schema::<ReadArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveCreateFolder",
|
||||
operation: "create_folder",
|
||||
description: "Create a folder through the explicit Drive write grant. Same-name conflicts are not overwritten.",
|
||||
schema: schema::<FolderArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveCreateText",
|
||||
operation: "create_text",
|
||||
description: "Create bounded UTF-8 text (default Markdown), <=64 KiB. Success means DB publication committed.",
|
||||
schema: schema::<CreateArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveWrite",
|
||||
operation: "write",
|
||||
description: "Replace <=64 KiB text using a previously read/metadata-observed revision. Conflicts are not retried with latest revision.",
|
||||
schema: schema::<WriteArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveEdit",
|
||||
operation: "edit",
|
||||
description: "Partial text edit using previously observed revision and shared old_string rules. Require nonempty unique match unless replace_all. Truncated preimages cannot be edited.",
|
||||
schema: schema::<EditArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveRelocate",
|
||||
operation: "relocate",
|
||||
description: "Rename and/or move an observed entry with revision precondition; stable ID and URL remain unchanged.",
|
||||
schema: schema::<RelocateArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveDelete",
|
||||
operation: "delete",
|
||||
description: "Delete one observed entry with revision precondition. A recreated name has a different ID; stale observations cannot target it.",
|
||||
schema: schema::<EntryArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveViewImage",
|
||||
operation: "view_image",
|
||||
description: "Attach revision-fixed PNG/JPEG/GIF/WebP bytes (<=10 MiB) through durable ToolOutput history/capture; a URL alone is not image viewing.",
|
||||
schema: schema::<EntryArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveSaveWorkdir",
|
||||
operation: "save_workdir",
|
||||
description: "Publish a <=16 MiB artifact using selected Workdir alias and logical path plus Drive parent/name. Both source read and destination write are required. No Bash/raw host path/base64 input.",
|
||||
schema: schema::<SaveArgs>,
|
||||
},
|
||||
Spec {
|
||||
name: "DriveRequestStatus",
|
||||
operation: "request_status",
|
||||
description: "Query the same mutation request ID after OutcomeUnknown. Uncommitted snapshot is not proof of failure; never blindly recreate the request.",
|
||||
schema: schema::<StatusArgs>,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,470 @@
|
||||
//! Identity-bound Drive API adapter. The Backend owns grants, hierarchy, CAS and
|
||||
//! receipts. In particular, a lost mutation response is queried, never resent.
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use crate::worker::{
|
||||
WorkspaceBinaryRequest, WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use server_api::*;
|
||||
|
||||
pub(super) const DEADLINE: Duration = Duration::from_secs(30);
|
||||
const RESPONSE_LIMIT: usize = 2 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DriveBackend {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, thiserror::Error)]
|
||||
pub enum DriveError {
|
||||
#[error("Drive access denied")]
|
||||
Denied,
|
||||
#[error("Drive resource not found")]
|
||||
NotFound,
|
||||
#[error("Drive revision or name conflict; observe explicitly before another operation")]
|
||||
Conflict,
|
||||
#[error("Invalid Drive arguments: {0}")]
|
||||
Invalid(String),
|
||||
#[error("Drive request exceeds a limit")]
|
||||
Limit,
|
||||
#[error("Drive storage or transport unavailable")]
|
||||
Unavailable,
|
||||
#[error(
|
||||
"Drive mutation outcome unknown; request_id={0}; query request_status; do not blindly repeat"
|
||||
)]
|
||||
OutcomeUnknown(String),
|
||||
}
|
||||
|
||||
impl DriveBackend {
|
||||
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
pub fn workspace(&self) -> Result<&str, DriveError> {
|
||||
self.client
|
||||
.workspace_id()
|
||||
.filter(|id| !id.is_empty() && self.client.is_available())
|
||||
.ok_or(DriveError::Denied)
|
||||
}
|
||||
fn base(&self) -> Result<String, DriveError> {
|
||||
Ok(format!("/api/w/{}/drive", encode(self.workspace()?)))
|
||||
}
|
||||
pub(super) fn validate_entry(&self, entry: &DriveEntry) -> Result<(), DriveError> {
|
||||
self.validate_ref(&entry.entry)?;
|
||||
if let Some(parent) = &entry.parent {
|
||||
self.validate_ref(parent)?;
|
||||
}
|
||||
if !decimal(&entry.revision) || entry.size.is_some_and(|n| n > DRIVE_FILE_MAX_BYTES) {
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn validate_ref(&self, entry: &DriveEntryRef) -> Result<(), DriveError> {
|
||||
if entry.workspace_id != self.workspace()? {
|
||||
return Err(DriveError::Denied);
|
||||
}
|
||||
if !decimal(&entry.node_id) {
|
||||
return Err(DriveError::Invalid(
|
||||
"canonical decimal node ID required".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn json<T: DeserializeOwned + Send + 'static>(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
mutation: bool,
|
||||
request_id: &str,
|
||||
) -> Result<T, DriveError> {
|
||||
let client = self.client.clone();
|
||||
let response = tokio::time::timeout(
|
||||
DEADLINE,
|
||||
tokio::task::spawn_blocking(move || client.execute_with_timeout(request, DEADLINE)),
|
||||
)
|
||||
.await;
|
||||
let unknown = || {
|
||||
if mutation {
|
||||
DriveError::OutcomeUnknown(request_id.into())
|
||||
} else {
|
||||
DriveError::Unavailable
|
||||
}
|
||||
};
|
||||
let response = response
|
||||
.map_err(|_| unknown())?
|
||||
.map_err(|_| unknown())?
|
||||
.map_err(|_| unknown())?;
|
||||
if response.body.len() > RESPONSE_LIMIT {
|
||||
return Err(if mutation {
|
||||
unknown()
|
||||
} else {
|
||||
DriveError::Limit
|
||||
});
|
||||
}
|
||||
if !response.is_success() {
|
||||
return Err(classify(
|
||||
response.status,
|
||||
response.body.as_bytes(),
|
||||
mutation,
|
||||
request_id,
|
||||
));
|
||||
}
|
||||
serde_json::from_str(&response.body).map_err(|_| unknown())
|
||||
}
|
||||
async fn get<T: DeserializeOwned + Send + 'static, Q: Serialize>(
|
||||
&self,
|
||||
suffix: &str,
|
||||
query: &Q,
|
||||
) -> Result<T, DriveError> {
|
||||
let query = query_string(query)?;
|
||||
self.json(
|
||||
WorkspaceRequest::get(format!("{}{suffix}{query}", self.base()?)),
|
||||
false,
|
||||
"",
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn root(&self) -> Result<DriveEntry, DriveError> {
|
||||
let entry: DriveEntry = self
|
||||
.json(
|
||||
WorkspaceRequest::get(format!("{}/root", self.base()?)),
|
||||
false,
|
||||
"",
|
||||
)
|
||||
.await?;
|
||||
self.validate_entry(&entry)?;
|
||||
if entry.kind != DriveEntryKind::Folder || entry.parent.is_some() {
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
Ok(entry)
|
||||
}
|
||||
pub async fn metadata(&self, entry: &DriveEntryRef) -> Result<DriveEntry, DriveError> {
|
||||
self.validate_ref(entry)?;
|
||||
let result: DriveEntry = self
|
||||
.get(
|
||||
"/metadata",
|
||||
&DriveEntryQuery {
|
||||
entry_workspace_id: entry.workspace_id.clone(),
|
||||
id: entry.node_id.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
self.validate_entry(&result)?;
|
||||
if result.entry != *entry {
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
pub async fn list(
|
||||
&self,
|
||||
parent: &DriveEntryRef,
|
||||
limit: Option<u32>,
|
||||
after: Option<String>,
|
||||
) -> Result<DriveListResponse, DriveError> {
|
||||
self.validate_ref(parent)?;
|
||||
let result: DriveListResponse = self
|
||||
.get(
|
||||
"/list",
|
||||
&DriveListQuery {
|
||||
entry_workspace_id: parent.workspace_id.clone(),
|
||||
id: parent.node_id.clone(),
|
||||
limit,
|
||||
after,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
self.validate_page(
|
||||
&result,
|
||||
limit
|
||||
.unwrap_or(DRIVE_PAGE_MAX_LIMIT)
|
||||
.min(DRIVE_PAGE_MAX_LIMIT),
|
||||
)?;
|
||||
if result
|
||||
.entries
|
||||
.iter()
|
||||
.any(|entry| entry.parent.as_ref() != Some(parent))
|
||||
{
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
pub async fn search(&self, query: DriveSearchQuery) -> Result<DriveListResponse, DriveError> {
|
||||
let result: DriveListResponse = self.get("/search", &query).await?;
|
||||
self.validate_page(
|
||||
&result,
|
||||
query
|
||||
.limit
|
||||
.unwrap_or(DRIVE_SEARCH_MAX_LIMIT)
|
||||
.min(DRIVE_SEARCH_MAX_LIMIT),
|
||||
)?;
|
||||
Ok(result)
|
||||
}
|
||||
fn validate_page(&self, page: &DriveListResponse, limit: u32) -> Result<(), DriveError> {
|
||||
if page.entries.len() > limit as usize {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
for entry in &page.entries {
|
||||
self.validate_entry(entry)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub async fn read(
|
||||
&self,
|
||||
entry: &DriveEntryRef,
|
||||
max_bytes: u32,
|
||||
) -> Result<DriveReadTextResponse, DriveError> {
|
||||
self.validate_ref(entry)?;
|
||||
let result: DriveReadTextResponse = self
|
||||
.get(
|
||||
"/read-text",
|
||||
&DriveReadTextQuery {
|
||||
entry_workspace_id: entry.workspace_id.clone(),
|
||||
id: entry.node_id.clone(),
|
||||
max_bytes,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
self.validate_entry(&result.entry)?;
|
||||
if result.entry.entry != *entry || result.text.len() > max_bytes as usize {
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
pub async fn bytes(&self, entry: &DriveEntry, limit: usize) -> Result<Vec<u8>, DriveError> {
|
||||
self.validate_entry(entry)?;
|
||||
let total = entry
|
||||
.size
|
||||
.ok_or_else(|| DriveError::Invalid("file required".into()))?;
|
||||
if total as usize > limit {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(total as usize);
|
||||
// Even an empty file gets a revision-fixed read and current authorization.
|
||||
loop {
|
||||
let offset = bytes.len() as u32;
|
||||
let length = total.saturating_sub(offset).clamp(1, DRIVE_CHUNK_MAX_BYTES);
|
||||
let query = query_string(&DriveReadChunkQuery {
|
||||
entry_workspace_id: entry.entry.workspace_id.clone(),
|
||||
id: entry.entry.node_id.clone(),
|
||||
expected_revision: entry.revision.clone(),
|
||||
offset,
|
||||
length,
|
||||
})?;
|
||||
let request = WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Get,
|
||||
path: format!("{}/read-chunk{query}", self.base()?),
|
||||
body: None,
|
||||
max_response_bytes: DRIVE_CHUNK_MAX_BYTES as usize,
|
||||
};
|
||||
let client = self.client.clone();
|
||||
let response = tokio::time::timeout(
|
||||
DEADLINE,
|
||||
tokio::task::spawn_blocking(move || {
|
||||
client.execute_binary_with_timeout(request, DEADLINE)
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| DriveError::Unavailable)?
|
||||
.map_err(|_| DriveError::Unavailable)?
|
||||
.map_err(|_| DriveError::Unavailable)?;
|
||||
if !(200..300).contains(&response.status) {
|
||||
return Err(classify(response.status, &response.body, false, ""));
|
||||
}
|
||||
let expected = total.saturating_sub(offset).min(length) as usize;
|
||||
if response.body.len() != expected {
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
bytes.extend_from_slice(&response.body);
|
||||
if bytes.len() == total as usize {
|
||||
return Ok(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn reconcile(
|
||||
&self,
|
||||
id: &str,
|
||||
result: Result<DriveMutationResponse, DriveError>,
|
||||
) -> Result<DriveMutationResponse, DriveError> {
|
||||
// A parseable success can still be an invalid completion. Classify it
|
||||
// before reconciliation so it also gets one original-request inquiry.
|
||||
let result = result.and_then(|response| {
|
||||
self.validate_completion(id, &response)
|
||||
.map_err(|_| DriveError::OutcomeUnknown(id.into()))?;
|
||||
Ok(response)
|
||||
});
|
||||
match result {
|
||||
Err(DriveError::OutcomeUnknown(_)) => match self.status(id).await {
|
||||
Ok(DriveRequestStatusResponse {
|
||||
state: DriveRequestState::Committed,
|
||||
response: Some(response),
|
||||
..
|
||||
}) => Ok(response), // status validates both IDs and entry metadata
|
||||
_ => Err(DriveError::OutcomeUnknown(id.into())),
|
||||
},
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
fn validate_completion(
|
||||
&self,
|
||||
id: &str,
|
||||
response: &DriveMutationResponse,
|
||||
) -> Result<(), DriveError> {
|
||||
if response.request_id != id {
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
if let Some(entry) = &response.entry {
|
||||
self.validate_entry(entry)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub async fn mutate(
|
||||
&self,
|
||||
request: DriveMutationRequest,
|
||||
) -> Result<DriveMutationResponse, DriveError> {
|
||||
let body = serde_json::to_string(&request)
|
||||
.map_err(|_| DriveError::Invalid("invalid mutation".into()))?;
|
||||
if body.len() > 512 * 1024 {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
let result = self
|
||||
.json(
|
||||
WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{}/mutate", self.base()?),
|
||||
body,
|
||||
),
|
||||
true,
|
||||
&request.request_id,
|
||||
)
|
||||
.await;
|
||||
self.reconcile(&request.request_id, result).await
|
||||
}
|
||||
pub async fn upload(
|
||||
&self,
|
||||
query: DriveUploadQuery,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<DriveMutationResponse, DriveError> {
|
||||
if bytes.len() > DRIVE_FILE_MAX_BYTES as usize {
|
||||
return Err(DriveError::Limit);
|
||||
}
|
||||
let path = format!("{}/upload{}", self.base()?, query_string(&query)?);
|
||||
let request = WorkspaceBinaryRequest {
|
||||
method: WorkspaceRequestMethod::Put,
|
||||
path,
|
||||
body: Some(bytes),
|
||||
max_response_bytes: RESPONSE_LIMIT,
|
||||
};
|
||||
let client = self.client.clone();
|
||||
let result = tokio::time::timeout(
|
||||
DEADLINE,
|
||||
tokio::task::spawn_blocking(move || {
|
||||
client.execute_binary_with_timeout(request, DEADLINE)
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let result = match result {
|
||||
Ok(Ok(Ok(response))) if (200..300).contains(&response.status) => {
|
||||
serde_json::from_slice(&response.body)
|
||||
.map_err(|_| DriveError::OutcomeUnknown(query.request_id.clone()))
|
||||
}
|
||||
Ok(Ok(Ok(response))) => Err(classify(
|
||||
response.status,
|
||||
&response.body,
|
||||
true,
|
||||
&query.request_id,
|
||||
)),
|
||||
_ => Err(DriveError::OutcomeUnknown(query.request_id.clone())),
|
||||
};
|
||||
self.reconcile(&query.request_id, result).await
|
||||
}
|
||||
pub async fn status(&self, request_id: &str) -> Result<DriveRequestStatusResponse, DriveError> {
|
||||
if request_id.is_empty() || request_id.len() > 256 {
|
||||
return Err(DriveError::Invalid("invalid request ID".into()));
|
||||
}
|
||||
let response: DriveRequestStatusResponse = self
|
||||
.json(
|
||||
WorkspaceRequest::get(format!("{}/requests/{}", self.base()?, encode(request_id))),
|
||||
false,
|
||||
"",
|
||||
)
|
||||
.await?;
|
||||
if response.request_id != request_id {
|
||||
return Err(DriveError::Unavailable);
|
||||
}
|
||||
if let Some(result) = &response.response {
|
||||
self.validate_completion(request_id, result)?;
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn decimal(value: &str) -> bool {
|
||||
value
|
||||
.as_bytes()
|
||||
.first()
|
||||
.is_some_and(|c| matches!(c, b'1'..=b'9'))
|
||||
&& value.bytes().all(|c| c.is_ascii_digit())
|
||||
&& value.parse::<i64>().is_ok_and(|id| id > 0)
|
||||
}
|
||||
pub(super) fn encode(value: &str) -> String {
|
||||
value
|
||||
.bytes()
|
||||
.map(|b| {
|
||||
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_') {
|
||||
(b as char).to_string()
|
||||
} else {
|
||||
format!("%{b:02X}")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn query_string<T: Serialize>(value: &T) -> Result<String, DriveError> {
|
||||
let value =
|
||||
serde_json::to_value(value).map_err(|_| DriveError::Invalid("invalid query".into()))?;
|
||||
let object = value
|
||||
.as_object()
|
||||
.ok_or_else(|| DriveError::Invalid("invalid query".into()))?;
|
||||
let pairs: Vec<_> = object
|
||||
.iter()
|
||||
.filter(|(_, v)| !v.is_null())
|
||||
.map(|(k, v)| {
|
||||
format!(
|
||||
"{}={}",
|
||||
encode(k),
|
||||
encode(
|
||||
v.as_str()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| v.to_string())
|
||||
.as_str()
|
||||
)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Ok(if pairs.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("?{}", pairs.join("&"))
|
||||
})
|
||||
}
|
||||
fn classify(status: u16, body: &[u8], mutation: bool, id: &str) -> DriveError {
|
||||
if let Ok(error) = serde_json::from_slice::<DriveApiError>(body) {
|
||||
if error.classification == DriveFailureClassification::Unknown {
|
||||
return DriveError::OutcomeUnknown(id.into());
|
||||
}
|
||||
return match error.code {
|
||||
DriveApiErrorCode::Denied => DriveError::Denied,
|
||||
DriveApiErrorCode::NotFound => DriveError::NotFound,
|
||||
DriveApiErrorCode::Conflict => DriveError::Conflict,
|
||||
DriveApiErrorCode::Invalid => DriveError::Invalid("Backend rejected arguments".into()),
|
||||
DriveApiErrorCode::Limit => DriveError::Limit,
|
||||
DriveApiErrorCode::StorageUnavailable => DriveError::Unavailable,
|
||||
DriveApiErrorCode::OutcomeUnknown => DriveError::OutcomeUnknown(id.into()),
|
||||
};
|
||||
}
|
||||
if matches!(status, 401 | 403) {
|
||||
DriveError::Denied
|
||||
} else if mutation {
|
||||
DriveError::OutcomeUnknown(id.into())
|
||||
} else {
|
||||
DriveError::Unavailable
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
//! Fail-closed Drive boundary for parent-owned Internal SubWorkers, including Reviewers.
|
||||
//!
|
||||
//! Internal children have no Backend Drive identity. Explicit internal Drive delegation is not
|
||||
//! supported until the Backend can approve scope bound to that child identity. A parent's current
|
||||
//! grant, enabled profile, or Reviewer attestation is not a child Drive grant. Registered Runtime
|
||||
//! Workers must use their own identity-bound, current Backend grants; this wrapper creates neither
|
||||
//! identities nor grants and keeps no local authorization cache.
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use crate::worker::{
|
||||
ReviewerContext, WorkerWorkspaceContext, WorkspaceBinaryRequest, WorkspaceBinaryResponse,
|
||||
WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution, WorkspaceRequest,
|
||||
WorkspaceResponse, WorkspaceServerOperation, WorkspaceWorkerDiscoveryRequest,
|
||||
};
|
||||
|
||||
/// Removes Drive authority without changing the selected client's other authority or behavior.
|
||||
/// Install outside any Reviewer client so its trusted review submission attestation is preserved.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct InternalChildWorkspaceClient {
|
||||
inner: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
impl InternalChildWorkspaceClient {
|
||||
pub(crate) fn new(inner: Arc<dyn WorkspaceClient>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
|
||||
fn check_path(path: &str) -> Result<(), WorkspaceClientError> {
|
||||
// Classify the route, not the method: even readonly Drive access must not inherit the
|
||||
// parent's identity. Decode route segments and normalize dot segments before checking,
|
||||
// so transport/router normalization cannot turn an allowed alias into a Drive request.
|
||||
let path = path.split(['?', '#']).next().unwrap_or(path);
|
||||
let mut decoded = Vec::with_capacity(path.len());
|
||||
// HTTP URL parsers discard literal tabs/newlines before route normalization.
|
||||
let bytes: Vec<_> = path
|
||||
.bytes()
|
||||
.filter(|byte| !matches!(byte, b'\t' | b'\r' | b'\n'))
|
||||
.collect();
|
||||
let mut index = 0;
|
||||
while index < bytes.len() {
|
||||
if bytes[index] == b'%'
|
||||
&& index + 2 < bytes.len()
|
||||
&& let (Some(high), Some(low)) =
|
||||
(hex_digit(bytes[index + 1]), hex_digit(bytes[index + 2]))
|
||||
{
|
||||
decoded.push(high * 16 + low);
|
||||
index += 3;
|
||||
} else {
|
||||
decoded.push(bytes[index]);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
let decoded = String::from_utf8_lossy(&decoded).replace('\\', "/");
|
||||
let mut segments = Vec::new();
|
||||
for segment in decoded.split('/') {
|
||||
match segment {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
segments.pop();
|
||||
}
|
||||
segment => segments.push(segment),
|
||||
}
|
||||
}
|
||||
if matches!(segments.as_slice(), ["api", "w", _, "drive", ..]) {
|
||||
return Err(WorkspaceClientError::Unavailable(
|
||||
"Internal SubWorker Drive access denied: no identity-bound Backend-approved child scope; explicit internal Drive delegation is not supported".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_digit(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep Workspace identity and prompt context, but never inherit Drive authority.
|
||||
pub(crate) fn restrict_internal_child_drive(
|
||||
context: WorkerWorkspaceContext,
|
||||
) -> WorkerWorkspaceContext {
|
||||
WorkerWorkspaceContext::with_client(
|
||||
context.workspace_id().cloned(),
|
||||
Arc::new(InternalChildWorkspaceClient::new(context.client_handle())),
|
||||
)
|
||||
}
|
||||
|
||||
impl WorkspaceClient for InternalChildWorkspaceClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
self.inner.workspace_id()
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
self.inner.kind()
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
self.inner.is_available()
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
Self::check_path(&request.path)?;
|
||||
self.inner.execute(request)
|
||||
}
|
||||
|
||||
fn execute_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
Self::check_path(&request.path)?;
|
||||
self.inner.execute_with_timeout(request, timeout)
|
||||
}
|
||||
|
||||
fn execute_binary(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
Self::check_path(&request.path)?;
|
||||
self.inner.execute_binary(request)
|
||||
}
|
||||
|
||||
fn execute_binary_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
Self::check_path(&request.path)?;
|
||||
self.inner.execute_binary_with_timeout(request, timeout)
|
||||
}
|
||||
|
||||
fn execute_server_operation(
|
||||
&self,
|
||||
operation: WorkspaceServerOperation,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.inner.execute_server_operation(operation)
|
||||
}
|
||||
|
||||
fn current_prompt_projection(
|
||||
&self,
|
||||
minimum_revision: Option<u64>,
|
||||
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
|
||||
self.inner.current_prompt_projection(minimum_revision)
|
||||
}
|
||||
|
||||
fn list_workspace_workers(
|
||||
&self,
|
||||
request: WorkspaceWorkerDiscoveryRequest,
|
||||
) -> Result<server_api::WorkspaceWorkerDiscoveryPage, WorkspaceClientError> {
|
||||
self.inner.list_workspace_workers(request)
|
||||
}
|
||||
|
||||
fn execute_worker_remove(
|
||||
&self,
|
||||
target_runtime_id: &str,
|
||||
target_worker_id: &str,
|
||||
reason: &str,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.inner
|
||||
.execute_worker_remove(target_runtime_id, target_worker_id, reason)
|
||||
}
|
||||
|
||||
fn reviewer_context(&self) -> Option<&ReviewerContext> {
|
||||
self.inner.reviewer_context()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::worker::{ReviewerChildWorkspaceClient, WorkspaceRequestMethod};
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Call {
|
||||
Json(WorkspaceRequest, Option<Duration>),
|
||||
Binary(WorkspaceBinaryRequest, Option<Duration>),
|
||||
Projection(Option<u64>),
|
||||
Discovery(WorkspaceWorkerDiscoveryRequest),
|
||||
Remove(String, String, String),
|
||||
Server,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct RecordingClient {
|
||||
calls: Mutex<Vec<Call>>,
|
||||
}
|
||||
|
||||
impl RecordingClient {
|
||||
fn response(&self, call: Call) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.calls.lock().unwrap().push(call);
|
||||
Ok(WorkspaceResponse {
|
||||
status: 202,
|
||||
body: "forwarded".into(),
|
||||
})
|
||||
}
|
||||
fn binary_response(
|
||||
&self,
|
||||
call: Call,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.calls.lock().unwrap().push(call);
|
||||
Ok(WorkspaceBinaryResponse {
|
||||
status: 206,
|
||||
body: vec![0, 255],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceClient for RecordingClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("ws")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"recording"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.response(Call::Json(request, None))
|
||||
}
|
||||
fn execute_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.response(Call::Json(request, Some(timeout)))
|
||||
}
|
||||
fn execute_binary(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.binary_response(Call::Binary(request, None))
|
||||
}
|
||||
fn execute_binary_with_timeout(
|
||||
&self,
|
||||
request: WorkspaceBinaryRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.binary_response(Call::Binary(request, Some(timeout)))
|
||||
}
|
||||
fn current_prompt_projection(
|
||||
&self,
|
||||
revision: Option<u64>,
|
||||
) -> Result<Option<WorkspacePromptCatalogResolution>, WorkspaceClientError> {
|
||||
self.calls.lock().unwrap().push(Call::Projection(revision));
|
||||
Err(WorkspaceClientError::Request("projection marker".into()))
|
||||
}
|
||||
fn list_workspace_workers(
|
||||
&self,
|
||||
request: WorkspaceWorkerDiscoveryRequest,
|
||||
) -> Result<server_api::WorkspaceWorkerDiscoveryPage, WorkspaceClientError> {
|
||||
self.calls.lock().unwrap().push(Call::Discovery(request));
|
||||
Err(WorkspaceClientError::Request("discovery marker".into()))
|
||||
}
|
||||
fn execute_worker_remove(
|
||||
&self,
|
||||
runtime: &str,
|
||||
worker: &str,
|
||||
reason: &str,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.response(Call::Remove(runtime.into(), worker.into(), reason.into()))
|
||||
}
|
||||
fn execute_server_operation(
|
||||
&self,
|
||||
_: WorkspaceServerOperation,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.response(Call::Server)
|
||||
}
|
||||
}
|
||||
|
||||
fn binary(method: WorkspaceRequestMethod, path: &str) -> WorkspaceBinaryRequest {
|
||||
WorkspaceBinaryRequest {
|
||||
method,
|
||||
path: path.into(),
|
||||
body: Some(vec![0, 128, 255]),
|
||||
max_response_bytes: 17,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_children_deny_all_drive_transports_without_parent_side_effects() {
|
||||
let inner = Arc::new(RecordingClient::default());
|
||||
for reviewer in [false, true] {
|
||||
let selected: Arc<dyn WorkspaceClient> = if reviewer {
|
||||
Arc::new(ReviewerChildWorkspaceClient::new(
|
||||
inner.clone(),
|
||||
ReviewerContext {
|
||||
ticket_id: "T1".into(),
|
||||
merge_request_id: "MR1".into(),
|
||||
},
|
||||
"attestation".into(),
|
||||
))
|
||||
} else {
|
||||
inner.clone()
|
||||
};
|
||||
let client = InternalChildWorkspaceClient::new(selected);
|
||||
for path in [
|
||||
"/api/w/ws/drive",
|
||||
"/api/w/ws/drive?limit=1",
|
||||
"/api/w/other/drive/root",
|
||||
"/api/w/ws/drive/read-chunk?id=1",
|
||||
"/api/w/ws/drive/upload",
|
||||
"/api/w/ws/drive/requests/request-id",
|
||||
"/api/w/ws/%64rive/root",
|
||||
"/api/w/ws/tickets/../drive/root",
|
||||
"/api/w/ws/tickets/%2e%2e/drive/root",
|
||||
"/api/w/ws%2fdrive/root",
|
||||
"/api/w/ws/dr\tive/root",
|
||||
"/api/w/ws/dri\r\nve/root",
|
||||
] {
|
||||
for method in [
|
||||
WorkspaceRequestMethod::Get,
|
||||
WorkspaceRequestMethod::Post,
|
||||
WorkspaceRequestMethod::Put,
|
||||
WorkspaceRequestMethod::Patch,
|
||||
WorkspaceRequestMethod::Delete,
|
||||
] {
|
||||
let request = WorkspaceRequest::json(method, path, "{}");
|
||||
for result in [
|
||||
client.execute(request.clone()),
|
||||
client.execute_with_timeout(request, Duration::from_secs(2)),
|
||||
] {
|
||||
assert!(
|
||||
matches!(result, Err(WorkspaceClientError::Unavailable(message)) if message.contains("Drive access denied")),
|
||||
"path={path}, reviewer={reviewer}"
|
||||
);
|
||||
}
|
||||
for result in [
|
||||
client.execute_binary(binary(method, path)),
|
||||
client.execute_binary_with_timeout(
|
||||
binary(method, path),
|
||||
Duration::from_secs(2),
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
matches!(result, Err(WorkspaceClientError::Unavailable(message)) if message.contains("Drive access denied")),
|
||||
"path={path}, reviewer={reviewer}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(inner.calls.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_drive_requests_preserve_payloads_deadlines_and_special_operations() {
|
||||
let inner = Arc::new(RecordingClient::default());
|
||||
let client = InternalChildWorkspaceClient::new(inner.clone());
|
||||
let request = WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"/api/w/ws/tickets/query",
|
||||
"{\"query\":\"drive\"}",
|
||||
);
|
||||
let bytes = binary(WorkspaceRequestMethod::Put, "/api/w/ws/files/content");
|
||||
let timeout = Duration::from_millis(37);
|
||||
let expected = WorkspaceResponse {
|
||||
status: 202,
|
||||
body: "forwarded".into(),
|
||||
};
|
||||
assert_eq!(client.execute(request.clone()).unwrap(), expected);
|
||||
assert_eq!(
|
||||
client
|
||||
.execute_with_timeout(request.clone(), timeout)
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
let expected_bytes = WorkspaceBinaryResponse {
|
||||
status: 206,
|
||||
body: vec![0, 255],
|
||||
};
|
||||
assert_eq!(
|
||||
client.execute_binary(bytes.clone()).unwrap(),
|
||||
expected_bytes
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.execute_binary_with_timeout(bytes.clone(), timeout)
|
||||
.unwrap(),
|
||||
expected_bytes
|
||||
);
|
||||
assert!(
|
||||
matches!(client.current_prompt_projection(Some(42)), Err(WorkspaceClientError::Request(message)) if message == "projection marker")
|
||||
);
|
||||
let discovery = WorkspaceWorkerDiscoveryRequest {
|
||||
cursor: Some("next".into()),
|
||||
limit: 3,
|
||||
query: Some("coder".into()),
|
||||
};
|
||||
assert!(
|
||||
matches!(client.list_workspace_workers(discovery.clone()), Err(WorkspaceClientError::Request(message)) if message == "discovery marker")
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.execute_worker_remove("runtime", "worker", "reason")
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
client
|
||||
.execute_server_operation(WorkspaceServerOperation::WorkerControlList)
|
||||
.unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(client.workspace_id(), Some("ws"));
|
||||
assert_eq!(client.kind(), "recording");
|
||||
assert!(client.is_available());
|
||||
assert_eq!(
|
||||
*inner.calls.lock().unwrap(),
|
||||
vec![
|
||||
Call::Json(request.clone(), None),
|
||||
Call::Json(request, Some(timeout)),
|
||||
Call::Binary(bytes.clone(), None),
|
||||
Call::Binary(bytes, Some(timeout)),
|
||||
Call::Projection(Some(42)),
|
||||
Call::Discovery(discovery),
|
||||
Call::Remove("runtime".into(), "worker".into(), "reason".into()),
|
||||
Call::Server,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_restriction_preserves_reviewer_context_and_review_attestation() {
|
||||
let inner = Arc::new(RecordingClient::default());
|
||||
let context = ReviewerContext {
|
||||
ticket_id: "T1".into(),
|
||||
merge_request_id: "MR1".into(),
|
||||
};
|
||||
let client =
|
||||
InternalChildWorkspaceClient::new(Arc::new(ReviewerChildWorkspaceClient::new(
|
||||
inner.clone(),
|
||||
context.clone(),
|
||||
"trusted-token".into(),
|
||||
)));
|
||||
assert_eq!(client.reviewer_context(), Some(&context));
|
||||
assert_eq!(client.kind(), "runtime-reviewer-child");
|
||||
let request = WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
"/api/w/ws/merge-requests/MR1/reviews",
|
||||
"{\"verdict\":\"approve\"}",
|
||||
);
|
||||
// Reviewer timeout execution uses its existing JSON attestation path, not raw parent access.
|
||||
client
|
||||
.execute_with_timeout(request, Duration::from_secs(1))
|
||||
.unwrap();
|
||||
let calls = inner.calls.lock().unwrap();
|
||||
let [Call::Json(request, None)] = calls.as_slice() else {
|
||||
panic!("unexpected calls: {calls:?}")
|
||||
};
|
||||
let body: serde_json::Value = serde_json::from_str(request.body.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(body["capability_token"], "trusted-token");
|
||||
assert_eq!(body["verdict"], "approve");
|
||||
drop(calls);
|
||||
let count = inner.calls.lock().unwrap().len();
|
||||
assert!(
|
||||
client
|
||||
.execute(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Delete,
|
||||
"/api/w/ws/tickets/T1",
|
||||
"{}"
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(inner.calls.lock().unwrap().len(), count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,651 @@
|
||||
use super::*;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
|
||||
use serde::Serialize;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Client {
|
||||
responses: Mutex<VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>,
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
}
|
||||
impl Client {
|
||||
fn new(responses: Vec<WorkspaceResponse>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
responses: Mutex::new(responses.into_iter().map(Ok).collect()),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
}
|
||||
impl WorkspaceClient for Client {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"drive-test"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("unexpected Backend call")
|
||||
}
|
||||
}
|
||||
fn response(value: impl Serialize) -> WorkspaceResponse {
|
||||
WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::to_string(&value).unwrap(),
|
||||
}
|
||||
}
|
||||
fn entry(id: &str, revision: &str) -> DriveEntry {
|
||||
DriveEntry {
|
||||
entry: DriveEntryRef {
|
||||
workspace_id: "workspace".into(),
|
||||
node_id: id.into(),
|
||||
},
|
||||
parent: Some(DriveEntryRef {
|
||||
workspace_id: "workspace".into(),
|
||||
node_id: "1".into(),
|
||||
}),
|
||||
name: "note.md".into(),
|
||||
kind: DriveEntryKind::File,
|
||||
revision: revision.into(),
|
||||
size: Some(5),
|
||||
content_type: Some("text/markdown".into()),
|
||||
updated_by: "worker".into(),
|
||||
updated_at: "now".into(),
|
||||
latest_url: format!("/api/w/workspace/drive/download?id={id}"),
|
||||
}
|
||||
}
|
||||
fn feature(client: Arc<Client>) -> DriveFeature {
|
||||
DriveFeature::new(client, Arc::new(WorkdirSessionRouter::new()))
|
||||
}
|
||||
async fn execute(
|
||||
feature: &DriveFeature,
|
||||
name: &str,
|
||||
input: Value,
|
||||
) -> Result<DriveOutput, DriveError> {
|
||||
feature
|
||||
.execute(
|
||||
name,
|
||||
&input.to_string(),
|
||||
&ToolExecutionContext::new(name, "batch", 0),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drive_feature_activation_never_creates_authority_and_schemas_hide_revisions() {
|
||||
let client = Client::new(Vec::new());
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
assert!(DriveFeature::configured(client.clone(), false, router.clone()).is_none());
|
||||
assert!(
|
||||
DriveFeature::configured(
|
||||
crate::worker::unavailable_workspace_client(None, "standalone"),
|
||||
true,
|
||||
router
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
let feature = feature(client);
|
||||
for (name, definition) in feature.tools() {
|
||||
let (meta, _) = definition();
|
||||
assert_eq!(name, meta.name);
|
||||
let schema = meta.input_schema.to_string();
|
||||
assert!(!schema.contains("expected_revision"), "{name}");
|
||||
assert!(!schema.contains("grant_id"), "{name}");
|
||||
}
|
||||
assert_eq!(feature.descriptor().instructions.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn foreign_or_unobserved_entry_cannot_dispatch_mutation() {
|
||||
let client = Client::new(Vec::new());
|
||||
let feature = feature(client.clone());
|
||||
let own = json!({"workspace_id":"workspace","node_id":"9007199254740993"});
|
||||
assert!(matches!(
|
||||
execute(&feature, "DriveWrite", json!({"entry":own,"content":"new"})).await,
|
||||
Err(DriveError::Invalid(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
execute(
|
||||
&feature,
|
||||
"DriveDelete",
|
||||
json!({"entry":{"workspace_id":"other","node_id":"1"}})
|
||||
)
|
||||
.await,
|
||||
Err(DriveError::Denied)
|
||||
));
|
||||
for id in [json!(1), json!("01"), json!("9223372036854775808")] {
|
||||
assert!(matches!(
|
||||
execute(
|
||||
&feature,
|
||||
"DriveMetadata",
|
||||
json!({"entry":{"workspace_id":"workspace","node_id":id}})
|
||||
)
|
||||
.await,
|
||||
Err(DriveError::Invalid(_))
|
||||
));
|
||||
}
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_edit_returns_conflict_without_upgrading_revision_or_dispatching_write() {
|
||||
let old = entry("2", "1");
|
||||
let current = entry("2", "2");
|
||||
let client = Client::new(vec![response(DriveReadTextResponse {
|
||||
entry: current,
|
||||
text: "hello".into(),
|
||||
truncated: false,
|
||||
})]);
|
||||
let feature = feature(client.clone());
|
||||
feature.observe(old.clone()).unwrap();
|
||||
let result = execute(
|
||||
&feature,
|
||||
"DriveEdit",
|
||||
json!({"entry":old.entry,"old_string":"hello","new_string":"new"}),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result, Err(DriveError::Conflict)));
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(feature.observed(&old.entry, None).unwrap().revision, "1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_edit_rules_reject_ambiguous_and_truncated_preimages_without_mutation() {
|
||||
for (text, truncated) in [("hello hello", false), ("hello", true)] {
|
||||
let old = entry("2", "1");
|
||||
let client = Client::new(vec![response(DriveReadTextResponse {
|
||||
entry: old.clone(),
|
||||
text: text.into(),
|
||||
truncated,
|
||||
})]);
|
||||
let feature = feature(client.clone());
|
||||
feature.observe(old.clone()).unwrap();
|
||||
assert!(
|
||||
execute(
|
||||
&feature,
|
||||
"DriveEdit",
|
||||
json!({"entry":old.entry,"old_string":"hello","new_string":"new"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
client
|
||||
.requests
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|r| r.method == crate::worker::WorkspaceRequestMethod::Get)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn successful_mutation_uses_observed_decimal_revision_and_requires_next_explicit_observation()
|
||||
{
|
||||
let old = entry("9007199254740993", "9007199254740993");
|
||||
let updated = entry("9007199254740993", "9007199254740994");
|
||||
let client = Client::new(Vec::new());
|
||||
let feature = feature(client.clone());
|
||||
feature.observe(old.clone()).unwrap();
|
||||
let context = ToolExecutionContext::new("write", "batch", 0);
|
||||
let id = feature.request_id(&context);
|
||||
client
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push_back(Ok(response(DriveMutationResponse {
|
||||
request_id: id,
|
||||
entry: Some(updated),
|
||||
})));
|
||||
feature
|
||||
.execute(
|
||||
"DriveWrite",
|
||||
&json!({"entry":old.entry,"content":"new"}).to_string(),
|
||||
&context,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let requests = client.requests.lock().unwrap();
|
||||
let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(body["mutation"]["expected_revision"], "9007199254740993");
|
||||
assert_eq!(body["mutation"]["id"]["node_id"], "9007199254740993");
|
||||
drop(requests);
|
||||
assert!(feature.observed(&old.entry, None).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lost_mutation_response_queries_same_receipt_without_resending() {
|
||||
let old = entry("2", "1");
|
||||
let client = Client::new(Vec::new());
|
||||
let feature = feature(client.clone());
|
||||
feature.observe(old.clone()).unwrap();
|
||||
let context = ToolExecutionContext::new("write", "batch", 0);
|
||||
let id = feature.request_id(&context);
|
||||
client.responses.lock().unwrap().extend([
|
||||
Err(WorkspaceClientError::Request("lost".into())),
|
||||
Ok(response(DriveRequestStatusResponse {
|
||||
request_id: id.clone(),
|
||||
state: DriveRequestState::Committed,
|
||||
response: Some(DriveMutationResponse {
|
||||
request_id: id.clone(),
|
||||
entry: Some(entry("2", "2")),
|
||||
}),
|
||||
})),
|
||||
]);
|
||||
feature
|
||||
.execute(
|
||||
"DriveWrite",
|
||||
&json!({"entry":old.entry,"content":"new"}).to_string(),
|
||||
&context,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert!(requests[1].path.ends_with(&format!("/requests/{id}")));
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|r| r.method == crate::worker::WorkspaceRequestMethod::Post)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uncommitted_receipt_snapshot_does_not_turn_unknown_into_failure_or_retry() {
|
||||
let old = entry("2", "1");
|
||||
let client = Client::new(Vec::new());
|
||||
let feature = feature(client.clone());
|
||||
feature.observe(old.clone()).unwrap();
|
||||
let context = ToolExecutionContext::new("write", "batch", 0);
|
||||
let id = feature.request_id(&context);
|
||||
client.responses.lock().unwrap().extend([
|
||||
Err(WorkspaceClientError::Request("lost".into())),
|
||||
Ok(response(DriveRequestStatusResponse {
|
||||
request_id: id.clone(),
|
||||
state: DriveRequestState::Uncommitted,
|
||||
response: None,
|
||||
})),
|
||||
]);
|
||||
let result = feature
|
||||
.execute(
|
||||
"DriveWrite",
|
||||
&json!({"entry":old.entry,"content":"new"}).to_string(),
|
||||
&context,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(result,Err(DriveError::OutcomeUnknown(ref observed)) if observed==&id));
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
fn invalid_completions(id: &str) -> Vec<DriveMutationResponse> {
|
||||
let mut invalid_entry = entry("2", "2");
|
||||
invalid_entry.revision = "not-a-revision".into();
|
||||
vec![
|
||||
DriveMutationResponse {
|
||||
request_id: "another-request".into(),
|
||||
entry: Some(entry("2", "2")),
|
||||
},
|
||||
DriveMutationResponse {
|
||||
request_id: id.into(),
|
||||
entry: Some(invalid_entry),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_mutation_completion_recovers_from_original_receipt_without_resending() {
|
||||
let context = ToolExecutionContext::new("write", "batch", 0);
|
||||
let client = Client::new(vec![]);
|
||||
let feature = feature(client.clone());
|
||||
let id = feature.request_id(&context);
|
||||
for completion in invalid_completions(&id) {
|
||||
let committed = DriveMutationResponse {
|
||||
request_id: id.clone(),
|
||||
entry: Some(entry("2", "2")),
|
||||
};
|
||||
client.requests.lock().unwrap().clear();
|
||||
client.responses.lock().unwrap().extend([
|
||||
Ok(response(completion)),
|
||||
Ok(response(DriveRequestStatusResponse {
|
||||
request_id: id.clone(),
|
||||
state: DriveRequestState::Committed,
|
||||
response: Some(committed.clone()),
|
||||
})),
|
||||
]);
|
||||
feature.observe(entry("2", "1")).unwrap();
|
||||
let output = feature
|
||||
.execute(
|
||||
"DriveWrite",
|
||||
&json!({"entry":entry("2", "1").entry,"content":"new"}).to_string(),
|
||||
&context,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.value["request_id"], id);
|
||||
assert_eq!(output.value["entry"]["metadata"]["revision"], "2");
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(
|
||||
requests[0].method,
|
||||
crate::worker::WorkspaceRequestMethod::Post
|
||||
);
|
||||
assert_eq!(
|
||||
requests[1].method,
|
||||
crate::worker::WorkspaceRequestMethod::Get
|
||||
);
|
||||
assert!(requests[1].path.ends_with(&format!("/requests/{id}")));
|
||||
let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(body["request_id"], id);
|
||||
assert_eq!(body["mutation"]["expected_revision"], "1");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_completion_and_unresolved_or_invalid_receipt_stay_unknown_after_one_query() {
|
||||
let context = ToolExecutionContext::new("write", "batch", 0);
|
||||
let client = Client::new(vec![]);
|
||||
let feature = feature(client.clone());
|
||||
let id = feature.request_id(&context);
|
||||
let committed = DriveMutationResponse {
|
||||
request_id: id.clone(),
|
||||
entry: Some(entry("2", "2")),
|
||||
};
|
||||
let mut receipts = vec![
|
||||
response(DriveRequestStatusResponse {
|
||||
request_id: id.clone(),
|
||||
state: DriveRequestState::Uncommitted,
|
||||
response: None,
|
||||
}),
|
||||
response(DriveRequestStatusResponse {
|
||||
request_id: id.clone(),
|
||||
state: DriveRequestState::Committed,
|
||||
response: None,
|
||||
}),
|
||||
response(DriveRequestStatusResponse {
|
||||
request_id: "another-request".into(),
|
||||
state: DriveRequestState::Committed,
|
||||
response: Some(committed),
|
||||
}),
|
||||
WorkspaceResponse {
|
||||
status: 200,
|
||||
body: "invalid-json".into(),
|
||||
},
|
||||
];
|
||||
receipts.extend(invalid_completions(&id).into_iter().map(|completion| {
|
||||
response(DriveRequestStatusResponse {
|
||||
request_id: id.clone(),
|
||||
state: DriveRequestState::Committed,
|
||||
response: Some(completion),
|
||||
})
|
||||
}));
|
||||
for completion in invalid_completions(&id) {
|
||||
for receipt in &receipts {
|
||||
client.requests.lock().unwrap().clear();
|
||||
client
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.extend([Ok(response(&completion)), Ok(receipt.clone())]);
|
||||
feature.observe(entry("2", "1")).unwrap();
|
||||
let result = feature
|
||||
.execute(
|
||||
"DriveWrite",
|
||||
&json!({"entry":entry("2", "1").entry,"content":"new"}).to_string(),
|
||||
&context,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(DriveError::OutcomeUnknown(ref observed)) if observed == &id)
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(
|
||||
requests[0].method,
|
||||
crate::worker::WorkspaceRequestMethod::Post
|
||||
);
|
||||
assert_eq!(
|
||||
requests[1].method,
|
||||
crate::worker::WorkspaceRequestMethod::Get
|
||||
);
|
||||
assert!(requests[1].path.ends_with(&format!("/requests/{id}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn observation_eviction_never_substitutes_another_entry() {
|
||||
let feature = feature(Client::new(Vec::new()));
|
||||
let first = entry("2", "1");
|
||||
feature.observe(first.clone()).unwrap();
|
||||
for id in 3..=258 {
|
||||
feature.observe(entry(&id.to_string(), "1")).unwrap();
|
||||
}
|
||||
assert!(feature.observed(&first.entry, None).is_err());
|
||||
let state = feature.observations.lock().unwrap();
|
||||
assert_eq!(state.entries.len(), MAX_OBSERVATIONS);
|
||||
assert_eq!(state.order.len(), MAX_OBSERVATIONS);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backend_error_details_are_not_reflected_and_conflict_is_not_retried() {
|
||||
let client = Client::new(vec![WorkspaceResponse {
|
||||
status: 409,
|
||||
body: serde_json::to_string(&DriveApiError {
|
||||
code: DriveApiErrorCode::Conflict,
|
||||
classification: DriveFailureClassification::NotCommitted,
|
||||
message: "/secret/provider/path".into(),
|
||||
})
|
||||
.unwrap(),
|
||||
}]);
|
||||
let feature = feature(client.clone());
|
||||
let old = entry("2", "1");
|
||||
feature.observe(old.clone()).unwrap();
|
||||
let result = execute(&feature, "DriveDelete", json!({"entry":old.entry}))
|
||||
.await
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(matches!(result, DriveError::Conflict));
|
||||
assert!(!result.to_string().contains("secret"));
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds_identity() {
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
let entry = entry("9007199254740993", "3");
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
let feature = feature(client.clone());
|
||||
let mut mounts = crate::wip::WipMountRegistry::new();
|
||||
wip::mount_drive_wip(&mut mounts, &feature).unwrap();
|
||||
let provider = wip::DriveProvider(feature);
|
||||
let path = entry_path(&entry.entry);
|
||||
let publication = provider.publication(&path).await.unwrap().unwrap();
|
||||
assert_eq!(publication.projection.route, path);
|
||||
assert_eq!(
|
||||
publication.scope.as_ref().unwrap().r#ref,
|
||||
publication.projection.object.r#ref
|
||||
);
|
||||
assert_eq!(publication.projection.object.validator, Some(b"3".to_vec()));
|
||||
assert!(provider.children("/drive").await.unwrap().is_empty());
|
||||
assert!(
|
||||
provider
|
||||
.publication("/drive/other/9007199254740993")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let operations = &publication.projection.descriptor.operations;
|
||||
assert!(operations.iter().any(|op| op.name == "view_image"));
|
||||
assert!(
|
||||
!operations
|
||||
.iter()
|
||||
.flat_map(|op| &op.parameters)
|
||||
.any(|parameter| parameter.name == "expected_revision")
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ChunkClient {
|
||||
json: Arc<Client>,
|
||||
chunks: Mutex<VecDeque<crate::worker::WorkspaceBinaryResponse>>,
|
||||
requests: Mutex<Vec<crate::worker::WorkspaceBinaryRequest>>,
|
||||
}
|
||||
impl WorkspaceClient for ChunkClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"drive-chunk-test"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.json.execute(request)
|
||||
}
|
||||
fn execute_binary(
|
||||
&self,
|
||||
request: crate::worker::WorkspaceBinaryRequest,
|
||||
) -> Result<crate::worker::WorkspaceBinaryResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(self
|
||||
.chunks
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("unexpected binary read"))
|
||||
}
|
||||
}
|
||||
fn chunk_client(
|
||||
metadata: DriveEntry,
|
||||
chunks: Vec<crate::worker::WorkspaceBinaryResponse>,
|
||||
) -> Arc<ChunkClient> {
|
||||
Arc::new(ChunkClient {
|
||||
json: Client::new(vec![response(metadata)]),
|
||||
chunks: Mutex::new(chunks.into()),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
fn chunk(bytes: Vec<u8>) -> crate::worker::WorkspaceBinaryResponse {
|
||||
crate::worker::WorkspaceBinaryResponse {
|
||||
status: 200,
|
||||
body: bytes,
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn multi_chunk_image_uses_one_revision_and_never_exposes_binary_in_text_content() {
|
||||
let mut metadata = entry("2", "9007199254740993");
|
||||
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
bytes.resize(DRIVE_CHUNK_MAX_BYTES as usize + 13, 42);
|
||||
metadata.size = Some(bytes.len() as u32);
|
||||
let client = chunk_client(
|
||||
metadata.clone(),
|
||||
vec![
|
||||
chunk(bytes[..DRIVE_CHUNK_MAX_BYTES as usize].to_vec()),
|
||||
chunk(bytes[DRIVE_CHUNK_MAX_BYTES as usize..].to_vec()),
|
||||
],
|
||||
);
|
||||
let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new()));
|
||||
let output = execute(&feature, "DriveViewImage", json!({"entry":metadata.entry}))
|
||||
.await
|
||||
.unwrap();
|
||||
let Attachment::Image(image) = &output.attachments[0];
|
||||
assert_eq!(image.data(), bytes);
|
||||
assert!(output.value.to_string().len() < 2048);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
for request in &*requests {
|
||||
assert!(request.path.contains("expected_revision=9007199254740993"));
|
||||
assert_eq!(request.max_response_bytes, DRIVE_CHUNK_MAX_BYTES as usize);
|
||||
}
|
||||
assert!(requests[0].path.split('&').any(|pair| pair == "offset=0"));
|
||||
assert!(
|
||||
requests[1]
|
||||
.path
|
||||
.split('&')
|
||||
.any(|pair| pair == "offset=65536")
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn expired_revision_or_partial_chunk_aborts_image_without_rebinding_or_retrying() {
|
||||
for next in [
|
||||
crate::worker::WorkspaceBinaryResponse {
|
||||
status: 409,
|
||||
body: serde_json::to_vec(&DriveApiError::new(DriveApiErrorCode::Conflict)).unwrap(),
|
||||
},
|
||||
chunk(vec![1]), // second range requires 13 bytes, not a successful prefix
|
||||
] {
|
||||
let mut metadata = entry("2", "3");
|
||||
metadata.size = Some(DRIVE_CHUNK_MAX_BYTES + 13);
|
||||
let mut first = vec![0; DRIVE_CHUNK_MAX_BYTES as usize];
|
||||
first[..8].copy_from_slice(b"\x89PNG\r\n\x1a\n");
|
||||
let client = chunk_client(metadata.clone(), vec![chunk(first), next]);
|
||||
let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new()));
|
||||
assert!(
|
||||
execute(&feature, "DriveViewImage", json!({"entry":metadata.entry}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
2,
|
||||
"must not retry an expired/truncated image"
|
||||
);
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.all(|request| request.path.contains("expected_revision=3&"))
|
||||
);
|
||||
assert_eq!(
|
||||
client.json.requests.lock().unwrap().len(),
|
||||
1,
|
||||
"must not fetch a new revision"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn oversized_images_and_unknown_arguments_fail_before_binary_transfer() {
|
||||
let mut metadata = entry("2", "1");
|
||||
metadata.size = Some(tools::view_image::MAX_IMAGE_BYTES as u32 + 1);
|
||||
let client = chunk_client(metadata.clone(), vec![]);
|
||||
let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new()));
|
||||
assert!(matches!(
|
||||
execute(&feature, "DriveViewImage", json!({"entry":metadata.entry})).await,
|
||||
Err(DriveError::Limit)
|
||||
));
|
||||
assert!(client.requests.lock().unwrap().is_empty());
|
||||
assert!(
|
||||
execute(
|
||||
&feature,
|
||||
"DriveRead",
|
||||
json!({"entry":metadata.entry,"expected_revision":"1"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(client.json.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
//! Native non-indexable Drive entries. Publication resolves just one ID through
|
||||
//! current Backend authorization; the Client owns identity/revision validators.
|
||||
use super::{
|
||||
DriveFeature, SPECS, Spec,
|
||||
backend::{DriveError, decimal},
|
||||
digest, entry_path,
|
||||
};
|
||||
use crate::wip::{
|
||||
WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, WipPublication, WipSubtreeMount,
|
||||
WipSubtreeProvider, contextual_reference, json_to_wip, wip_to_json,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use server_api::{DriveEntry, DriveEntryKind, DriveEntryRef};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use wip_protocol::{
|
||||
Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration,
|
||||
ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value,
|
||||
};
|
||||
|
||||
const ROOT: &str = "/drive";
|
||||
const INTERFACE: &str = "yoi.drive/entry/v1";
|
||||
|
||||
pub fn mount_drive_wip(
|
||||
registry: &mut WipMountRegistry,
|
||||
feature: &DriveFeature,
|
||||
) -> Result<(), WipMountError> {
|
||||
registry.allocate_namespace("drive", "drive")?;
|
||||
// Registration is only a namespace entrance. The subtree provides the
|
||||
// current target/scope/descriptor/handler in a single self-scoped snapshot.
|
||||
let names = root_specs();
|
||||
let descriptor = descriptor(&names, true);
|
||||
registry.mount(WipProjection {
|
||||
route: ROOT.into(),
|
||||
capability: "drive:content".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object: Object {
|
||||
name: "drive".into(),
|
||||
description: Some(
|
||||
"Authorized Workspace Drive; discover entries via bounded list/search, not tree"
|
||||
.into(),
|
||||
),
|
||||
interfaces: vec![contextual_reference(INTERFACE, ROOT)],
|
||||
r#ref: None,
|
||||
validator: None,
|
||||
},
|
||||
interface: contextual_reference(INTERFACE, ROOT),
|
||||
interface_validator: Some(digest(format!("{descriptor:?}").as_bytes()).into_bytes()),
|
||||
descriptor,
|
||||
handler: Arc::new(Unresolved),
|
||||
})?;
|
||||
registry.mount_subtree(WipSubtreeMount {
|
||||
root: ROOT.into(),
|
||||
provider: Arc::new(DriveProvider(feature.clone())),
|
||||
})?;
|
||||
registry.replace_compatibility_tools(ROOT, SPECS.iter().map(|spec| spec.name))?;
|
||||
Ok(())
|
||||
}
|
||||
struct Unresolved;
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for Unresolved {
|
||||
async fn call(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &BTreeMap<String, Value>,
|
||||
_: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
Err(map_error(DriveError::Denied))
|
||||
}
|
||||
}
|
||||
pub(super) struct DriveProvider(pub(super) DriveFeature);
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for DriveProvider {
|
||||
// Arbitrary tree depth is safe: there are deliberately no indexable entries.
|
||||
fn max_depth(&self) -> u32 {
|
||||
32
|
||||
}
|
||||
fn max_nodes(&self) -> usize {
|
||||
1
|
||||
}
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
let result = if path == ROOT {
|
||||
self.0.backend.root().await
|
||||
} else {
|
||||
let Some(entry) = parse_entry(&self.0, path).map_err(protocol)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.0.backend.metadata(&entry).await
|
||||
};
|
||||
let entry = match result {
|
||||
Ok(entry) => entry,
|
||||
Err(DriveError::Denied | DriveError::NotFound) => return Ok(None),
|
||||
Err(error) => return Err(protocol(error)),
|
||||
};
|
||||
let root = path == ROOT;
|
||||
let specs = if root {
|
||||
root_specs()
|
||||
} else {
|
||||
entry_specs(&entry)
|
||||
};
|
||||
let descriptor = descriptor(&specs, root);
|
||||
let reference = contextual_reference(INTERFACE, path);
|
||||
let validator = entry.revision.as_bytes().to_vec();
|
||||
let object = Object {
|
||||
name: if root {
|
||||
"drive".into()
|
||||
} else {
|
||||
entry.entry.node_id.clone()
|
||||
},
|
||||
description: Some(format!(
|
||||
"Workspace Drive {:?} {:?}; stable ID {}, bodies only through bounded operations",
|
||||
entry.kind, entry.name, entry.entry.node_id
|
||||
)),
|
||||
interfaces: vec![reference.clone()],
|
||||
r#ref: Some(format!(
|
||||
"drive:{}:{}",
|
||||
entry.entry.workspace_id, entry.entry.node_id
|
||||
)),
|
||||
validator: Some(validator),
|
||||
};
|
||||
let projection = WipProjection {
|
||||
route: path.into(),
|
||||
capability: "drive:content".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object,
|
||||
interface: reference,
|
||||
interface_validator: Some(digest(format!("{descriptor:?}").as_bytes()).into_bytes()),
|
||||
descriptor,
|
||||
handler: Arc::new(DriveHandler {
|
||||
feature: self.0.clone(),
|
||||
entry,
|
||||
specs,
|
||||
root,
|
||||
}),
|
||||
};
|
||||
WipPublication::self_scoped(projection).map(Some)
|
||||
}
|
||||
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
fn root_specs() -> Vec<Spec> {
|
||||
SPECS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|spec| {
|
||||
matches!(
|
||||
spec.operation,
|
||||
"root"
|
||||
| "metadata"
|
||||
| "list"
|
||||
| "search"
|
||||
| "create_folder"
|
||||
| "create_text"
|
||||
| "save_workdir"
|
||||
| "request_status"
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn entry_specs(entry: &DriveEntry) -> Vec<Spec> {
|
||||
SPECS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|spec| match spec.operation {
|
||||
"metadata" => true,
|
||||
"relocate" | "delete" => entry.parent.is_some(),
|
||||
"list" | "create_folder" | "create_text" | "save_workdir" => {
|
||||
entry.kind == DriveEntryKind::Folder
|
||||
}
|
||||
"read" | "write" | "edit" | "view_image" => entry.kind == DriveEntryKind::File,
|
||||
_ => false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration {
|
||||
ParameterDeclaration {
|
||||
name: name.into(),
|
||||
required,
|
||||
documentation: None,
|
||||
r#type,
|
||||
}
|
||||
}
|
||||
fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor {
|
||||
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client manage revisions; never supply validators or retry conflicts/outcome-unknown with a new revision. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
|
||||
"list"=>vec![parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
|
||||
"search"=>vec![parameter("query",true,TypeExpr::String),parameter("include_text",false,TypeExpr::Boolean),parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
|
||||
"read"=>vec![parameter("max_bytes",false,TypeExpr::Integer)],
|
||||
"write"|"edit"=>crate::file_operation::parameters(spec.operation,false).expect("shared text arguments"),
|
||||
"create_text"=>vec![parameter("name",true,TypeExpr::String),parameter("content",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)],
|
||||
"create_folder"=>vec![parameter("name",true,TypeExpr::String)],
|
||||
"relocate"=>vec![parameter("parent",true,TypeExpr::Entry),parameter("name",true,TypeExpr::String)],
|
||||
"save_workdir"=>vec![parameter("target_workdir",true,TypeExpr::String),parameter("path",true,TypeExpr::String),parameter("name",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)],
|
||||
"request_status"=>vec![parameter("request_id",true,TypeExpr::String)],
|
||||
_=>vec![],
|
||||
},returns:ReturnDeclaration {documentation:None,r#type:TypeExpr::Json}}).collect()}
|
||||
}
|
||||
struct DriveHandler {
|
||||
feature: DriveFeature,
|
||||
entry: DriveEntry,
|
||||
specs: Vec<Spec>,
|
||||
root: bool,
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for DriveHandler {
|
||||
async fn call(
|
||||
&self,
|
||||
operation: &str,
|
||||
arguments: &BTreeMap<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
let spec = self
|
||||
.specs
|
||||
.iter()
|
||||
.find(|spec| spec.operation == operation)
|
||||
.ok_or_else(|| {
|
||||
failure(
|
||||
ProtocolErrorCode::OperationNotFound,
|
||||
"Drive operation is not published",
|
||||
)
|
||||
})?;
|
||||
let declaration = descriptor(&self.specs, self.root)
|
||||
.operations
|
||||
.into_iter()
|
||||
.find(|op| op.name == operation)
|
||||
.unwrap();
|
||||
if arguments
|
||||
.keys()
|
||||
.any(|key| !declaration.parameters.iter().any(|p| p.name == *key))
|
||||
{
|
||||
return Err(map_error(DriveError::Invalid(
|
||||
"unrecognized arguments; revision metadata is Client-managed".into(),
|
||||
)));
|
||||
}
|
||||
let mut input = wip_to_json(&Value::Record(arguments.clone()))
|
||||
.map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))?;
|
||||
let fields = input
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| map_error(DriveError::Invalid("named arguments required".into())))?;
|
||||
match operation {
|
||||
"metadata" | "read" | "write" | "edit" | "delete" | "view_image" => {
|
||||
fields.insert("entry".into(), json!(self.entry.entry));
|
||||
}
|
||||
"list" | "create_folder" | "create_text" | "save_workdir" => {
|
||||
fields.insert("parent".into(), json!(self.entry.entry));
|
||||
}
|
||||
"relocate" => {
|
||||
let path = match arguments.get("parent") {
|
||||
Some(Value::String(path)) => path.as_str(),
|
||||
_ => {
|
||||
return Err(map_error(DriveError::Invalid(
|
||||
"parent must be a Drive entry".into(),
|
||||
)));
|
||||
}
|
||||
};
|
||||
let parent = if path == ROOT {
|
||||
self.feature.backend.root().await.map_err(map_error)?.entry
|
||||
} else {
|
||||
parse_entry(&self.feature, path)
|
||||
.map_err(map_error)?
|
||||
.ok_or_else(|| map_error(DriveError::Denied))?
|
||||
};
|
||||
fields.insert("parent".into(), json!(parent));
|
||||
fields.insert("entry".into(), json!(self.entry.entry));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(permissions) = &self.feature.permissions {
|
||||
if !matches!(
|
||||
crate::permission::permission_action_for(permissions, spec.name, &input),
|
||||
manifest::ToolPermissionAction::Allow
|
||||
) {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::PermissionDenied,
|
||||
"Drive operation denied by tool permission; approval unavailable in native invocation",
|
||||
));
|
||||
}
|
||||
}
|
||||
let output = self
|
||||
.feature
|
||||
.execute(
|
||||
spec.name,
|
||||
&input.to_string(),
|
||||
&context.execution,
|
||||
Some(&self.entry),
|
||||
)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
let value = json_to_wip(&output.value).map_err(|_| {
|
||||
WipOperationError::OutcomeUnknown(
|
||||
"Completed Drive result unavailable; query request_status before another mutation"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
// Attachments use the existing ToolOutput side channel and durable Engine
|
||||
// capture, not transient request injection or URL-only success.
|
||||
let result = if matches!(operation, "write" | "edit" | "relocate") {
|
||||
let revision = output.value["entry"]["metadata"]["revision"]
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
WipOperationError::OutcomeUnknown("Committed Drive revision unavailable".into())
|
||||
})?;
|
||||
WipOperationOutput::native_with_validator(value, revision.as_bytes().to_vec())
|
||||
} else {
|
||||
WipOperationOutput::native(value)
|
||||
};
|
||||
Ok(result.with_attachments(output.attachments))
|
||||
}
|
||||
}
|
||||
fn parse_entry(feature: &DriveFeature, path: &str) -> Result<Option<DriveEntryRef>, DriveError> {
|
||||
let workspace = feature.backend.workspace()?;
|
||||
let prefix = format!(
|
||||
"/drive/{}/",
|
||||
crate::feature::builtin::manage_workdir::wip::encode_identity(workspace)
|
||||
);
|
||||
let Some(id) = path.strip_prefix(&prefix) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !decimal(id) {
|
||||
return Ok(None);
|
||||
}
|
||||
let entry = DriveEntryRef {
|
||||
workspace_id: workspace.into(),
|
||||
node_id: id.into(),
|
||||
};
|
||||
if entry_path(&entry) != path {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(entry))
|
||||
}
|
||||
fn failure(code: ProtocolErrorCode, message: &str) -> WipOperationError {
|
||||
WipOperationError::Protocol(ProtocolError {
|
||||
code,
|
||||
message: message.into(),
|
||||
})
|
||||
}
|
||||
fn map_error(error: DriveError) -> WipOperationError {
|
||||
let code = match &error {
|
||||
DriveError::Denied => ProtocolErrorCode::PermissionDenied,
|
||||
DriveError::NotFound => ProtocolErrorCode::NotFound,
|
||||
DriveError::Conflict => ProtocolErrorCode::ValidatorMismatch,
|
||||
DriveError::Invalid(_) => ProtocolErrorCode::InvalidArguments,
|
||||
DriveError::Limit => ProtocolErrorCode::ResourceLimitExceeded,
|
||||
DriveError::Unavailable => ProtocolErrorCode::Internal,
|
||||
DriveError::OutcomeUnknown(_) => {
|
||||
return WipOperationError::OutcomeUnknown(error.to_string());
|
||||
}
|
||||
};
|
||||
WipOperationError::Protocol(ProtocolError {
|
||||
code,
|
||||
message: error.to_string(),
|
||||
})
|
||||
}
|
||||
fn protocol(error: DriveError) -> ProtocolError {
|
||||
match map_error(error) {
|
||||
WipOperationError::Protocol(error) => error,
|
||||
_ => ProtocolError {
|
||||
code: ProtocolErrorCode::Internal,
|
||||
message: "Drive observation unavailable".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -734,8 +734,14 @@ impl WorkspaceHttpWorkdirBackend {
|
||||
))?;
|
||||
workdir_output(
|
||||
format!(
|
||||
"Workdir {workdir_id} removal disposition: {:?}",
|
||||
response.disposition
|
||||
"Workdir {workdir_id} removal disposition: {:?}. {}{}",
|
||||
response.disposition,
|
||||
removal_guidance(response.failure_category.as_deref()),
|
||||
if response.retryable {
|
||||
" After resolving the cause, repeat this WorkdirDelete with the same id and reason; current safety conditions will be rechecked."
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
&response,
|
||||
)
|
||||
@@ -920,6 +926,48 @@ fn decode_error(error: serde_json::Error) -> ToolError {
|
||||
ToolError::ExecutionFailed(format!("decode Workspace Workdir response: {error}"))
|
||||
}
|
||||
|
||||
fn removal_guidance(category: Option<&str>) -> &'static str {
|
||||
match category {
|
||||
None => "",
|
||||
Some("mount_present") => {
|
||||
"A mount remains inside the Workdir. Confirm its owner and usage, and have that owner safely release it. Do not recursively delete mounted data or implicitly unmount external or unknown resources."
|
||||
}
|
||||
Some("mount_check_unavailable") => "Restore Runtime mount inspection before deletion.",
|
||||
Some("changes_present" | "dirty_or_unknown") => {
|
||||
"Current changes are protected; preserve or resolve them before deletion."
|
||||
}
|
||||
Some("changes_unknown") => {
|
||||
"Restore checkout inspection or preserve remaining content so current changes can be verified."
|
||||
}
|
||||
Some(
|
||||
"ownership_unknown"
|
||||
| "provider_identity_changed"
|
||||
| "authority_changed"
|
||||
| "authority_invalid",
|
||||
) => {
|
||||
"Confirm target identity and ownership with its owner; never remove a replacement or unknown resource."
|
||||
}
|
||||
Some("blocked_by_live_authority") => {
|
||||
"Release current attachments, reservations and holds through their owning authority."
|
||||
}
|
||||
Some("permission_denied" | "resource_busy" | "storage_unavailable") => {
|
||||
"Have the resource owner resolve access, usage or storage safely; do not force-clean or change shared resource permissions."
|
||||
}
|
||||
Some("runtime_unavailable" | "provider_unavailable") => {
|
||||
"Restore the Runtime connection; the registry remains until deletion is confirmed."
|
||||
}
|
||||
Some("provider_observation_unknown" | "provider_cleanup_outcome_unknown") => {
|
||||
"Deletion is not confirmed. Re-observe with normal removal after restoring provider access; the registry is retained."
|
||||
}
|
||||
Some("unsupported_target") => {
|
||||
"Ask the owner for the provider's supported cleanup operation."
|
||||
}
|
||||
Some(_) => {
|
||||
"Inspect correlated Runtime cleanup diagnostics with the owning operator and resolve the current cause."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn workdir_output<T: Serialize>(summary: String, value: &T) -> Result<ToolOutput, ToolError> {
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
@@ -1218,6 +1266,45 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_workdir_delete_reports_safe_cause_and_exact_retry_guidance() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
|
||||
"working_directory_id": "wd-retry",
|
||||
"disposition": "attention_required",
|
||||
"retryable": true,
|
||||
"failure_category": "mount_present"
|
||||
}))]));
|
||||
let backend = WorkspaceHttpWorkdirBackend::new(client.clone());
|
||||
let output = backend
|
||||
.delete(WorkdirDeleteInput {
|
||||
working_directory_id: "wd-retry".into(),
|
||||
reason: "remove unused Workdir".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(output.summary.contains("Confirm its owner"));
|
||||
assert!(
|
||||
output
|
||||
.summary
|
||||
.contains("Do not recursively delete mounted data")
|
||||
);
|
||||
assert!(output.summary.contains("same id and reason"));
|
||||
assert!(output.summary.len() <= 600);
|
||||
let content: serde_json::Value =
|
||||
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(content["failure_category"], "mount_present");
|
||||
assert_eq!(content["retryable"], true);
|
||||
let requests = client.requests();
|
||||
assert_eq!(requests[0].method, WorkspaceRequestMethod::Delete);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(requests[0].body.as_deref().unwrap())
|
||||
.unwrap()["reason"],
|
||||
"remove unused Workdir"
|
||||
);
|
||||
let unknown = removal_guidance(Some("secret /private/provider/path"));
|
||||
assert!(!unknown.contains("/private"));
|
||||
assert!(!unknown.contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_request_failure_is_not_reported_as_session_unavailable() {
|
||||
let error = workspace_workdir_error(WorkspaceClientError::Request(
|
||||
|
||||
@@ -101,7 +101,8 @@ pub fn mount_workspace_workdir_wip(
|
||||
for domain in [Domain::Repository, Domain::Workdir, Domain::Attachment] {
|
||||
let namespace =
|
||||
registry.allocate_namespace(domain.owner(), domain.route().trim_start_matches('/'))?;
|
||||
let interface = format!("yoi.{}/collection/v1", domain.owner());
|
||||
let interface =
|
||||
crate::wip::root_reference(&format!("yoi.{}/collection/v1", domain.owner()));
|
||||
let handler = Arc::new(CollectionHandler {
|
||||
provider: provider.clone(),
|
||||
domain,
|
||||
@@ -130,7 +131,7 @@ pub fn mount_workspace_workdir_wip(
|
||||
handler,
|
||||
})?;
|
||||
}
|
||||
let item_interface = format!("yoi.{}/item/v1", domain.owner());
|
||||
let item_interface = crate::wip::root_reference(&format!("yoi.{}/item/v1", domain.owner()));
|
||||
let resolver = Arc::new(ItemResolver {
|
||||
provider: provider.clone(),
|
||||
domain,
|
||||
@@ -668,7 +669,7 @@ impl WipDynamicItemResolver for ItemResolver {
|
||||
}
|
||||
let raw = self.provider.raw_item(self.domain, &id).ok()?;
|
||||
let projected = self.provider.projected(self.domain, &raw).ok()?;
|
||||
let interface = format!("yoi.{}/item/v1", self.domain.owner());
|
||||
let interface = crate::wip::root_reference(&format!("yoi.{}/item/v1", self.domain.owner()));
|
||||
Some(WipDynamicItem {
|
||||
object: object(&item_path(self.domain, &id), &id, &interface, &projected),
|
||||
handler: Arc::new(ItemHandler {
|
||||
@@ -836,7 +837,12 @@ impl WipOperationHandler for ItemHandler {
|
||||
}
|
||||
}
|
||||
|
||||
fn object(path: &str, identity: &str, interface: &str, value: &Json) -> Object {
|
||||
fn object(
|
||||
path: &str,
|
||||
identity: &str,
|
||||
interface: &wip_protocol::InterfaceReference,
|
||||
value: &Json,
|
||||
) -> Object {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(path);
|
||||
digest.update([0]);
|
||||
@@ -844,7 +850,7 @@ fn object(path: &str, identity: &str, interface: &str, value: &Json) -> Object {
|
||||
Object {
|
||||
name: path.rsplit('/').next().unwrap_or_default().into(),
|
||||
description: Some(format!("Workspace authority Object: {identity}")),
|
||||
interfaces: vec![interface.into()],
|
||||
interfaces: vec![interface.clone()],
|
||||
r#ref: Some(identity.into()),
|
||||
validator: Some(digest.finalize().to_vec()),
|
||||
}
|
||||
@@ -1658,7 +1664,7 @@ pub(crate) mod tests {
|
||||
registry.contribute_operations(WipOperationContribution {
|
||||
route: WORKDIRS.into(),
|
||||
contributor: "another-feature".into(),
|
||||
interface: "yoi.workdir/collection/v1".into(),
|
||||
interface: crate::wip::root_reference("yoi.workdir/collection/v1"),
|
||||
descriptor: collection_descriptor(Domain::Workdir, true),
|
||||
handler: Arc::new(CollectionHandler {
|
||||
provider: provider(Arc::new(CatalogClient::default()), true, true, None),
|
||||
|
||||
@@ -725,8 +725,8 @@ struct WorkerSpawnInput {
|
||||
/// Optional opaque Workspace singleton key. Prefixes are caller-owned and uninterpreted.
|
||||
#[serde(default)]
|
||||
singleton_key: Option<String>,
|
||||
/// Optional inprogress Ticket already accepted by the Orchestrator. Set
|
||||
/// this with one Flow segment to assign the new Coder atomically.
|
||||
/// Optional Ticket assignment. Backend validates claims and resource requirements;
|
||||
/// neither the profile nor an initial Flow segment grants review authority.
|
||||
#[serde(default)]
|
||||
ticket_id: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -861,7 +861,7 @@ impl WorkerOperation {
|
||||
"List only known Runtime Workers and direct SubWorkers granted to the current Worker."
|
||||
}
|
||||
Self::Spawn => {
|
||||
"Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission. After the Orchestrator has committed a Ticket to `inprogress`, set `ticket_id` with a Flow segment in `initial_submit` to atomically assign the new Coder Worker; the operation id is derived from the durable tool call rather than model input."
|
||||
"Spawn a Backend/Runtime Worker session in one existing Workspace Workdir. The model-facing input remains singular and the shared lifecycle wraps it as the canonical attachment collection under the stable `workdir` alias. The Workdir id is authority; filesystem paths and Runtime URLs are not accepted. `initial_submit` carries the normal typed user submission, with an optional Flow segment. Set `ticket_id` to atomically assign the Worker; Backend validates claims and resource requirements. Selecting a profile or Flow does not grant review authority. The operation id is derived from the durable tool call rather than model input."
|
||||
}
|
||||
Self::SendInput => {
|
||||
"Start a fresh turn for an Idle Worker. Non-Idle Workers reject WorkerSendInput without queueing. Use WorkerNotify for advisory information during work already in progress. Direct SubWorkers also require Idle."
|
||||
@@ -870,12 +870,14 @@ impl WorkerOperation {
|
||||
"Send an advisory notification to a known Runtime Worker so information can be incorporated into work already in progress without creating a queued Submit."
|
||||
}
|
||||
Self::Cancel => "Cancel the current turn of a known Runtime Worker when allowed.",
|
||||
Self::Stop => "Stop a known Runtime Worker when allowed.",
|
||||
Self::Stop => {
|
||||
"Stop a known Runtime Worker when allowed. Stop is independent of Ticket conclusion: it does not end unfinished work, remove retained session records, release attachments, or delete Workdirs."
|
||||
}
|
||||
Self::Restore => {
|
||||
"Restore a stopped Backend/Runtime Worker session in the current Workspace. Supply expected_observation_token from WorkerList; a conflict requires refreshing and deliberate new intent. New requests default to the durable Tool call ID. For explicit recovery of an unknown/pending outcome, supply the reported request_id and the original token unchanged; never create a new intent or automatically retry."
|
||||
}
|
||||
Self::Remove => {
|
||||
"Remove an eligible stopped, non-internal Worker with no unfinished Ticket work. Terminal Ticket responsibility is retained and does not require unassignment. Supply a bounded reason; Backend validation and retention are authoritative."
|
||||
"Remove an eligible stopped, non-internal Worker with no unfinished Ticket work. Terminal Ticket responsibility is retained and does not require unassignment. Conclusion, stopping, retention/removal, attachment release, and Workdir deletion are separate decisions. Removal releases attachments but preserves Workdir materialization. Supply a bounded reason; Backend validation and retention are authoritative."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1949,6 +1951,41 @@ mod tests {
|
||||
assert!(value.get("initial_text").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_lifecycle_spawn_serializes_ticket_assignment_without_workdirs_or_flow() {
|
||||
let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest {
|
||||
runtime_id: "runtime-1".to_string(),
|
||||
workdir_attachments: Vec::new(),
|
||||
profile: "project:ticket-worker".to_string(),
|
||||
singleton_key: None,
|
||||
ticket_id: Some("T-719".to_string()),
|
||||
operation_id: Some("spawn-ticket-worker:T-719:call-1".to_string()),
|
||||
display_name: "Worker · T-719".to_string(),
|
||||
initial_submit: vec![Segment::text("Investigate Ticket T-719.")],
|
||||
})
|
||||
.unwrap();
|
||||
let value = serde_json::to_value(request).unwrap();
|
||||
assert_eq!(value["workdir_attachments"], serde_json::json!([]));
|
||||
assert_eq!(value["profile"], "project:ticket-worker");
|
||||
assert_eq!(
|
||||
value["ticket_assignment"],
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-719",
|
||||
"operation_id": "spawn-ticket-worker:T-719:call-1",
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
value["control_operation_id"],
|
||||
"spawn-ticket-worker:T-719:call-1"
|
||||
);
|
||||
assert_eq!(
|
||||
value["initial_submit"],
|
||||
serde_json::json!([
|
||||
{"kind": "text", "content": "Investigate Ticket T-719."}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_message_and_cancel_use_permission_fenced_control_routes() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
@@ -2015,6 +2052,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_lifecycle_descriptions_separate_unfinished_work_retention_and_attachment_cleanup() {
|
||||
let stop = worker_tool_declaration(WorkerOperation::Stop);
|
||||
assert!(stop.description.contains("does not end unfinished work"));
|
||||
assert!(
|
||||
stop.description
|
||||
.contains("release attachments, or delete Workdirs")
|
||||
);
|
||||
let remove = worker_tool_declaration(WorkerOperation::Remove);
|
||||
assert!(
|
||||
remove
|
||||
.description
|
||||
.contains("Terminal Ticket responsibility is retained")
|
||||
);
|
||||
assert!(remove.description.contains("does not require unassignment"));
|
||||
assert!(
|
||||
remove
|
||||
.description
|
||||
.contains("releases attachments but preserves Workdir materialization")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_remove_forwards_only_target_and_bounded_reason() {
|
||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||
|
||||
@@ -27,8 +27,7 @@ use wip_protocol::{
|
||||
|
||||
pub const FEATURE_ID: &str = "merge_request";
|
||||
const FEATURE_NAME: &str = "Merge Request tools";
|
||||
const FEATURE_DESCRIPTION: &str =
|
||||
"Operation-specific Merge Request workflow tools over Workspace authority.";
|
||||
const FEATURE_DESCRIPTION: &str = "Optional repository MR workflow with trusted review and separate Orchestrator integration authority; not a universal Ticket conclusion gate.";
|
||||
const FEATURE_INSTRUCTION_ID: &str = "merge_request.workflow";
|
||||
pub const FEATURE_PROMPT_REF: &str = "common.merge_request";
|
||||
|
||||
@@ -559,14 +558,16 @@ pub fn mount_workspace_http_merge_request_wip(
|
||||
description: Some(
|
||||
"Authoritative Merge Request collection through scoped Backend authority".into(),
|
||||
),
|
||||
interfaces: vec![MERGE_REQUEST_COLLECTION_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(
|
||||
MERGE_REQUEST_COLLECTION_INTERFACE,
|
||||
)],
|
||||
r#ref: Some("merge-request:collection".into()),
|
||||
validator: Some(merge_request_route_validator(
|
||||
&collection_route,
|
||||
"collection",
|
||||
)),
|
||||
},
|
||||
interface: MERGE_REQUEST_COLLECTION_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(MERGE_REQUEST_COLLECTION_INTERFACE),
|
||||
interface_validator: Some(merge_request_descriptor_validator(&collection_descriptor)),
|
||||
descriptor: collection_descriptor,
|
||||
handler: Arc::new(MergeRequestCollectionWipHandler {
|
||||
@@ -585,7 +586,7 @@ pub fn mount_workspace_http_merge_request_wip(
|
||||
registry.mount_dynamic(WipDynamicMount {
|
||||
collection_route: collection_route.clone(),
|
||||
capability: "merge-request:item".into(),
|
||||
interface: MERGE_REQUEST_ITEM_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(MERGE_REQUEST_ITEM_INTERFACE),
|
||||
interface_validator: Some(merge_request_descriptor_validator(&item_descriptor)),
|
||||
descriptor: item_descriptor,
|
||||
resolver: Arc::new(MergeRequestItemResolver {
|
||||
@@ -683,7 +684,7 @@ impl WipDynamicItemResolver for MergeRequestItemResolver {
|
||||
object: Object {
|
||||
name: item_reference.into(),
|
||||
description: Some("Authoritative Merge Request bound to this object route".into()),
|
||||
interfaces: vec![MERGE_REQUEST_ITEM_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(MERGE_REQUEST_ITEM_INTERFACE)],
|
||||
r#ref: Some(format!("merge-request:{item_reference}")),
|
||||
validator: Some(merge_request_route_validator(&route, &revision)),
|
||||
},
|
||||
@@ -1085,10 +1086,10 @@ pub fn description(n: &str) -> Option<&'static str> {
|
||||
"Open one repository-scoped Merge Request linked to the Ticket; reuse an existing Merge Request for that repository and advance only its selector_from with a normal non-force push.",
|
||||
),
|
||||
"CompleteMergeRequest" => Some(
|
||||
"Record Orchestrator-owned integration for the explicitly addressed Merge Request without completing the Ticket or releasing its assignment.",
|
||||
"Record Orchestrator-owned integration for the explicitly addressed Merge Request using current trusted exact-source approval and provider evidence. This records an already-applied integration, not a Ticket conclusion or assignment release; a review-not-required Ticket request does not bypass MR integration guards.",
|
||||
),
|
||||
"ReviewMergeRequest" => Some(
|
||||
"Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.",
|
||||
"Submit the injected trusted Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not. The capability authenticates the attempt, not the verdict: independent natural-language judgment and validation remain the Reviewer's responsibility.",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
|
||||
@@ -578,11 +578,11 @@ pub fn mount_workspace_http_objective_wip(
|
||||
"Authoritative Objective collection; search and create through Backend authority"
|
||||
.into(),
|
||||
),
|
||||
interfaces: vec![OBJECTIVE_COLLECTION_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(OBJECTIVE_COLLECTION_INTERFACE)],
|
||||
r#ref: Some("objective:collection".into()),
|
||||
validator: Some(route_validator(&collection_route, "collection")),
|
||||
},
|
||||
interface: OBJECTIVE_COLLECTION_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(OBJECTIVE_COLLECTION_INTERFACE),
|
||||
descriptor: collection_descriptor,
|
||||
interface_validator: Some(collection_validator),
|
||||
handler: collection_handler,
|
||||
@@ -593,7 +593,7 @@ pub fn mount_workspace_http_objective_wip(
|
||||
registry.mount_dynamic(WipDynamicMount {
|
||||
collection_route: collection_route.clone(),
|
||||
capability: "objective:item".into(),
|
||||
interface: OBJECTIVE_ITEM_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(OBJECTIVE_ITEM_INTERFACE),
|
||||
descriptor: item_descriptor,
|
||||
interface_validator: Some(item_validator),
|
||||
resolver: Arc::new(ObjectiveItemResolver {
|
||||
@@ -744,7 +744,7 @@ impl WipDynamicItemResolver for ObjectiveItemResolver {
|
||||
object: Object {
|
||||
name: item_reference.to_string(),
|
||||
description: Some("Authoritative Objective bound to this object route".into()),
|
||||
interfaces: vec![OBJECTIVE_ITEM_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(OBJECTIVE_ITEM_INTERFACE)],
|
||||
r#ref: Some(format!("objective:{item_reference}")),
|
||||
validator: Some(route_validator(&route, &revision)),
|
||||
},
|
||||
|
||||
@@ -20,9 +20,8 @@ use crate::feature::{
|
||||
};
|
||||
|
||||
const FEATURE_ID: &str = "orchestration";
|
||||
const TOOL_NAME: &str = "SpawnTicketCoder";
|
||||
const CODER_PROFILE: &str = "builtin:coder";
|
||||
const CODER_FLOW: &str = "builtin:coder-review";
|
||||
const TOOL_NAME: &str = "SpawnTicketWorker";
|
||||
const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); Flow and repository/Workdir attachments are optional. Results and return choices follow user intent, not a universal approval or MR gate. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority.";
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct OrchestrationFeature;
|
||||
@@ -37,16 +36,13 @@ impl FeatureModule for OrchestrationFeature {
|
||||
.with_description("Semantic Ticket orchestration operations.")
|
||||
.with_service_requirement(ServiceRequirement::required(
|
||||
ServiceId::builtin(TICKET_SERVICE_ID),
|
||||
"SpawnTicketCoder requires current typed Ticket authority",
|
||||
"SpawnTicketWorker requires current typed Ticket authority",
|
||||
))
|
||||
.with_service_requirement(ServiceRequirement::required(
|
||||
ServiceId::builtin(WORKER_LIFECYCLE_SERVICE_ID),
|
||||
"SpawnTicketCoder requires Workspace Worker lifecycle authority",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
TOOL_NAME,
|
||||
"Spawn and atomically assign a Coder Worker for a queued or already-inprogress Ticket. Select every required Workdir by stable alias; Backend authority derives each attachment's access from the Ticket targets. The guarded operation records queued acceptance only after spawn, initial input, assignment, and Workdir finalization. The profile, Flow, display name, assignment operation, and initial message are fixed by orchestration policy.",
|
||||
"SpawnTicketWorker requires Workspace Worker lifecycle authority",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(TOOL_NAME, TOOL_DESCRIPTION))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
@@ -68,7 +64,7 @@ impl FeatureModule for OrchestrationFeature {
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SpawnTicketCoderWorkdirInput {
|
||||
struct SpawnTicketWorkerWorkdirInput {
|
||||
/// Stable Worker-local routing alias (for example `checkout` or `docs`).
|
||||
alias: String,
|
||||
/// Workspace-authoritative Workdir id. Paths and URLs are not accepted.
|
||||
@@ -80,27 +76,37 @@ struct SpawnTicketCoderWorkdirInput {
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SpawnTicketCoderInput {
|
||||
struct SpawnTicketWorkerInput {
|
||||
ticket_id: String,
|
||||
runtime_id: String,
|
||||
/// Every Workdir required by the Ticket target set. Backend authority derives
|
||||
/// effective read/write capabilities; callers cannot request capabilities.
|
||||
workdir_attachments: Vec<SpawnTicketCoderWorkdirInput>,
|
||||
/// Optional registered profile selector; omitted/null selects builtin:ticket-worker.
|
||||
/// Backend resolves the profile and validates claims, not natural-language satisfaction.
|
||||
#[serde(default)]
|
||||
profile: Option<String>,
|
||||
/// Initial user request delivered through the normal typed submission path.
|
||||
initial_request: String,
|
||||
/// Optional Flow selector, delivered as a typed Flow segment before the request.
|
||||
#[serde(default)]
|
||||
flow: Option<String>,
|
||||
/// Alias-keyed Workdir selections; omit or leave empty for a Worker without
|
||||
/// Workdirs. Backend derives effective capabilities; callers cannot request them.
|
||||
#[serde(default)]
|
||||
workdir_attachments: Vec<SpawnTicketWorkerWorkdirInput>,
|
||||
}
|
||||
|
||||
struct SpawnTicketCoderTool {
|
||||
struct SpawnTicketWorkerTool {
|
||||
ticket_service: Arc<dyn TicketService>,
|
||||
worker_service: Arc<dyn WorkerLifecycleService>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SpawnTicketCoderTool {
|
||||
impl Tool for SpawnTicketWorkerTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| {
|
||||
let input: SpawnTicketWorkerInput = serde_json::from_str(input_json).map_err(|error| {
|
||||
ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}"))
|
||||
})?;
|
||||
let ticket_ref = authority_id(input.ticket_id, "ticket_id")?;
|
||||
@@ -108,35 +114,32 @@ impl Tool for SpawnTicketCoderTool {
|
||||
.ticket_service
|
||||
.ticket_handoff(&ticket_ref)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
if !matches!(
|
||||
ticket.workflow_state,
|
||||
ticket::TicketWorkflowState::Queued | ticket::TicketWorkflowState::InProgress
|
||||
) {
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"Ticket {} must be queued or inprogress before spawning its Coder; current state is {}",
|
||||
ticket.resource_key,
|
||||
ticket.workflow_state.as_str()
|
||||
)));
|
||||
}
|
||||
let call_id = non_empty(ctx.call_id, "tool call_id")?;
|
||||
let runtime_id = authority_id(input.runtime_id, "runtime_id")?;
|
||||
let profile = input
|
||||
.profile
|
||||
.map(|profile| non_empty(profile, "profile"))
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| "builtin:ticket-worker".into());
|
||||
let initial_request = non_empty(input.initial_request, "initial_request")?;
|
||||
let flow = input.flow.map(|flow| non_empty(flow, "flow")).transpose()?;
|
||||
let workdir_attachments = validate_workdir_attachments(input.workdir_attachments)?;
|
||||
let mut initial_submit = Vec::new();
|
||||
if let Some(selector) = flow {
|
||||
initial_submit.push(Segment::Flow { selector });
|
||||
}
|
||||
initial_submit.push(Segment::text(initial_request));
|
||||
let response = self
|
||||
.worker_service
|
||||
.spawn(WorkerLifecycleSpawnRequest {
|
||||
runtime_id,
|
||||
workdir_attachments,
|
||||
profile: CODER_PROFILE.to_string(),
|
||||
profile,
|
||||
singleton_key: None,
|
||||
ticket_id: Some(ticket.id.clone()),
|
||||
operation_id: Some(format!("spawn-ticket-coder:{}:{call_id}", ticket.id)),
|
||||
display_name: format!("Coder · {}", ticket.resource_key),
|
||||
initial_submit: vec![
|
||||
Segment::Flow {
|
||||
selector: CODER_FLOW.to_string(),
|
||||
},
|
||||
Segment::text(format!("Implement Ticket {}.", ticket.resource_key)),
|
||||
],
|
||||
operation_id: Some(format!("spawn-ticket-worker:{}:{call_id}", ticket.id)),
|
||||
display_name: format!("Worker · {}", ticket.resource_key),
|
||||
initial_submit,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
@@ -147,7 +150,7 @@ impl Tool for SpawnTicketCoderTool {
|
||||
)));
|
||||
}
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Spawned Coder for Ticket {}", ticket.resource_key),
|
||||
summary: format!("Spawned Worker for Ticket {}", ticket.resource_key),
|
||||
content: Some(response.body),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
@@ -155,14 +158,8 @@ impl Tool for SpawnTicketCoderTool {
|
||||
}
|
||||
|
||||
fn validate_workdir_attachments(
|
||||
attachments: Vec<SpawnTicketCoderWorkdirInput>,
|
||||
attachments: Vec<SpawnTicketWorkerWorkdirInput>,
|
||||
) -> Result<Vec<WorkerLifecycleWorkdirAttachment>, ToolError> {
|
||||
if attachments.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"workdir_attachments must contain at least one Workdir".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut aliases = BTreeSet::new();
|
||||
let mut workdir_ids = BTreeSet::new();
|
||||
attachments
|
||||
@@ -200,14 +197,12 @@ fn definition(
|
||||
worker_service: Arc<dyn WorkerLifecycleService>,
|
||||
) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketCoderInput))
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput))
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let meta = ToolMeta::new(TOOL_NAME)
|
||||
.description(
|
||||
"Spawn and atomically assign a policy-configured Coder for a Ticket with every required alias-keyed Workdir attachment.",
|
||||
)
|
||||
.description(TOOL_DESCRIPTION)
|
||||
.input_schema(schema);
|
||||
let tool: Arc<dyn Tool> = Arc::new(SpawnTicketCoderTool {
|
||||
let tool: Arc<dyn Tool> = Arc::new(SpawnTicketWorkerTool {
|
||||
ticket_service: ticket_service.clone(),
|
||||
worker_service: worker_service.clone(),
|
||||
});
|
||||
@@ -254,32 +249,18 @@ mod tests {
|
||||
|
||||
use ticket::{TicketError, TicketWorkflowState};
|
||||
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
use super::*;
|
||||
use crate::feature::builtin::ticket::TicketHandoff;
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingTicketService;
|
||||
|
||||
impl TicketService for RecordingTicketService {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
assert_eq!(ticket_ref, "T-482");
|
||||
Ok(TicketHandoff {
|
||||
id: "00001KZXN51C7".to_string(),
|
||||
resource_key: "T-482".to_string(),
|
||||
workflow_state: TicketWorkflowState::Queued,
|
||||
})
|
||||
}
|
||||
}
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
|
||||
|
||||
struct FixedTicketService(TicketWorkflowState);
|
||||
|
||||
impl TicketService for FixedTicketService {
|
||||
fn ticket_handoff(&self, _ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
assert_eq!(ticket_ref, "T-719");
|
||||
Ok(TicketHandoff {
|
||||
id: "00001KZXN51C7".to_string(),
|
||||
resource_key: "T-482".to_string(),
|
||||
resource_key: "T-719".to_string(),
|
||||
workflow_state: self.0,
|
||||
})
|
||||
}
|
||||
@@ -288,6 +269,7 @@ mod tests {
|
||||
#[derive(Default)]
|
||||
struct RecordingService {
|
||||
requests: Mutex<Vec<WorkerLifecycleSpawnRequest>>,
|
||||
response: Option<WorkspaceResponse>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -297,193 +279,278 @@ mod tests {
|
||||
request: WorkerLifecycleSpawnRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
Ok(WorkspaceResponse {
|
||||
Ok(self.response.clone().unwrap_or_else(|| WorkspaceResponse {
|
||||
status: 200,
|
||||
body: r#"{"worker_id":"42"}"#.to_string(),
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn input() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-719",
|
||||
"runtime_id": "runtime-1",
|
||||
"profile": "project:ticket-worker",
|
||||
"initial_request": "Investigate Ticket T-719."
|
||||
})
|
||||
}
|
||||
|
||||
fn tool(service: Arc<RecordingService>, state: TicketWorkflowState) -> SpawnTicketWorkerTool {
|
||||
SpawnTicketWorkerTool {
|
||||
ticket_service: Arc::new(FixedTicketService(state)),
|
||||
worker_service: service,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_coder_fixes_profile_flow_assignment_and_message() {
|
||||
async fn spawn_ticket_worker_forwards_selected_profile_flow_request_and_workdirs() {
|
||||
let service = Arc::new(RecordingService::default());
|
||||
let tool = SpawnTicketCoderTool {
|
||||
ticket_service: Arc::new(RecordingTicketService),
|
||||
worker_service: service.clone(),
|
||||
};
|
||||
tool.execute(
|
||||
&serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": [
|
||||
{
|
||||
"alias": "checkout",
|
||||
"working_directory_id": "workdir-1",
|
||||
"relative_cwd": "crates/yoi"
|
||||
},
|
||||
{
|
||||
"alias": "docs",
|
||||
"working_directory_id": "workdir-2"
|
||||
}
|
||||
]
|
||||
})
|
||||
.to_string(),
|
||||
ToolExecutionContext::new("call-7", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut input = input();
|
||||
input["flow"] = serde_json::json!("project:investigation");
|
||||
input["workdir_attachments"] = serde_json::json!([
|
||||
{
|
||||
"alias": "checkout",
|
||||
"working_directory_id": "workdir-1",
|
||||
"relative_cwd": "crates/worker"
|
||||
},
|
||||
{"alias": "docs", "working_directory_id": "workdir-2"}
|
||||
]);
|
||||
let output = tool(service.clone(), TicketWorkflowState::Queued)
|
||||
.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("call-7", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let requests = service.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let request = &requests[0];
|
||||
assert_eq!(request.profile, CODER_PROFILE);
|
||||
assert_eq!(request.runtime_id, "runtime-1");
|
||||
assert_eq!(request.profile, "project:ticket-worker");
|
||||
assert_eq!(request.ticket_id.as_deref(), Some("00001KZXN51C7"));
|
||||
assert_eq!(
|
||||
request.operation_id.as_deref(),
|
||||
Some("spawn-ticket-coder:00001KZXN51C7:call-7")
|
||||
Some("spawn-ticket-worker:00001KZXN51C7:call-7")
|
||||
);
|
||||
assert_eq!(request.display_name, "Coder · T-482");
|
||||
assert_eq!(request.workdir_attachments.len(), 2);
|
||||
assert_eq!(request.workdir_attachments[0].alias, "checkout");
|
||||
assert_eq!(request.display_name, "Worker · T-719");
|
||||
assert_eq!(request.singleton_key, None);
|
||||
assert_eq!(
|
||||
request.workdir_attachments[0].working_directory_id,
|
||||
"workdir-1"
|
||||
request.workdir_attachments,
|
||||
vec![
|
||||
WorkerLifecycleWorkdirAttachment {
|
||||
alias: "checkout".into(),
|
||||
working_directory_id: "workdir-1".into(),
|
||||
relative_cwd: Some("crates/worker".into()),
|
||||
},
|
||||
WorkerLifecycleWorkdirAttachment {
|
||||
alias: "docs".into(),
|
||||
working_directory_id: "workdir-2".into(),
|
||||
relative_cwd: None,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
request.workdir_attachments[0].relative_cwd.as_deref(),
|
||||
Some("crates/yoi")
|
||||
);
|
||||
assert_eq!(request.workdir_attachments[1].alias, "docs");
|
||||
assert_eq!(
|
||||
request.workdir_attachments[1].working_directory_id,
|
||||
"workdir-2"
|
||||
);
|
||||
assert_eq!(request.workdir_attachments[1].relative_cwd, None);
|
||||
assert_eq!(
|
||||
request.initial_submit,
|
||||
vec![
|
||||
Segment::Flow {
|
||||
selector: CODER_FLOW.to_string()
|
||||
selector: "project:investigation".into()
|
||||
},
|
||||
Segment::text("Implement Ticket T-482.")
|
||||
Segment::text("Investigate Ticket T-719."),
|
||||
]
|
||||
);
|
||||
assert!(!request.display_name.contains("00001KZXN51C7"));
|
||||
assert!(request.initial_submit.iter().all(|segment| {
|
||||
!Segment::flatten_to_text(std::slice::from_ref(segment)).contains("00001KZXN51C7")
|
||||
}));
|
||||
assert_eq!(output.summary, "Spawned Worker for Ticket T-719");
|
||||
assert_eq!(output.content.as_deref(), Some(r#"{"worker_id":"42"}"#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_coder_rejects_ineligible_ticket_before_worker_side_effect() {
|
||||
let worker_service = Arc::new(RecordingService::default());
|
||||
let tool = SpawnTicketCoderTool {
|
||||
ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Planning)),
|
||||
worker_service: worker_service.clone(),
|
||||
};
|
||||
let error = tool
|
||||
async fn spawn_ticket_worker_allows_zero_workdirs_and_omitted_flow() {
|
||||
let service = Arc::new(RecordingService::default());
|
||||
for explicit_empty in [false, true] {
|
||||
let mut input = input();
|
||||
if explicit_empty {
|
||||
input["workdir_attachments"] = serde_json::json!([]);
|
||||
input["flow"] = serde_json::Value::Null;
|
||||
}
|
||||
tool(service.clone(), TicketWorkflowState::Queued)
|
||||
.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("call-empty", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let requests = service.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
for request in requests.iter() {
|
||||
assert!(request.workdir_attachments.is_empty());
|
||||
assert_eq!(
|
||||
request.initial_submit,
|
||||
vec![Segment::text("Investigate Ticket T-719.")]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_worker_omitted_profile_uses_general_worker_without_flow_or_repository() {
|
||||
let service = Arc::new(RecordingService::default());
|
||||
let definition = definition(
|
||||
Arc::new(FixedTicketService(TicketWorkflowState::Ready)),
|
||||
service.clone(),
|
||||
);
|
||||
let (meta, tool) = definition();
|
||||
let required = meta.input_schema["required"].as_array().unwrap();
|
||||
assert!(!required.iter().any(|field| field == "profile"));
|
||||
assert!(
|
||||
meta.description
|
||||
.contains("neither trusted review nor merge authority")
|
||||
);
|
||||
for profile in [None, Some(serde_json::Value::Null)] {
|
||||
let mut input = input();
|
||||
input.as_object_mut().unwrap().remove("profile");
|
||||
if let Some(profile) = profile {
|
||||
input["profile"] = profile;
|
||||
}
|
||||
input["initial_request"] = serde_json::json!(
|
||||
"Research the question; review is not required. Return without concluding the Ticket."
|
||||
);
|
||||
tool.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("call-default", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
for request in service.requests.lock().unwrap().iter() {
|
||||
assert_eq!(request.profile, "builtin:ticket-worker");
|
||||
assert!(request.workdir_attachments.is_empty());
|
||||
assert_eq!(
|
||||
request.initial_submit,
|
||||
vec![Segment::text(
|
||||
"Research the question; review is not required. Return without concluding the Ticket."
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_worker_defers_claim_eligibility_to_backend() {
|
||||
let service = Arc::new(RecordingService {
|
||||
response: Some(WorkspaceResponse {
|
||||
status: 409,
|
||||
body: "Backend rejected Ticket claim".into(),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let error = tool(service.clone(), TicketWorkflowState::Planning)
|
||||
.execute(
|
||||
&serde_json::json!({
|
||||
"ticket_id": "00001KZXN51C7",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": [{
|
||||
"alias": "checkout",
|
||||
"working_directory_id": "workdir-1"
|
||||
}]
|
||||
})
|
||||
.to_string(),
|
||||
ToolExecutionContext::new("call-queued", "batch-1", 0),
|
||||
&input().to_string(),
|
||||
ToolExecutionContext::new("call-claim", "batch-1", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("must be queued or inprogress"));
|
||||
assert!(worker_service.requests.lock().unwrap().is_empty());
|
||||
assert_eq!(service.requests.lock().unwrap().len(), 1);
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("HTTP 409: Backend rejected Ticket claim")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_ticket_coder_rejects_invalid_workdir_collections_before_worker_side_effect() {
|
||||
let worker_service = Arc::new(RecordingService::default());
|
||||
let tool = SpawnTicketCoderTool {
|
||||
ticket_service: Arc::new(FixedTicketService(TicketWorkflowState::Queued)),
|
||||
worker_service: worker_service.clone(),
|
||||
};
|
||||
async fn spawn_ticket_worker_rejects_invalid_input_before_worker_side_effect() {
|
||||
let service = Arc::new(RecordingService::default());
|
||||
let tool = tool(service.clone(), TicketWorkflowState::Queued);
|
||||
let cases = [
|
||||
(
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": []
|
||||
}),
|
||||
"must contain at least one Workdir",
|
||||
"profile",
|
||||
serde_json::json!(" "),
|
||||
"profile must not be empty",
|
||||
),
|
||||
(
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": [
|
||||
{"alias": "checkout", "working_directory_id": "workdir-1"},
|
||||
{"alias": " checkout ", "working_directory_id": "workdir-2"}
|
||||
]
|
||||
}),
|
||||
"duplicate Workdir attachment alias `checkout`",
|
||||
"initial_request",
|
||||
serde_json::json!(" \n "),
|
||||
"initial_request must not be empty",
|
||||
),
|
||||
("flow", serde_json::json!(" "), "flow must not be empty"),
|
||||
(
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": [
|
||||
{"alias": "checkout", "working_directory_id": "workdir-1"},
|
||||
{"alias": "docs", "working_directory_id": " workdir-1 "}
|
||||
]
|
||||
}),
|
||||
"duplicate Workdir attachment `workdir-1`",
|
||||
),
|
||||
(
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": [
|
||||
{"alias": "docs/path", "working_directory_id": "workdir-1"}
|
||||
]
|
||||
}),
|
||||
"workdir_attachments.alias must be an authority id",
|
||||
),
|
||||
(
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": [
|
||||
{"alias": "docs", "working_directory_id": "https://example.test/workdir"}
|
||||
]
|
||||
}),
|
||||
"workdir_attachments.working_directory_id must be an authority id",
|
||||
),
|
||||
(
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime/1",
|
||||
"workdir_attachments": [
|
||||
{"alias": "checkout", "working_directory_id": "workdir-1"}
|
||||
]
|
||||
}),
|
||||
"runtime_id",
|
||||
serde_json::json!("runtime/1"),
|
||||
"runtime_id must be an authority id",
|
||||
),
|
||||
(
|
||||
serde_json::json!({
|
||||
"ticket_id": "T-482",
|
||||
"runtime_id": "runtime-1",
|
||||
"workdir_attachments": [
|
||||
{
|
||||
"alias": "checkout",
|
||||
"working_directory_id": "workdir-1",
|
||||
"relative_cwd": "../outside"
|
||||
}
|
||||
]
|
||||
}),
|
||||
"ticket_id",
|
||||
serde_json::json!("https://example.test/ticket"),
|
||||
"ticket_id must be an authority id",
|
||||
),
|
||||
(
|
||||
"workdir_attachments",
|
||||
serde_json::json!([
|
||||
{"alias": "checkout", "working_directory_id": "workdir-1"},
|
||||
{"alias": " checkout ", "working_directory_id": "workdir-2"}
|
||||
]),
|
||||
"duplicate Workdir attachment alias `checkout`",
|
||||
),
|
||||
(
|
||||
"workdir_attachments",
|
||||
serde_json::json!([
|
||||
{"alias": "checkout", "working_directory_id": "workdir-1"},
|
||||
{"alias": "docs", "working_directory_id": " workdir-1 "}
|
||||
]),
|
||||
"duplicate Workdir attachment `workdir-1`",
|
||||
),
|
||||
(
|
||||
"workdir_attachments",
|
||||
serde_json::json!([
|
||||
{"alias": "docs/path", "working_directory_id": "workdir-1"}
|
||||
]),
|
||||
"workdir_attachments.alias must be an authority id",
|
||||
),
|
||||
(
|
||||
"workdir_attachments",
|
||||
serde_json::json!([
|
||||
{"alias": "docs", "working_directory_id": "https://example.test/workdir"}
|
||||
]),
|
||||
"workdir_attachments.working_directory_id must be an authority id",
|
||||
),
|
||||
(
|
||||
"workdir_attachments",
|
||||
serde_json::json!([
|
||||
{"alias": "checkout", "working_directory_id": "workdir-1", "relative_cwd": "../outside"}
|
||||
]),
|
||||
"relative_cwd must be a normalized relative path inside the Workdir",
|
||||
),
|
||||
(
|
||||
"workdir_attachments",
|
||||
serde_json::json!([
|
||||
{"alias": "checkout", "working_directory_id": "workdir-1", "access": "write"}
|
||||
]),
|
||||
"unknown field `access`",
|
||||
),
|
||||
(
|
||||
"review",
|
||||
serde_json::json!({"ticket_id": "T-719", "merge_request_id": "MR-1"}),
|
||||
"unknown field `review`",
|
||||
),
|
||||
(
|
||||
"operation_id",
|
||||
serde_json::json!("caller-claim"),
|
||||
"unknown field `operation_id`",
|
||||
),
|
||||
(
|
||||
"display_name",
|
||||
serde_json::json!("Reviewer"),
|
||||
"unknown field `display_name`",
|
||||
),
|
||||
(
|
||||
"initial_submit",
|
||||
serde_json::json!([]),
|
||||
"unknown field `initial_submit`",
|
||||
),
|
||||
];
|
||||
|
||||
for (index, (input, expected)) in cases.into_iter().enumerate() {
|
||||
for (index, (field, value, expected)) in cases.into_iter().enumerate() {
|
||||
let mut input = input();
|
||||
input[field] = value;
|
||||
let error = tool
|
||||
.execute(
|
||||
&input.to_string(),
|
||||
@@ -493,10 +560,27 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains(expected),
|
||||
"unexpected error for case {index}: {error}"
|
||||
"unexpected error for {field}: {error}"
|
||||
);
|
||||
}
|
||||
assert!(worker_service.requests.lock().unwrap().is_empty());
|
||||
for field in ["ticket_id", "runtime_id", "initial_request"] {
|
||||
let mut input = input();
|
||||
input.as_object_mut().unwrap().remove(field);
|
||||
let error = tool
|
||||
.execute(
|
||||
&input.to_string(),
|
||||
ToolExecutionContext::new("call-missing", "batch-validation", 0),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains(&format!("missing field `{field}`")),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
assert!(service.requests.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -517,26 +601,38 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_surface_does_not_expose_profile_flow_or_assignment_controls() {
|
||||
let schema = serde_json::to_string(&schemars::schema_for!(SpawnTicketCoderInput)).unwrap();
|
||||
for field in [
|
||||
"ticket_id",
|
||||
"runtime_id",
|
||||
"workdir_attachments",
|
||||
"alias",
|
||||
"working_directory_id",
|
||||
"relative_cwd",
|
||||
] {
|
||||
assert!(schema.contains(field));
|
||||
fn spawn_ticket_worker_schema_exposes_configuration_not_claim_or_review_grants() {
|
||||
let definition = definition(
|
||||
Arc::new(FixedTicketService(TicketWorkflowState::Queued)),
|
||||
Arc::new(RecordingService::default()),
|
||||
);
|
||||
let (meta, _) = definition();
|
||||
assert_eq!(meta.name, "SpawnTicketWorker");
|
||||
let schema = serde_json::to_value(schemars::schema_for!(SpawnTicketWorkerInput)).unwrap();
|
||||
assert_eq!(schema["additionalProperties"], false);
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
for field in ["ticket_id", "runtime_id", "initial_request"] {
|
||||
assert!(
|
||||
required.contains(&serde_json::json!(field)),
|
||||
"missing required field {field}"
|
||||
);
|
||||
}
|
||||
for field in ["profile", "flow", "workdir_attachments"] {
|
||||
assert!(schema["properties"].get(field).is_some());
|
||||
assert!(!required.contains(&serde_json::json!(field)));
|
||||
}
|
||||
for forbidden in [
|
||||
"profile",
|
||||
"selector",
|
||||
"review",
|
||||
"operation_id",
|
||||
"display_name",
|
||||
"initial_submit",
|
||||
"claim",
|
||||
"access",
|
||||
] {
|
||||
assert!(!schema.contains(forbidden), "schema leaked {forbidden}");
|
||||
assert!(
|
||||
schema["properties"].get(forbidden).is_none(),
|
||||
"schema leaked {forbidden}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ struct ModelTicketQueryItem {
|
||||
workspace_action_priority: Option<String>,
|
||||
matched_fields: Vec<String>,
|
||||
snippet: Option<String>,
|
||||
current_coder: Option<ModelWorkerSummary>,
|
||||
current_worker: Option<ModelWorkerSummary>,
|
||||
linked_objectives: Vec<String>,
|
||||
relation_count: usize,
|
||||
blocker_count: usize,
|
||||
@@ -47,7 +47,7 @@ pub(super) struct ModelTicketDetail {
|
||||
relations: ModelTicketRelations,
|
||||
linked_objectives: Vec<ModelObjectiveSummary>,
|
||||
assignments: Vec<ModelAssignment>,
|
||||
current_coder: Option<ModelWorkerSummary>,
|
||||
current_worker: Option<ModelWorkerSummary>,
|
||||
implementation_reports: Vec<ModelEvidenceEvent>,
|
||||
merge_requests: Vec<ModelMergeRequest>,
|
||||
merge_request: Option<ModelMergeRequest>,
|
||||
@@ -195,7 +195,7 @@ struct ModelTicketActions {
|
||||
can_assign_orchestrator: bool,
|
||||
can_unassign_orchestrator: bool,
|
||||
can_queue: bool,
|
||||
can_start_manual_coder: bool,
|
||||
can_start_manual_worker: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -244,8 +244,8 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
||||
workspace_action_priority: optional_string(item, "workspace_action_priority")?,
|
||||
matched_fields: string_array(item, "matched_fields")?,
|
||||
snippet: optional_string(item, "snippet")?,
|
||||
current_coder: item
|
||||
.get("current_coder")
|
||||
current_worker: item
|
||||
.get("current_worker")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_worker)
|
||||
.transpose()?,
|
||||
@@ -272,14 +272,14 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
||||
|
||||
pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, String> {
|
||||
let root = object(&value, "Ticket detail response")?;
|
||||
let current_coder = root
|
||||
.get("current_coder")
|
||||
let current_worker = root
|
||||
.get("current_worker")
|
||||
.filter(|value| !value.is_null())
|
||||
.map(project_worker)
|
||||
.transpose()?;
|
||||
let assignments = array_field(root, "assignments")?
|
||||
.iter()
|
||||
.map(|assignment| project_assignment(assignment, current_coder.as_ref()))
|
||||
.map(|assignment| project_assignment(assignment, current_worker.as_ref()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(ModelTicketDetail {
|
||||
@@ -302,7 +302,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
||||
.map(project_objective_summary)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
assignments,
|
||||
current_coder,
|
||||
current_worker,
|
||||
implementation_reports: array_field(root, "implementation_reports")?
|
||||
.iter()
|
||||
.map(project_evidence_event)
|
||||
@@ -483,13 +483,13 @@ fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
|
||||
|
||||
fn project_assignment(
|
||||
value: &Value,
|
||||
current_coder: Option<&ModelWorkerSummary>,
|
||||
current_worker: Option<&ModelWorkerSummary>,
|
||||
) -> Result<ModelAssignment, String> {
|
||||
let assignment = object(value, "Ticket assignment")?;
|
||||
let principal = object_field(assignment, "principal")?;
|
||||
let kind = string_field(principal, "kind")?;
|
||||
let principal = match kind.as_str() {
|
||||
"worker" => current_coder
|
||||
"worker" => current_worker
|
||||
.map(|coder| coder.worker.clone())
|
||||
.ok_or_else(|| "Worker assignment is missing a Workspace key projection".to_string())?,
|
||||
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
|
||||
@@ -572,7 +572,7 @@ fn project_actions(value: &Value) -> Result<ModelTicketActions, String> {
|
||||
can_assign_orchestrator: bool_field(actions, "can_assign_orchestrator")?,
|
||||
can_unassign_orchestrator: bool_field(actions, "can_unassign_orchestrator")?,
|
||||
can_queue: bool_field(actions, "can_queue")?,
|
||||
can_start_manual_coder: bool_field(actions, "can_start_manual_coder")?,
|
||||
can_start_manual_worker: bool_field(actions, "can_start_manual_worker")?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -783,7 +783,7 @@ mod tests {
|
||||
"workspace_action_priority": "active_work",
|
||||
"matched_fields": ["title"],
|
||||
"snippet": "Ticket",
|
||||
"current_coder": {"runtime_id": "runtime-internal", "worker_id": "worker-internal", "worker_resource_key": "W-12"},
|
||||
"current_worker": {"runtime_id": "runtime-internal", "worker_id": "worker-internal", "worker_resource_key": "W-12"},
|
||||
"linked_objective_ids": ["00001OBJECTIVEINTERNAL"],
|
||||
"linked_objective_keys": ["O-6"],
|
||||
"relation_count": 0,
|
||||
@@ -870,7 +870,7 @@ mod tests {
|
||||
"linked_objectives": [],
|
||||
"implementation_reports": [],
|
||||
"assignments": [],
|
||||
"current_coder": null,
|
||||
"current_worker": null,
|
||||
"merge_requests": [],
|
||||
"merge_request": null,
|
||||
"evidence": {
|
||||
@@ -889,7 +889,7 @@ mod tests {
|
||||
"can_assign_orchestrator": true,
|
||||
"can_unassign_orchestrator": false,
|
||||
"can_queue": false,
|
||||
"can_start_manual_coder": false
|
||||
"can_start_manual_worker": false
|
||||
},
|
||||
"event_page": {"next_cursor": null, "has_more": false}
|
||||
}))
|
||||
|
||||
@@ -335,9 +335,9 @@ struct WorkspaceTicketCloseInput {
|
||||
references: Vec<ticket::TicketReference>,
|
||||
}
|
||||
|
||||
const TICKET_CLOSE_DESCRIPTION: &str = "Close a Ticket with resolution, item/state CAS, and replay key. No Merge Request or enforced state sequence is required. Actor identity is transport-bound; this does not integrate Merge Requests or expand Workdir grants.";
|
||||
const COMPLETE_TICKET_DESCRIPTION: &str = "Record a Ticket completion decision with reason, item/state CAS, and replay key. Merge Requests and approvals are not prerequisites. The Backend authorizes the assigned Worker or registered Workspace Orchestrator; roles supplied by the model never grant authority.";
|
||||
const TICKET_STATE_UPDATE_DESCRIPTION: &str = "Update a Ticket progress-display state with reason, item/state CAS, and replay key. States need not follow an enforced sequence. This does not start Workers, queue dependencies, integrate Merge Requests, or enlarge live Workdir grants; the Backend validates actor authority.";
|
||||
const TICKET_CLOSE_DESCRIPTION: &str = "Record an authorized Ticket closure decision with resolution, item/state CAS, and replay key when the requested return path calls for closure. No Merge Request or enforced state sequence is required. Backend checks actor authority, not natural-language satisfaction; this does not integrate MRs, stop/remove Workers, clean attachments, or expand Workdir grants.";
|
||||
const COMPLETE_TICKET_DESCRIPTION: &str = "Record an authorized Ticket completion decision with reason, item/state CAS, and replay key when user intent calls for completion; return without conclusion need not call this tool. Merge Requests and approvals are not prerequisites. Backend checks assigned Worker/Workspace Orchestrator authority, not real judgment of natural-language satisfaction; model-supplied roles never grant authority. Conclusion ends unfinished work while retaining terminal responsibility for display; it does not grant review/merge authority or stop/remove Workers and attachments.";
|
||||
const TICKET_STATE_UPDATE_DESCRIPTION: &str = "Update a Ticket progress-display state with reason, item/state CAS, and replay key. States need not follow an enforced sequence. Backend checks actor authority, not natural-language satisfaction. This does not start/stop Workers, queue dependencies, integrate Merge Requests, clean attachments, or enlarge live Workdir grants.";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum WorkspaceTicketDecisionKind {
|
||||
@@ -508,8 +508,7 @@ fn workspace_ticket_tool_description(name: &str) -> String {
|
||||
|
||||
const FEATURE_ID: &str = "ticket";
|
||||
const FEATURE_NAME: &str = "Ticket tools";
|
||||
const FEATURE_DESCRIPTION: &str =
|
||||
"Typed Ticket operations through the authoritative Workspace API.";
|
||||
const FEATURE_DESCRIPTION: &str = "Intent-led Ticket reads and authorized decisions through the Workspace API; no universal MR/approval gate, and no Backend judgment of natural-language satisfaction.";
|
||||
const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow";
|
||||
const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets";
|
||||
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
|
||||
@@ -1814,11 +1813,11 @@ pub fn mount_workspace_http_ticket_wip(
|
||||
description: Some(
|
||||
"Authoritative Ticket collection through scoped Backend authority".into(),
|
||||
),
|
||||
interfaces: vec![TICKET_COLLECTION_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(TICKET_COLLECTION_INTERFACE)],
|
||||
r#ref: Some("ticket:collection".into()),
|
||||
validator: Some(route_validator(&collection_route, "collection")),
|
||||
},
|
||||
interface: TICKET_COLLECTION_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(TICKET_COLLECTION_INTERFACE),
|
||||
interface_validator: Some(descriptor_validator(&collection_descriptor)),
|
||||
descriptor: collection_descriptor,
|
||||
handler: Arc::new(TicketCollectionWipHandler {
|
||||
@@ -1837,7 +1836,7 @@ pub fn mount_workspace_http_ticket_wip(
|
||||
registry.mount_dynamic(WipDynamicMount {
|
||||
collection_route: collection_route.clone(),
|
||||
capability: "ticket:item".into(),
|
||||
interface: TICKET_ITEM_INTERFACE.into(),
|
||||
interface: crate::wip::root_reference(TICKET_ITEM_INTERFACE),
|
||||
interface_validator: Some(descriptor_validator(&item_descriptor)),
|
||||
descriptor: item_descriptor,
|
||||
resolver: Arc::new(TicketItemResolver {
|
||||
@@ -1923,7 +1922,7 @@ impl WipDynamicItemResolver for TicketItemResolver {
|
||||
object: Object {
|
||||
name: item_reference.into(),
|
||||
description: Some("Authoritative Ticket bound to this object route".into()),
|
||||
interfaces: vec![TICKET_ITEM_INTERFACE.into()],
|
||||
interfaces: vec![crate::wip::root_reference(TICKET_ITEM_INTERFACE)],
|
||||
r#ref: Some(format!("ticket:{item_reference}")),
|
||||
validator: Some(route_validator(&route, &revision)),
|
||||
},
|
||||
@@ -2945,6 +2944,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_ticket_decision_descriptions_preserve_intent_and_independent_lifecycle() {
|
||||
let tools = native_ticket_tools(
|
||||
decision_client(200, TicketWorkflowState::Ready),
|
||||
TicketFeatureAccess::work_report(),
|
||||
)
|
||||
.unwrap();
|
||||
let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap();
|
||||
let complete = descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.find(|op| op.name == "complete")
|
||||
.unwrap();
|
||||
let summary = &complete.documentation.as_ref().unwrap().summary;
|
||||
assert!(summary.contains("Merge Requests and approvals are not prerequisites"));
|
||||
assert!(summary.contains("return without conclusion need not call this tool"));
|
||||
assert!(summary.contains("not real judgment"));
|
||||
assert!(summary.contains("retaining terminal responsibility"));
|
||||
assert!(summary.contains("does not grant review/merge authority or stop/remove Workers"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_ticket_completion_binds_subject_and_stales_only_its_ticket_not_mrs() {
|
||||
let client = decision_client(200, TicketWorkflowState::Done);
|
||||
|
||||
@@ -318,7 +318,7 @@ impl FeatureInvocationHandler for WorkspaceConfigFeature {
|
||||
result.access.as_str()
|
||||
),
|
||||
context: Some(format!(
|
||||
"Workspace configuration is available as logical attachment `{ATTACHMENT_ALIAS}` at WIP `{CONTENT_ROOT}` with {} access. Discover structure through WIP; read and change bodies through published Operations. This attachment grants no OS paths or command execution. Backend validation, concurrency checks and activation rules apply; saving does not imply running Workers adopted new configuration.",
|
||||
"Workspace configuration is available as logical attachment `{ATTACHMENT_ALIAS}` at WIP `{CONTENT_ROOT}` with {} access. Use Tree for indexable structure, Inspect with a known Object path for its complete signatures, and Invoke for published Operations. This attachment grants no OS paths or command execution. Backend validation, concurrency checks and activation rules apply; saving does not imply running Workers adopted new configuration.",
|
||||
result.access.as_str()
|
||||
)),
|
||||
})
|
||||
|
||||
@@ -185,16 +185,8 @@ async fn observe(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for ConfigProvider {
|
||||
fn max_depth(&self) -> u32 {
|
||||
MAX_DEPTH
|
||||
}
|
||||
fn max_nodes(&self) -> usize {
|
||||
MAX_NODES
|
||||
}
|
||||
|
||||
async fn projection(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||
impl ConfigProvider {
|
||||
async fn projection_at(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
|
||||
let source = source_path(path).map_err(protocol)?;
|
||||
let Some(attachment) = visible_metadata(self.backend.current_async().await)? else {
|
||||
return Ok(None);
|
||||
@@ -205,7 +197,7 @@ impl WipSubtreeProvider for ConfigProvider {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut projection = super::wip::attach_projection(&self.feature);
|
||||
projection.interface = contextual_reference(&projection.interface, path);
|
||||
projection.interface = contextual_reference(&projection.interface.name, path);
|
||||
projection.object.interfaces = vec![projection.interface.clone()];
|
||||
return Ok(Some(projection));
|
||||
};
|
||||
@@ -259,6 +251,26 @@ impl WipSubtreeProvider for ConfigProvider {
|
||||
}),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for ConfigProvider {
|
||||
fn max_depth(&self) -> u32 {
|
||||
MAX_DEPTH
|
||||
}
|
||||
fn max_nodes(&self) -> usize {
|
||||
MAX_NODES
|
||||
}
|
||||
|
||||
async fn publication(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Option<crate::wip::WipPublication>, ProtocolError> {
|
||||
self.projection_at(path)
|
||||
.await?
|
||||
.map(crate::wip::WipPublication::self_scoped)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
let source = source_path(path).map_err(protocol)?;
|
||||
|
||||
@@ -428,7 +428,7 @@ fn runtime(client: Arc<dyn WorkspaceClient>) -> (WorkspaceConfigFeature, WipRunt
|
||||
let runtime = WipRuntime::from_mounts(mounts, "worker-1@workspace-1".into()).unwrap();
|
||||
(feature, runtime)
|
||||
}
|
||||
fn interface(path: &str, attached: bool) -> String {
|
||||
fn interface(path: &str, attached: bool) -> wip_protocol::InterfaceReference {
|
||||
crate::wip::contextual_reference(
|
||||
if attached {
|
||||
"yoi.workspace-config/node/v1"
|
||||
@@ -438,12 +438,9 @@ fn interface(path: &str, attached: bool) -> String {
|
||||
path,
|
||||
)
|
||||
}
|
||||
async fn observe_interface(runtime: &WipRuntime, path: &str, attached: bool) {
|
||||
runtime.discover(path.into(), 0, true).await.unwrap();
|
||||
runtime
|
||||
.inspect(interface(path, attached), true)
|
||||
.await
|
||||
.unwrap();
|
||||
async fn observe_interface(runtime: &WipRuntime, path: &str, _attached: bool) {
|
||||
runtime.tree(path.into(), 0, true).await.unwrap();
|
||||
runtime.inspect(path.into(), true).await.unwrap();
|
||||
}
|
||||
async fn call(
|
||||
runtime: &WipRuntime,
|
||||
@@ -497,7 +494,7 @@ async fn workspace_config_production_slash_deep_discover_read_edit_and_atomic_im
|
||||
.starts_with("Already attached")
|
||||
);
|
||||
let discovered = runtime
|
||||
.discover(CONTENT_ROOT.into(), 5, true)
|
||||
.tree(CONTENT_ROOT.into(), 5, true)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
@@ -606,7 +603,7 @@ async fn workspace_config_production_native_attach_create_delete_and_detach_end_
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.discover("/workspace-config/main.dcdl".into(), 0, true)
|
||||
.tree("/workspace-config/main.dcdl".into(), 0, true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
@@ -633,19 +630,15 @@ async fn workspace_config_production_revocation_ro_cross_workspace_and_cache_iso
|
||||
);
|
||||
observe_interface(&runtime, path, true).await;
|
||||
let descriptor = runtime
|
||||
.inspect(interface(path, true), true)
|
||||
.inspect(path.into(), true)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap();
|
||||
let descriptor: Json = serde_json::from_str(&descriptor).unwrap();
|
||||
let names = descriptor["descriptor"]["operations"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|op| op["name"].as_str().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(names, ["read"]);
|
||||
let signature = descriptor["interfaces"][0]["signature"].as_str().unwrap();
|
||||
assert!(signature.contains("operation read("));
|
||||
assert!(!signature.contains("operation write("));
|
||||
assert!(
|
||||
call(&runtime, path, "write", json!({"content":"DENIED"}))
|
||||
.await
|
||||
@@ -657,7 +650,7 @@ async fn workspace_config_production_revocation_ro_cross_workspace_and_cache_iso
|
||||
});
|
||||
let (feature, other) = runtime_for_cross(cross);
|
||||
assert!(feature.attach("cross", None).await.is_err());
|
||||
assert!(other.discover(path.into(), 0, false).await.is_err());
|
||||
assert!(other.tree(path.into(), 0, false).await.is_err());
|
||||
assert!(client.state.lock().unwrap().commits.is_empty());
|
||||
}
|
||||
fn runtime_for_cross(client: Arc<dyn WorkspaceClient>) -> (WorkspaceConfigFeature, WipRuntime) {
|
||||
@@ -760,21 +753,16 @@ async fn workspace_config_production_bounds_and_traversal_reject_before_content_
|
||||
let client = Router::new();
|
||||
let (feature, runtime) = runtime(client.clone());
|
||||
feature.attach("initial", None).await.unwrap();
|
||||
assert!(
|
||||
runtime
|
||||
.discover(CONTENT_ROOT.into(), 9, true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(runtime.tree(CONTENT_ROOT.into(), 9, true).await.is_err());
|
||||
for path in [
|
||||
"/workspace-config/../secret",
|
||||
"/workspace-config//main.dcdl",
|
||||
"/workspace-config/./main.dcdl",
|
||||
] {
|
||||
assert!(runtime.discover(path.into(), 0, true).await.is_err());
|
||||
assert!(runtime.tree(path.into(), 0, true).await.is_err());
|
||||
}
|
||||
let path = format!("/workspace-config/{}", "a".repeat(513));
|
||||
assert!(runtime.discover(path, 0, true).await.is_err());
|
||||
assert!(runtime.tree(path, 0, true).await.is_err());
|
||||
assert!(client.state.lock().unwrap().commits.is_empty());
|
||||
}
|
||||
|
||||
@@ -837,7 +825,7 @@ async fn workspace_config_production_cumulative_node_limit_and_edit_growth_are_b
|
||||
// No individual depth-1 response exceeds the cap; the whole Host traversal
|
||||
// must nevertheless stop before exceeding 256 Objects.
|
||||
let error = runtime
|
||||
.discover(CONTENT_ROOT.into(), 4, true)
|
||||
.tree(CONTENT_ROOT.into(), 4, true)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("ResourceLimitExceeded"));
|
||||
@@ -849,7 +837,7 @@ async fn workspace_config_production_backend_failure_is_not_hidden_as_unauthoriz
|
||||
client.state.lock().unwrap().fail_current = true;
|
||||
let (_, runtime) = runtime(client);
|
||||
let error = runtime
|
||||
.discover(CONTENT_ROOT.into(), 0, true)
|
||||
.tree(CONTENT_ROOT.into(), 0, true)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let message = error.to_string();
|
||||
@@ -907,16 +895,11 @@ async fn workspace_config_production_ungranted_root_does_not_poison_worldspace_d
|
||||
let client = Router::new();
|
||||
client.state.lock().unwrap().granted = false;
|
||||
let (_, runtime) = runtime(client.clone());
|
||||
let discovery = runtime.discover("/".into(), 2, true).await.unwrap();
|
||||
let discovery = runtime.tree("/".into(), 2, true).await.unwrap();
|
||||
let discovery: Json = serde_json::from_str(discovery.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(discovery["known_space"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(discovery["known_space"][0]["path"], "/");
|
||||
assert!(
|
||||
runtime
|
||||
.discover(CONTENT_ROOT.into(), 0, true)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(discovery["tree"]["children"], json!([]));
|
||||
assert_eq!(discovery["tree"]["path"], "/");
|
||||
assert!(runtime.tree(CONTENT_ROOT.into(), 0, true).await.is_err());
|
||||
assert!(
|
||||
client
|
||||
.state
|
||||
|
||||
@@ -93,13 +93,13 @@ pub(super) fn attach_projection(feature: &WorkspaceConfigFeature) -> WipProjecti
|
||||
object: Object {
|
||||
name: ATTACHMENT_ALIAS.into(),
|
||||
description: Some("Logical Workspace configuration attachment entrance".into()),
|
||||
interfaces: vec![interface.into()],
|
||||
interfaces: vec![crate::wip::root_reference(interface)],
|
||||
r#ref: None,
|
||||
// Attaching does not modify config content. The content subtree will
|
||||
// supply its own Backend validators for file/directory Operations.
|
||||
validator: None,
|
||||
},
|
||||
interface: interface.into(),
|
||||
interface: crate::wip::root_reference(interface),
|
||||
descriptor,
|
||||
interface_validator,
|
||||
handler: Arc::new(AttachHandler(feature.clone())),
|
||||
|
||||
@@ -143,6 +143,7 @@ impl WorkerFsView {
|
||||
.list(ListRequest {
|
||||
path: logical.clone(),
|
||||
limit: DIR_FILE_REF_ENTRY_LIMIT,
|
||||
after: None,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)
|
||||
@@ -222,6 +223,7 @@ impl WorkerFsView {
|
||||
.list(ListRequest {
|
||||
path: parent,
|
||||
limit: COMPLETION_LIMIT,
|
||||
after: None,
|
||||
})
|
||||
.await
|
||||
else {
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
//! 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::scheme::{Scheme, openai_responses::OpenAIResponsesScheme};
|
||||
use agen::llm_client::{ClientError, Request, ResponseStream};
|
||||
use agen::tool::{Attachment, ImageAttachment};
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
enum RejectionRoute {
|
||||
#[default]
|
||||
Http,
|
||||
SseError,
|
||||
ResponseFailed,
|
||||
}
|
||||
|
||||
const REJECTION_ROUTES: [RejectionRoute; 3] = [
|
||||
RejectionRoute::Http,
|
||||
RejectionRoute::SseError,
|
||||
RejectionRoute::ResponseFailed,
|
||||
];
|
||||
|
||||
struct RetryObservation {
|
||||
store: session_store::FsStore,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
committed: Option<Vec<LogEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct RejectImages {
|
||||
route: RejectionRoute,
|
||||
requests: Arc<Mutex<Vec<Request>>>,
|
||||
retry_observation: Arc<Mutex<Option<RetryObservation>>>,
|
||||
cancel_on_retry: Arc<Mutex<Option<tokio::sync::mpsc::Sender<()>>>>,
|
||||
}
|
||||
#[async_trait]
|
||||
@@ -21,12 +45,49 @@ impl LlmClient for RejectImages {
|
||||
);
|
||||
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 matches!(self.route, RejectionRoute::Http) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
// Exercise the public Responses parser, not a hand-built ErrorEvent.
|
||||
// Started is metadata; no generated block precedes this rejection.
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut state = Default::default();
|
||||
let mut events = scheme.parse_sse(
|
||||
"response.created",
|
||||
r#"{"response":{"id":"image-response"}}"#,
|
||||
&mut state,
|
||||
)?;
|
||||
let (event_type, data) = match self.route {
|
||||
RejectionRoute::Http => unreachable!(),
|
||||
RejectionRoute::SseError => (
|
||||
"error",
|
||||
r#"{"type":"error","code":"invalid_value","message":"The image requires 52150 patches, exceeding the limit of 30000."}"#,
|
||||
),
|
||||
RejectionRoute::ResponseFailed => (
|
||||
"response.failed",
|
||||
r#"{"type":"response.failed","response":{"id":"image-response","status":"failed","error":{"code":"invalid_value","message":"The image requires 52150 patches, exceeding the limit of 30000."}}}"#,
|
||||
),
|
||||
};
|
||||
events.extend(scheme.parse_sse(event_type, data, &mut state)?);
|
||||
return Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok))));
|
||||
}
|
||||
// Capture committed filesystem state at the retry boundary, before any
|
||||
// successful output or cancellation can cause a later append.
|
||||
if let Some(observation) = self.retry_observation.lock().unwrap().as_mut()
|
||||
&& observation.committed.is_none()
|
||||
{
|
||||
observation.committed = Some(
|
||||
observation
|
||||
.store
|
||||
.read_all(observation.session_id, observation.segment_id)
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
if let Some(cancel) = self.cancel_on_retry.lock().unwrap().take() {
|
||||
cancel.try_send(()).unwrap();
|
||||
@@ -43,7 +104,9 @@ impl LlmClient for RejectImages {
|
||||
}
|
||||
}
|
||||
|
||||
async fn image_worker() -> (
|
||||
async fn image_worker(
|
||||
route: RejectionRoute,
|
||||
) -> (
|
||||
tempfile::TempDir,
|
||||
Worker<RejectImages, session_store::FsStore>,
|
||||
RejectImages,
|
||||
@@ -63,7 +126,10 @@ allow = []
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let client = RejectImages::default();
|
||||
let client = RejectImages {
|
||||
route,
|
||||
..RejectImages::default()
|
||||
};
|
||||
let mut worker = Worker::new(
|
||||
manifest,
|
||||
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(client.clone()),
|
||||
@@ -90,24 +156,81 @@ allow = []
|
||||
),
|
||||
]);
|
||||
worker.wire_history_persistence();
|
||||
*client.retry_observation.lock().unwrap() = Some(RetryObservation {
|
||||
// Reopen the store so this observes filesystem records, not engine state.
|
||||
store: session_store::FsStore::new(worker.store.root_dir()).unwrap(),
|
||||
session_id: worker.session_id(),
|
||||
segment_id: worker.segment_id(),
|
||||
committed: None,
|
||||
});
|
||||
(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())
|
||||
}
|
||||
|
||||
fn assert_correction_committed_before_retry(client: &RejectImages) {
|
||||
let observation = client.retry_observation.lock().unwrap();
|
||||
let observation = observation.as_ref().unwrap();
|
||||
let raw = observation
|
||||
.committed
|
||||
.as_ref()
|
||||
.expect("the retry must observe the committed correction");
|
||||
assert_eq!(
|
||||
raw.iter()
|
||||
.filter(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
.count(),
|
||||
1,
|
||||
"{:?}: correction must already be appended at retry",
|
||||
client.route
|
||||
);
|
||||
let restored =
|
||||
restore_history_entries(observation.session_id, observation.segment_id, raw).unwrap();
|
||||
let corrected = restored
|
||||
.iter()
|
||||
.find(|entry| rejected(&entry.item))
|
||||
.expect("committed retry history must contain the attachment-free error");
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2, "{:?}", client.route);
|
||||
assert!(requests[0].items.iter().any(
|
||||
|item| matches!(item, Item::ToolResult { attachments, .. } if !attachments.is_empty())
|
||||
));
|
||||
assert_eq!(
|
||||
requests[1].items.iter().find(|item| rejected(item)),
|
||||
Some(&corrected.item),
|
||||
"{:?}: retry must use the durable corrected result",
|
||||
client.route
|
||||
);
|
||||
assert!(requests[1].items.iter().all(
|
||||
|item| !matches!(item, Item::ToolResult { attachments, .. } if !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;
|
||||
for route in REJECTION_ROUTES {
|
||||
assert_image_correction_survives_restore_and_reseed(route).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_image_correction_survives_restore_and_reseed(route: RejectionRoute) {
|
||||
let (_dir, mut worker, client) = image_worker(route).await;
|
||||
assert_eq!(
|
||||
worker.run_text("continue").await.unwrap(),
|
||||
WorkerRunResult::Finished
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
assert_correction_committed_before_retry(&client);
|
||||
let raw = worker
|
||||
.store
|
||||
.read_all(worker.session_id(), worker.segment_id())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
raw.iter()
|
||||
.filter(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
.count(),
|
||||
1,
|
||||
"{route:?}: recovery must append exactly one correction"
|
||||
);
|
||||
let correction = raw
|
||||
.iter()
|
||||
.find(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
@@ -177,8 +300,17 @@ async fn image_correction_is_committed_before_retry_and_survives_restore_and_res
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
let reseeded_segment_id = session_store::new_segment_id();
|
||||
worker
|
||||
.store
|
||||
.create_segment(worker.session_id(), reseeded_segment_id, &[seed])
|
||||
.unwrap();
|
||||
let reopened_store = session_store::FsStore::new(worker.store.root_dir()).unwrap();
|
||||
let reseeded_raw = reopened_store
|
||||
.read_all(worker.session_id(), reseeded_segment_id)
|
||||
.unwrap();
|
||||
let restored_again =
|
||||
restore_history_entries(worker.session_id(), worker.segment_id(), &[seed]).unwrap();
|
||||
restore_history_entries(worker.session_id(), reseeded_segment_id, &reseeded_raw).unwrap();
|
||||
assert_eq!(
|
||||
restored_again
|
||||
.iter()
|
||||
@@ -186,6 +318,12 @@ async fn image_correction_is_committed_before_retry_and_survives_restore_and_res
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let effective_correction = restored_again
|
||||
.iter()
|
||||
.find(|entry| rejected(&entry.item))
|
||||
.unwrap();
|
||||
assert_eq!(effective_correction.annotation, original.metadata);
|
||||
let expected_result = effective_correction.item.clone();
|
||||
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 |_| {
|
||||
@@ -193,22 +331,41 @@ async fn image_correction_is_committed_before_retry_and_survives_restore_and_res
|
||||
})
|
||||
.await;
|
||||
assert!(matches!(out.result, EngineRunExit::Finished));
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 3);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 3, "{route:?}");
|
||||
assert_eq!(
|
||||
requests[2].items.iter().find(|item| rejected(item)),
|
||||
Some(&expected_result),
|
||||
"{route:?}: next request must retain the restored correction"
|
||||
);
|
||||
assert!(requests[2].items.iter().all(
|
||||
|item| !matches!(item, Item::ToolResult { attachments, .. } if !attachments.is_empty())
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_after_image_correction_does_not_rollback_the_correction() {
|
||||
let (_dir, mut worker, client) = image_worker().await;
|
||||
for route in REJECTION_ROUTES {
|
||||
assert_cancellation_preserves_image_correction(route).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_cancellation_preserves_image_correction(route: RejectionRoute) {
|
||||
let (_dir, mut worker, client) = image_worker(route).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);
|
||||
assert_eq!(result, WorkerRunResult::Cancelled, "{route:?}");
|
||||
assert_correction_committed_before_retry(&client);
|
||||
let raw = worker
|
||||
.store
|
||||
.read_all(worker.session_id(), worker.segment_id())
|
||||
.unwrap();
|
||||
assert!(
|
||||
assert_eq!(
|
||||
raw.iter()
|
||||
.any(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
.filter(|entry| matches!(entry, LogEntry::ToolResultCorrected { .. }))
|
||||
.count(),
|
||||
1,
|
||||
"{route:?}: cancellation must preserve exactly one correction"
|
||||
);
|
||||
let restored = restore_history_entries(worker.session_id(), worker.segment_id(), &raw).unwrap();
|
||||
assert_eq!(
|
||||
|
||||
@@ -494,7 +494,11 @@ impl InternalWorkerSessionHandle {
|
||||
(entries, snapshot_from_guard(&guard))
|
||||
};
|
||||
InternalWorkerSessionSnapshot {
|
||||
session: session_store::public_snapshot::project_current_session_snapshot(&entries),
|
||||
session:
|
||||
session_store::public_snapshot::project_current_session_snapshot_with_accounting(
|
||||
&self.store,
|
||||
&entries,
|
||||
),
|
||||
greeting: self.greeting.as_ref().map(|greeting| {
|
||||
greeting
|
||||
.read()
|
||||
|
||||
@@ -96,7 +96,22 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
|
||||
item: value,
|
||||
})
|
||||
}
|
||||
LogEntry::Invoke { trigger, .. } => Some(Event::InvokeStart { kind: trigger }),
|
||||
LogEntry::Invoke { ts, trigger } => Some(Event::InvokeStart {
|
||||
kind: trigger,
|
||||
timestamp_ms: Some(ts),
|
||||
}),
|
||||
LogEntry::LlmUsage {
|
||||
ts,
|
||||
input_total_tokens,
|
||||
cache_read_tokens,
|
||||
output_tokens,
|
||||
..
|
||||
} => Some(Event::Usage {
|
||||
timestamp_ms: Some(ts),
|
||||
input_tokens: Some(input_total_tokens),
|
||||
cache_read_input_tokens: Some(cache_read_tokens),
|
||||
output_tokens: Some(output_tokens),
|
||||
}),
|
||||
other => {
|
||||
// `SegmentLogSink::is_live_relevant` keeps non-live-relevant
|
||||
// variants off the broadcast lane; reaching here means the two are
|
||||
|
||||
@@ -284,6 +284,7 @@ fn validate_profile_with_grant(
|
||||
manage_workdir,
|
||||
workdir_catalog,
|
||||
workspace_config,
|
||||
drive,
|
||||
ticket,
|
||||
merge_request,
|
||||
orchestration,
|
||||
@@ -315,6 +316,7 @@ fn validate_profile_with_grant(
|
||||
("feature.objective", objective.enabled),
|
||||
("feature.manage_workdir", manage_workdir.enabled),
|
||||
("feature.workspace_config", workspace_config.enabled),
|
||||
("feature.drive", drive.enabled),
|
||||
(
|
||||
"feature.ticket",
|
||||
ticket.enabled || ticket.authoring || ticket.thread || ticket.intake || ticket.workflow,
|
||||
|
||||
@@ -65,9 +65,9 @@ pub use session_history::{
|
||||
pub use shared_state::WorkerSharedState;
|
||||
pub use worker::{
|
||||
LocalWorkingDirectory, SubjektivSessionAttributionLifecycle, Worker, WorkerError,
|
||||
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceClient,
|
||||
WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspacePromptCatalogResolution,
|
||||
WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, WorkspaceServerOperation,
|
||||
WorkspaceWorkerDiscoveryRequest, apply_worker_manifest, marker_workspace_client,
|
||||
unavailable_workspace_client,
|
||||
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceBinaryRequest,
|
||||
WorkspaceBinaryResponse, WorkspaceClient, WorkspaceClientError, WorkspaceId, WorkspaceIdError,
|
||||
WorkspacePromptCatalogResolution, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
|
||||
WorkspaceServerOperation, WorkspaceWorkerDiscoveryRequest, apply_worker_manifest,
|
||||
marker_workspace_client, unavailable_workspace_client,
|
||||
};
|
||||
|
||||
@@ -1034,7 +1034,8 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert!(rendered.contains("This policy governs naming only"));
|
||||
assert!(rendered.contains("Coder cleanup is a separate post-completion decision"));
|
||||
assert!(rendered.contains("Worker cleanup is a separate lifecycle decision"));
|
||||
assert!(rendered.contains("not an automatic consequence of a Ticket state"));
|
||||
assert!(rendered.contains("perform one cleanup pass before ending the orchestration turn"));
|
||||
assert!(rendered.contains("Never predeclare `delete_on_completion`"));
|
||||
assert!(rendered.contains("call `WorkerStop`"));
|
||||
|
||||
@@ -322,6 +322,154 @@ fn append_trailing_section(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::feature::FeatureRegistryBuilder;
|
||||
use crate::feature::builtin::merge_request::MergeRequestFeature;
|
||||
use crate::feature::builtin::ticket::{TicketFeature, TicketFeatureAccess};
|
||||
use crate::hook::HookRegistryBuilder;
|
||||
use crate::worker::TestWorkspaceHttpClient;
|
||||
use manifest::MergeRequestFeatureConfig;
|
||||
|
||||
// These builtin resources are the model-visible workflow compatibility contract.
|
||||
// Fixtures prove composition and tool descriptions, not an agent's real judgment.
|
||||
fn ticket_workflow_fixture(
|
||||
role: &str,
|
||||
mr_config: MergeRequestFeatureConfig,
|
||||
) -> (String, Vec<agen::tool::ToolMeta>) {
|
||||
let client = Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused"));
|
||||
let mut tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(TicketFeature::new(
|
||||
client.clone(),
|
||||
TicketFeatureAccess {
|
||||
thread: true,
|
||||
workflow: true,
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
.with_module(MergeRequestFeature::new(client, mr_config))
|
||||
.install_into_pending(&mut tools, &mut hooks);
|
||||
assert!(!report.has_errors(), "{}", report.error_message());
|
||||
let metas: Vec<_> = tools.into_iter().map(|definition| definition().0).collect();
|
||||
let instructions = report.installed_instruction_contributions();
|
||||
let prompts = PromptCatalog::builtins_only().unwrap();
|
||||
let scope = Scope::empty();
|
||||
let rendered = SystemPromptTemplate::parse(role, PromptCatalogSource::builtins_only())
|
||||
.unwrap()
|
||||
.render(&SystemPromptContext {
|
||||
now: DateTime::parse_from_rfc3339("2026-10-08T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
cwd: Cow::Borrowed("."),
|
||||
language: "en",
|
||||
scope: &scope,
|
||||
tool_names: metas.iter().map(|meta| meta.name.clone()).collect(),
|
||||
feature_instructions: &instructions,
|
||||
agents_md: None,
|
||||
resident_summary: None,
|
||||
prompts: &prompts,
|
||||
})
|
||||
.unwrap();
|
||||
(rendered, metas)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_ticket_instructions_allow_review_not_required_without_mr_gate() {
|
||||
let (prompt, tools) =
|
||||
ticket_workflow_fixture("role.ticket_worker", MergeRequestFeatureConfig::default());
|
||||
assert!(prompt.contains("No Merge Request or approval is a prerequisite"));
|
||||
assert!(prompt.contains("optional Ticket comment"));
|
||||
assert!(prompt.contains("optional Drive document/artifact"));
|
||||
assert!(!prompt.contains("## Merge Request workflow"));
|
||||
assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest"));
|
||||
let complete = tools
|
||||
.iter()
|
||||
.find(|meta| meta.name == "CompleteTicket")
|
||||
.unwrap();
|
||||
assert!(
|
||||
complete
|
||||
.description
|
||||
.contains("Merge Requests and approvals are not prerequisites")
|
||||
);
|
||||
assert!(complete.description.contains("not real judgment"));
|
||||
assert!(
|
||||
complete.input_schema["properties"]
|
||||
.get("approval_event_id")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_code_instructions_preserve_independent_review_before_merge() {
|
||||
let (prompt, tools) = ticket_workflow_fixture(
|
||||
"role.orchestrator",
|
||||
MergeRequestFeatureConfig {
|
||||
show: true,
|
||||
readiness_check: true,
|
||||
complete: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(prompt.contains("Trusted Reviewer authority"));
|
||||
assert!(
|
||||
prompt.contains(
|
||||
"Target integration and `CompleteMergeRequest` are Orchestrator authority"
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("does not bypass the guarded review requirements for MR integration")
|
||||
);
|
||||
assert!(prompt.contains("not a universal Ticket pipeline"));
|
||||
assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest"));
|
||||
let complete = tools
|
||||
.iter()
|
||||
.find(|meta| meta.name == "CompleteMergeRequest")
|
||||
.unwrap();
|
||||
assert!(
|
||||
complete
|
||||
.description
|
||||
.contains("trusted exact-source approval")
|
||||
);
|
||||
assert!(
|
||||
complete.input_schema["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|field| field == "approval_event_id")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_ticket_instructions_allow_return_without_conclusion_and_separate_cleanup() {
|
||||
for role in ["role.ticket_worker", "role.coder", "role.orchestrator"] {
|
||||
let (prompt, tools) =
|
||||
ticket_workflow_fixture(role, MergeRequestFeatureConfig::default());
|
||||
assert!(prompt.contains("return without conclusion"), "{role}");
|
||||
assert!(
|
||||
prompt.contains(
|
||||
"a turn ending or a result report is not itself a completion decision"
|
||||
),
|
||||
"{role}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("stopping a Worker alone does not end unfinished work"),
|
||||
"{role}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("does not stop, remove, or clean up"),
|
||||
"{role}"
|
||||
);
|
||||
let complete = tools
|
||||
.iter()
|
||||
.find(|meta| meta.name == "CompleteTicket")
|
||||
.unwrap();
|
||||
assert!(
|
||||
complete
|
||||
.description
|
||||
.contains("return without conclusion need not call this tool")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_worker_capabilities_follow_the_registered_canonical_control_tools() {
|
||||
|
||||
@@ -124,6 +124,7 @@ impl SegmentLogSink {
|
||||
| LogEntry::ToolResultCorrected { .. }
|
||||
| LogEntry::AnnotatedSystemItem { .. }
|
||||
| LogEntry::Invoke { .. }
|
||||
| LogEntry::LlmUsage { .. }
|
||||
| LogEntry::RunYielded { .. }
|
||||
| LogEntry::RunResumed { .. }
|
||||
| LogEntry::RunCancelled { .. }
|
||||
@@ -357,6 +358,54 @@ mod tests {
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_usage_is_split_once_between_snapshot_and_live_even_with_equal_timestamps() {
|
||||
let sink = SegmentLogSink::new();
|
||||
sink.publish(LogEntry::Invoke {
|
||||
ts: 100,
|
||||
trigger: protocol::InvokeKind::UserSend,
|
||||
});
|
||||
let usage = LogEntry::LlmUsage {
|
||||
ts: 200,
|
||||
history_len: 2,
|
||||
input_total_tokens: 2000,
|
||||
cache_read_tokens: 1000,
|
||||
cache_write_tokens: 0,
|
||||
output_tokens: 100,
|
||||
};
|
||||
sink.publish(usage.clone());
|
||||
let (snapshot, mut rx) = sink.subscribe_with_snapshot();
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.iter()
|
||||
.filter(|e| matches!(e, LogEntry::LlmUsage { .. }))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(rx.try_recv().is_err());
|
||||
sink.publish(usage);
|
||||
let event =
|
||||
crate::ipc::protocol_session::live_log_entry_event(rx.try_recv().unwrap()).unwrap();
|
||||
assert!(matches!(
|
||||
event,
|
||||
protocol::Event::Usage {
|
||||
timestamp_ms: Some(200),
|
||||
input_tokens: Some(2000),
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(rx.try_recv().is_err());
|
||||
let (reconnected, mut next_rx) = sink.subscribe_with_snapshot();
|
||||
assert_eq!(
|
||||
reconnected
|
||||
.iter()
|
||||
.filter(|e| matches!(e, LogEntry::LlmUsage { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(next_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_with_initial_clears_and_broadcasts() {
|
||||
let sink = SegmentLogSink::new();
|
||||
|
||||
@@ -294,6 +294,9 @@ impl SessionCapture {
|
||||
// Observation deliberately excludes system items and
|
||||
// controller errors from model-visible session evidence.
|
||||
SessionSnapshotEntryData::SystemItem { .. }
|
||||
| SessionSnapshotEntryData::Invoke { .. }
|
||||
| SessionSnapshotEntryData::Usage { .. }
|
||||
| SessionSnapshotEntryData::RunStats { .. }
|
||||
| SessionSnapshotEntryData::RunYielded { .. }
|
||||
| SessionSnapshotEntryData::RunResumed { .. }
|
||||
| SessionSnapshotEntryData::RunCancelled
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user