chore: merge develop into hare/develop

# Conflicts:
#	crates/api-macros/src/axum.rs
This commit is contained in:
2026-10-09 02:28:08 +09:00
90 changed files with 20357 additions and 2010 deletions
Generated
+68 -6
View File
@@ -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",
]
@@ -6560,6 +6602,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 +6726,7 @@ name = "yoi-workspace-server"
version = "0.1.0"
dependencies = [
"agen",
"api-macros",
"async-trait",
"axum",
"chrono",
@@ -6709,6 +6770,7 @@ dependencies = [
"workdir",
"worker",
"worker-runtime",
"workspace-drive",
"yoi",
"zeroize",
]
+14 -3
View File
@@ -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
View File
@@ -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();
+24 -14
View File
@@ -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 な失敗としてリトライ対象になるかを判定する。
///
/// 対象:
+9
View File
@@ -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 })])
}
+390 -2
View File
@@ -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()
);
}
}
+32 -4
View File
@@ -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)
+10
View File
@@ -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()
+6 -3
View File
@@ -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;
+19
View File
@@ -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,
+5
View File
@@ -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(())
+331
View File
@@ -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)
);
}
}
+55
View File
@@ -75,6 +75,16 @@ 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 Ticket work with typed progress and completion tools.",
imports: &[BuiltinProfileImport {
specifier: "./default.dcdl",
resolved_path: DEFAULT_PATH,
}],
},
BuiltinProfileResource {
selector: Some("builtin:coder"),
path: "profiles/coder.dcdl",
@@ -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()])
);
}
}
+160 -1
View File
@@ -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", &registration, |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", &registration, |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", &registration, |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(&registered)
.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();
+72
View File
@@ -1279,6 +1279,78 @@ mod tests {
}
}
#[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();
+4
View File
@@ -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())
);
}
+902
View File
@@ -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());
}
}
}
}
+196 -5
View File
@@ -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,
}
@@ -11121,6 +11248,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 {
+21 -5
View File
@@ -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");
}
}
+46 -11
View File
@@ -2360,7 +2360,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(());
@@ -5678,6 +5678,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 +5721,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 +5770,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 +5798,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 +5812,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 +5833,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 +5844,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 +5873,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 +5941,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 +5951,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 +6104,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);
+1
View File
@@ -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 }
+1 -1
View File
@@ -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,
+8 -5
View File
@@ -23,8 +23,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";
@@ -146,8 +146,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(),
@@ -286,7 +288,7 @@ impl Provider {
#[async_trait]
impl WipSubtreeProvider for Arc<Provider> {
async fn projection(&self, path: &str) -> Result<Option<WipProjection>, ProtocolError> {
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
.await
.map_err(|_| {
@@ -304,6 +306,7 @@ impl WipSubtreeProvider for Arc<Provider> {
"checkout observation deadline",
)
})?
.and_then(|projection| projection.map(WipPublication::self_scoped).transpose())
}
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
+3 -3
View File
@@ -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
}
+3 -3
View File
@@ -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(
+38 -5
View File
@@ -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();
@@ -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."
@@ -1949,6 +1949,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());
@@ -559,14 +559,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 +587,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 +685,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)),
},
@@ -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)),
},
+272 -220
View File
@@ -20,9 +20,7 @@ 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";
#[derive(Debug, Default)]
pub struct OrchestrationFeature;
@@ -37,15 +35,15 @@ 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",
"SpawnTicketWorker 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.",
"Spawn and atomically assign a configurable Worker for a Ticket. Supply a registered profile selector, an initial request, and optionally a Flow selector. Workdir attachments may be empty; Backend authority validates claims and derives each attachment's access from the Ticket targets. The guarded operation records acceptance only after spawn, initial input, assignment, and resource finalization. Selecting a profile or Flow does not grant review authority.",
))
}
@@ -68,7 +66,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 +78,35 @@ 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>,
/// Registered profile selector. Backend resolves the profile and validates claims.
profile: 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,28 @@ 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 = non_empty(input.profile, "profile")?;
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 +146,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 +154,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 +193,14 @@ 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.",
"Spawn and atomically assign a configurable Ticket Worker with a registered profile, initial request, optional typed Flow, and zero or more alias-keyed Workdir attachments. Backend validates claims and resources; profile and Flow selection do not grant review authority.",
)
.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 +247,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 +267,7 @@ mod tests {
#[derive(Default)]
struct RecordingService {
requests: Mutex<Vec<WorkerLifecycleSpawnRequest>>,
response: Option<WorkspaceResponse>,
}
#[async_trait]
@@ -297,193 +277,236 @@ 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_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 +516,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", "profile", "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 +557,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", "profile", "initial_request"] {
assert!(
required.contains(&serde_json::json!(field)),
"missing required field {field}"
);
}
for field in ["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}
}))
+4 -4
View File
@@ -1814,11 +1814,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 +1837,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 +1923,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)),
},
@@ -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())),
+173 -16
View File
@@ -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!(
+656 -809
View File
File diff suppressed because it is too large Load Diff
+540
View File
@@ -0,0 +1,540 @@
//! Regression coverage for MR P1_CURRENT_ANCESTOR_SCOPE, through real adapters.
use super::*;
struct State {
visible: bool,
identity: Option<String>,
name: String,
generation: usize,
target_validator: Option<Vec<u8>>,
lookups: Vec<String>,
}
struct AncestorProvider {
scope_path: String,
target_path: String,
state: Arc<Mutex<State>>,
calls: Arc<AtomicUsize>,
blocked: bool,
started: Arc<tokio::sync::Notify>,
release: Arc<tokio::sync::Notify>,
}
impl AncestorProvider {
fn scope_projection(&self, state: &State) -> WipProjection {
let mut p = projection(
&self.scope_path,
&self.scope_path,
&state.name,
self.calls.clone(),
);
p.object.r#ref = state.identity.clone();
self.version(p, state.generation)
}
fn version(&self, mut p: WipProjection, generation: usize) -> WipProjection {
p.descriptor.documentation = Some(Documentation {
summary: format!("Published generation {generation}"),
details: None,
});
if self.blocked {
p.interface_validator = Some(vec![generation as u8]);
if generation != 0 {
p.descriptor.operations[0].returns.r#type = TypeExpr::Integer;
}
p.handler = Arc::new(FrozenResultHandler {
generation,
started: self.started.clone(),
release: self.release.clone(),
calls: self.calls.clone(),
});
}
p
}
}
#[async_trait]
impl WipSubtreeProvider for AncestorProvider {
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
// One lock captures target, scope lifetime, descriptor/validator and
// handler. No separate scope lookup can splice new identity onto old data.
let mut state = self.state.lock().unwrap();
state.lookups.push(path.into());
if path == self.scope_path {
return if state.visible {
WipPublication::self_scoped(self.scope_projection(&state)).map(Some)
} else {
Ok(None)
};
}
if path == "/github" && self.scope_path != path {
return if state.visible {
WipPublication::self_scoped(projection(path, path, "root", self.calls.clone()))
.map(Some)
} else {
Ok(None)
};
}
if path != self.target_path {
return Ok(None);
}
let mut target = projection(path, &self.scope_path, &state.name, self.calls.clone());
target.object.validator = state.target_validator.clone();
let target = self.version(target, state.generation);
// Retain a target artifact after deleting the scope to exercise Host
// fail-closed behavior, not merely a provider that already hides orphans.
let scope = state.visible.then(|| self.scope_projection(&state).object);
Ok(Some(WipPublication {
projection: target,
scope,
}))
}
async fn children(&self, path: &str) -> Result<Vec<String>, ProtocolError> {
let state = self.state.lock().unwrap();
if !state.visible {
return Ok(vec![]);
}
if path == self.scope_path {
return Ok(vec![self.target_path.clone()]);
}
if path == "/github" && path != self.scope_path {
return Ok(vec![self.scope_path.clone()]);
}
Ok(vec![])
}
}
fn fixture(scope: &str, with_ref: bool, blocked: bool) -> (WipRuntime, Arc<AncestorProvider>) {
let provider = Arc::new(AncestorProvider {
scope_path: scope.into(),
target_path: if scope == "/" {
"/item".into()
} else {
format!("{scope}/item")
},
state: Arc::new(Mutex::new(State {
visible: true,
identity: with_ref.then(|| "live-scope".into()),
name: "read".into(),
generation: 0,
target_validator: None,
lookups: vec![],
})),
calls: Arc::new(AtomicUsize::new(0)),
blocked,
started: Arc::new(tokio::sync::Notify::new()),
release: Arc::new(tokio::sync::Notify::new()),
});
let mut mounts = WipMountRegistry::new();
mounts.allocate_namespace("scope", "github").unwrap();
let mount_root = if scope == "/" { "/" } else { "/github" };
let mut placeholder = projection(mount_root, mount_root, "read", provider.calls.clone());
placeholder.object.r#ref = Some("registration-placeholder".into());
mounts.mount(placeholder).unwrap();
mounts
.mount_subtree(WipSubtreeMount {
root: mount_root.into(),
provider: provider.clone(),
})
.unwrap();
(
WipRuntime::from_mounts(mounts, "ancestor-test".into()).unwrap(),
provider,
)
}
fn reference(scope: &str, name: &str) -> InterfaceReference {
InterfaceReference {
scope: scope.into(),
name: name.into(),
}
}
#[tokio::test]
async fn live_root_and_intermediate_ancestors_use_one_current_publication_not_placeholder() {
for scope in ["/", "/github", "/github/team"] {
for with_ref in [true, false] {
let (runtime, provider) = fixture(scope, with_ref, false);
let selected = reference(scope, "read");
let fetched = runtime.host.fetch_interface_live(&selected).await.unwrap();
assert_eq!(fetched.scope_ref, with_ref.then(|| "live-scope".into()));
runtime
.inspect(provider.target_path.clone(), false)
.await
.unwrap();
provider.state.lock().unwrap().lookups.clear();
runtime
.invoke(
provider.target_path.clone(),
selected,
"read".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
assert_eq!(
provider.state.lock().unwrap().lookups,
[provider.target_path.clone()]
);
let state = runtime.state.lock().unwrap();
let call = state
.client
.call_history(&state.session)
.unwrap()
.back()
.unwrap();
assert_eq!(
call.context.request().interface.scope_ref,
fetched.scope_ref
);
drop(state);
// A specified ref must still match even when the current scope has none.
assert_eq!(
rejected_code(
&runtime,
metadata_request(
&provider.target_path,
json!({"scope":scope,"name":"read"}),
None,
Some("wrong")
)
)
.await,
"interface_mismatch"
);
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
}
}
}
#[tokio::test]
async fn deletion_without_prior_scope_inspect_rejects_cached_invoke_and_orphan_observation() {
for scope in ["/github", "/github/team"] {
for with_ref in [true, false] {
let (runtime, provider) = fixture(scope, with_ref, false);
let selected = reference(scope, "read");
runtime
.inspect(provider.target_path.clone(), false)
.await
.unwrap();
provider.state.lock().unwrap().visible = false;
let failed = runtime
.invoke(
provider.target_path.clone(),
selected.clone(),
"read".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.unwrap_err();
assert!(failed.to_string().contains("InterfaceMismatch"), "{failed}");
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
assert!(
runtime
.inspect(provider.target_path.clone(), true)
.await
.is_err()
);
assert!(
runtime
.tree(provider.target_path.clone(), 0, true)
.await
.is_err()
);
assert_eq!(
runtime
.host
.fetch_interface_live(&selected)
.await
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
let root = runtime.tree("/".into(), 3, true).await.unwrap();
assert!(!root.content.unwrap().contains("/github"));
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
}
}
}
#[tokio::test]
async fn republishing_scope_never_restores_old_registration_even_without_refs() {
for with_ref in [true, false] {
let (runtime, provider) = fixture("/github", with_ref, false);
let old = reference("/github", "read");
runtime
.inspect(provider.target_path.clone(), false)
.await
.unwrap();
provider.state.lock().unwrap().visible = false;
assert_eq!(
runtime
.host
.fetch_interface_live(&old)
.await
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
{
let mut state = provider.state.lock().unwrap();
state.visible = true;
state.identity = with_ref.then(|| "replacement".into());
state.name = "new".into();
state.generation = 1;
}
// The old root registration and cached old descriptor both still exist.
assert_eq!(
runtime
.host
.fetch_interface_live(&old)
.await
.unwrap_err()
.code,
ProtocolErrorCode::InterfaceNotFound
);
assert!(
runtime
.invoke(
provider.target_path.clone(),
old,
"read".into(),
json!({}),
ToolExecutionContext::direct()
)
.await
.is_err()
);
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
runtime
.inspect(provider.target_path.clone(), true)
.await
.unwrap();
let new = reference("/github", "new");
assert_eq!(
runtime
.host
.fetch_interface_live(&new)
.await
.unwrap()
.scope_ref,
with_ref.then(|| "replacement".into())
);
runtime
.invoke(
provider.target_path.clone(),
new,
"read".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
}
}
#[tokio::test]
async fn missing_ancestor_preserves_wire_shape_target_and_object_validator_precedence() {
let (runtime, provider) = fixture("/github", false, false);
{
let mut state = provider.state.lock().unwrap();
state.visible = false;
state.target_validator = Some(vec![1]);
}
assert_eq!(
rejected_code(
&runtime,
metadata_request("/missing", json!("old"), None, None)
)
.await,
"invalid_request"
);
assert_eq!(
rejected_code(
&runtime,
metadata_request(
"/missing",
json!({"scope":"/github","name":"read"}),
Some("AA=="),
None
)
)
.await,
"not_found"
);
assert_eq!(
rejected_code(
&runtime,
metadata_request(
&provider.target_path,
json!({"scope":"/github","name":"read"}),
Some("AA=="),
None
)
)
.await,
"validator_mismatch"
);
assert_eq!(
rejected_code(
&runtime,
metadata_request(
&provider.target_path,
json!({"scope":"/github","name":"read"}),
Some("AQ=="),
None
)
)
.await,
"interface_mismatch"
);
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn ancestor_replacement_during_in_flight_call_keeps_the_original_descriptor_and_handler() {
let (runtime, provider) = fixture("/github/team", true, true);
let selected = reference("/github/team", "read");
runtime
.inspect(provider.target_path.clone(), false)
.await
.unwrap();
let pending = runtime.invoke(
provider.target_path.clone(),
selected.clone(),
"read".into(),
json!({}),
ToolExecutionContext::direct(),
);
let update = async {
provider.started.notified().await;
{
let mut state = provider.state.lock().unwrap();
state.identity = Some("replacement".into());
state.generation = 1;
}
let updated = runtime
.inspect(provider.target_path.clone(), true)
.await
.unwrap();
assert!(updated.content.unwrap().contains("integer"));
provider.release.notify_one();
};
let (old, ()) = tokio::time::timeout(std::time::Duration::from_secs(5), async {
tokio::join!(pending, update)
})
.await
.unwrap();
assert_eq!(old.unwrap().content.as_deref(), Some("\"old result\""));
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
let state = runtime.state.lock().unwrap();
let history = state.client.call_history(&state.session).unwrap();
let old = history.back().unwrap();
assert_eq!(
old.context.request().interface.scope_ref.as_deref(),
Some("live-scope")
);
assert_eq!(
old.context.descriptor().operations[0].returns.r#type,
TypeExpr::String
);
drop(state);
let new = runtime
.invoke(
provider.target_path.clone(),
selected,
"read".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(new.content.as_deref(), Some("2"));
assert_eq!(provider.calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn same_name_scope_replacement_or_loss_of_ref_rejects_old_metadata_without_replay() {
for identity in [Some("replacement"), None] {
let (runtime, provider) = fixture("/github/team", true, false);
let selected = reference("/github/team", "read");
runtime
.inspect(provider.target_path.clone(), false)
.await
.unwrap();
{
let mut state = provider.state.lock().unwrap();
state.identity = identity.map(Into::into);
if identity.is_some() {
state.generation = 1;
}
}
assert_eq!(
rejected_code(
&runtime,
metadata_request(
&provider.target_path,
json!({"scope":"/github/team","name":"read"}),
None,
Some("live-scope")
)
)
.await,
"interface_mismatch"
);
let failed = runtime
.invoke(
provider.target_path.clone(),
selected.clone(),
"read".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.unwrap_err();
assert!(failed.to_string().contains("InterfaceMismatch"), "{failed}");
assert_eq!(provider.calls.load(Ordering::SeqCst), 0);
let fetched = runtime.host.fetch_interface_live(&selected).await.unwrap();
assert_eq!(fetched.scope_ref.as_deref(), identity);
assert_eq!(
fetched.descriptor.documentation.unwrap().summary,
if identity.is_some() {
"Published generation 1"
} else {
"Published generation 0"
}
);
runtime
.inspect(provider.target_path.clone(), true)
.await
.unwrap();
runtime
.invoke(
provider.target_path.clone(),
selected,
"read".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(provider.calls.load(Ordering::SeqCst), 1);
let state = runtime.state.lock().unwrap();
let call = state
.client
.call_history(&state.session)
.unwrap()
.back()
.unwrap();
// Omitted scope_ref is never filled from the placeholder or other observations.
assert_eq!(
call.context.request().interface.scope_ref.as_deref(),
identity
);
}
}
File diff suppressed because it is too large Load Diff
+565
View File
@@ -0,0 +1,565 @@
//! Scope publication and precondition tests through the actual Yoi HTTP adapter.
use super::*;
use std::sync::atomic::{AtomicBool, AtomicUsize};
#[path = "ancestor_tests.rs"]
mod ancestor_tests;
struct ReadHandler(Arc<AtomicUsize>);
#[async_trait]
impl WipOperationHandler for ReadHandler {
async fn call(
&self,
_: &str,
_: &BTreeMap<String, Value>,
_: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(WipOperationOutput::native(Value::String("value".into())))
}
}
fn projection(path: &str, scope: &str, name: &str, calls: Arc<AtomicUsize>) -> WipProjection {
let interface = InterfaceReference {
scope: scope.into(),
name: name.into(),
};
WipProjection {
route: path.into(),
capability: "scope:object".into(),
kind: WipProjectionKind::Native,
object: Object {
name: path.rsplit('/').next().unwrap().into(),
description: None,
interfaces: vec![interface.clone()],
r#ref: Some(format!("object:{path}")),
validator: None,
},
interface,
descriptor: InterfaceDescriptor {
format: INTERFACE_FORMAT_V1.into(),
documentation: None,
types: vec![],
operations: vec![OperationDeclaration {
name: "read".into(),
documentation: None,
parameters: vec![],
returns: ReturnDeclaration {
documentation: None,
r#type: TypeExpr::String,
},
}],
},
interface_validator: None,
handler: Arc::new(ReadHandler(calls)),
}
}
fn metadata_request(
path: &str,
reference: Json,
object_validator: Option<&str>,
scope_ref: Option<&str>,
) -> Request<Vec<u8>> {
let mut target = json!({"path": path});
if let Some(v) = object_validator {
target["validator"] = json!(v);
}
let mut interface = json!({"reference": reference});
if let Some(v) = scope_ref {
interface["scope_ref"] = json!(v);
}
Request::builder()
.body(
serde_json::to_vec(
&json!({"target":target,"interface":interface,"operation":"read","arguments":{}}),
)
.unwrap(),
)
.unwrap()
}
async fn rejected_code(runtime: &WipRuntime, request: Request<Vec<u8>>) -> String {
let (response, passthrough, outcome) = runtime
.dispatch_call(&request, ToolExecutionContext::direct())
.await
.unwrap();
assert!(passthrough.is_none());
assert_eq!(outcome, WipAuditOutcome::Rejected);
let json: Json = serde_json::from_slice(response.body()).unwrap();
json["error"]["code"].as_str().unwrap().into()
}
#[tokio::test]
async fn adapter_preconditions_preserve_shape_target_validator_and_scope_precedence() {
let calls = Arc::new(AtomicUsize::new(0));
let mut registry = WipMountRegistry::new();
registry.allocate_namespace("scope", "github").unwrap();
let mut p = projection("/github/item", "/github/item", "read", calls.clone());
p.object.validator = Some(vec![1]);
registry.mount(p).unwrap();
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
assert_eq!(
rejected_code(
&runtime,
metadata_request("/missing", json!("legacy"), None, None)
)
.await,
"invalid_request"
);
assert_eq!(
rejected_code(
&runtime,
metadata_request(
"/missing",
json!({"scope":"/sibling","name":"read"}),
Some("AA=="),
None
)
)
.await,
"not_found"
);
assert_eq!(
rejected_code(
&runtime,
metadata_request(
"/github/item",
json!({"scope":"/sibling","name":"read"}),
Some("AA=="),
None
)
)
.await,
"validator_mismatch"
);
for scope in ["/sibling", "/github/item/child", "/github-extra"] {
assert_eq!(
rejected_code(
&runtime,
metadata_request(
"/github/item",
json!({"scope":scope,"name":"read"}),
Some("AQ=="),
None
)
)
.await,
"interface_mismatch"
);
}
assert_eq!(
rejected_code(
&runtime,
metadata_request(
"/github/item",
json!({"scope":"/github/item","name":"read"}),
Some("AQ=="),
Some("old")
)
)
.await,
"interface_mismatch"
);
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn actual_provider_root_and_self_scope_inspect_invoke_preserve_optional_refs() {
for path in ["/", "/github"] {
for with_ref in [true, false] {
let calls = Arc::new(AtomicUsize::new(0));
let mut registry = WipMountRegistry::new();
registry.allocate_namespace("scope", "github").unwrap();
let mut p = projection(path, path, "read", calls.clone());
if !with_ref {
p.object.r#ref = None;
}
let reference = p.interface.clone();
registry.mount(p).unwrap();
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
let fetched = runtime.host.fetch_interface_live(&reference).await.unwrap();
assert_eq!(
fetched.scope_ref,
with_ref.then(|| format!("object:{path}"))
);
runtime.inspect(path.into(), false).await.unwrap();
runtime
.invoke(
path.into(),
reference.clone(),
"read".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
let state = runtime.state.lock().unwrap();
let history = state.client.call_history(&state.session).unwrap();
assert_eq!(
history
.back()
.unwrap()
.context
.request()
.interface
.scope_ref,
fetched.scope_ref
);
drop(state);
if !with_ref {
assert_eq!(
rejected_code(
&runtime,
metadata_request(
path,
json!({"scope":path,"name":"read"}),
None,
Some("invented")
)
)
.await,
"interface_mismatch"
);
}
}
}
}
#[tokio::test]
async fn multiple_interfaces_same_operation_are_inspected_and_dispatched_without_flattening() {
let a = Arc::new(AtomicUsize::new(0));
let b = Arc::new(AtomicUsize::new(0));
let mut registry = WipMountRegistry::new();
registry.allocate_namespace("scope", "github").unwrap();
let owner = projection("/github", "/", "same", a.clone());
let other = projection("/github", "/github", "same", b.clone());
let refs = [owner.interface.clone(), other.interface.clone()];
registry.mount(owner).unwrap();
registry.mount_interface(other).unwrap();
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
let inspected = runtime.inspect("/github".into(), false).await.unwrap();
let inspected: Json = serde_json::from_str(inspected.content.as_ref().unwrap()).unwrap();
assert_eq!(inspected["interfaces"].as_array().unwrap().len(), 2);
for reference in refs {
runtime
.invoke(
"/github".into(),
reference,
"read".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
}
assert_eq!(a.load(Ordering::SeqCst), 1);
assert_eq!(b.load(Ordering::SeqCst), 1);
}
struct Publication {
visible: Arc<AtomicBool>,
identity: Arc<Mutex<Option<String>>>,
validator: Arc<Mutex<Option<Vec<u8>>>>,
calls: Arc<AtomicUsize>,
}
impl WipDynamicItemResolver for Publication {
fn contextual_interface(&self) -> bool {
true
}
fn resolve(&self, item: &str) -> Option<WipDynamicItem> {
if item != "item" || !self.visible.load(Ordering::SeqCst) {
return None;
}
let mut p = projection("/github/item", "/", "read", self.calls.clone());
p.object.r#ref = self.identity.lock().unwrap().clone();
p.object.validator = self.validator.lock().unwrap().clone();
Some(WipDynamicItem {
object: p.object,
handler: p.handler,
})
}
}
#[tokio::test]
async fn current_self_scope_fetch_does_not_attach_a_new_ref_to_an_old_target_snapshot() {
let calls = Arc::new(AtomicUsize::new(0));
let visible = Arc::new(AtomicBool::new(true));
let identity = Arc::new(Mutex::new(Some("first".into())));
let validator = Arc::new(Mutex::new(None));
let mut registry = WipMountRegistry::new();
registry.allocate_namespace("scope", "github").unwrap();
registry
.mount(projection("/github", "/", "collection", calls.clone()))
.unwrap();
let p = projection("/github/item", "/", "read", calls.clone());
registry
.mount_dynamic(WipDynamicMount {
collection_route: "/github".into(),
capability: "scope:item".into(),
interface: p.interface,
descriptor: p.descriptor,
interface_validator: None,
resolver: Arc::new(Publication {
visible: visible.clone(),
identity: identity.clone(),
validator: validator.clone(),
calls: calls.clone(),
}),
})
.unwrap();
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
let reference = contextual_reference("read", "/github/item");
runtime.inspect("/github/item".into(), false).await.unwrap();
assert_eq!(
runtime
.host
.fetch_interface_live(&reference)
.await
.unwrap()
.scope_ref
.as_deref(),
Some("first")
);
// Ordinary content-state updates do not change the scope Object identity.
*validator.lock().unwrap() = Some(vec![2]);
assert_eq!(
runtime
.host
.fetch_interface_live(&reference)
.await
.unwrap()
.scope_ref
.as_deref(),
Some("first")
);
*validator.lock().unwrap() = None;
*identity.lock().unwrap() = Some("second".into());
let error = runtime
.invoke(
"/github/item".into(),
reference.clone(),
"read".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.unwrap_err();
assert!(error.to_string().contains("InterfaceMismatch"));
assert_eq!(calls.load(Ordering::SeqCst), 0);
// Both target and Interface are invalidated; retrievals are not reexecution.
let state = runtime.state.lock().unwrap();
assert_eq!(
state
.client
.object(&state.session, "/github/item")
.unwrap()
.state,
ObservationState::Stale
);
assert_eq!(
state
.client
.interface(&state.session, &reference)
.unwrap()
.state,
ObservationState::Stale
);
drop(state);
runtime
.invoke(
"/github/item".into(),
reference.clone(),
"read".into(),
json!({}),
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
visible.store(false, Ordering::SeqCst);
assert!(runtime.host.fetch_interface_live(&reference).await.is_err());
assert!(runtime.inspect("/github/item".into(), true).await.is_err());
let state = runtime.state.lock().unwrap();
assert!(state.client.interface(&state.session, &reference).is_none());
}
#[tokio::test]
async fn supplied_validators_require_current_values_and_required_object_validator_is_late() {
let calls = Arc::new(AtomicUsize::new(0));
let mut registry = WipMountRegistry::new();
registry.allocate_namespace("scope", "github").unwrap();
registry
.mount(projection("/github", "/github", "read", calls.clone()))
.unwrap();
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
let reference = json!({"scope":"/github","name":"read"});
assert_eq!(
rejected_code(
&runtime,
metadata_request("/github", reference.clone(), Some("AQ=="), None)
)
.await,
"validator_mismatch"
);
let mut request = metadata_request("/github", reference, None, None);
let mut body: Json = serde_json::from_slice(request.body()).unwrap();
body["interface"]["validator"] = json!("AQ==");
*request.body_mut() = serde_json::to_vec(&body).unwrap();
assert_eq!(
rejected_code(&runtime, request).await,
"interface_validator_mismatch"
);
let mut registry = WipMountRegistry::new();
registry.allocate_namespace("scope", "github").unwrap();
let mut p = projection("/github", "/github", "read", calls.clone());
p.object.validator = Some(vec![1]);
registry.mount(p).unwrap();
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
assert_eq!(
rejected_code(
&runtime,
metadata_request(
"/github",
json!({"scope":"/wrong","name":"read"}),
None,
None
)
)
.await,
"interface_mismatch"
);
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
struct ChangingProvider {
generation: Arc<AtomicUsize>,
started: Arc<tokio::sync::Notify>,
release: Arc<tokio::sync::Notify>,
calls: Arc<AtomicUsize>,
}
struct FrozenResultHandler {
generation: usize,
started: Arc<tokio::sync::Notify>,
release: Arc<tokio::sync::Notify>,
calls: Arc<AtomicUsize>,
}
#[async_trait]
impl WipOperationHandler for FrozenResultHandler {
async fn call(
&self,
_: &str,
_: &BTreeMap<String, Value>,
_: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> {
self.calls.fetch_add(1, Ordering::SeqCst);
if self.generation == 0 {
self.started.notify_one();
self.release.notified().await;
Ok(WipOperationOutput::native(Value::String(
"old result".into(),
)))
} else {
Ok(WipOperationOutput::native(Value::Integer(2)))
}
}
}
#[async_trait]
impl WipSubtreeProvider for ChangingProvider {
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
if path != "/github" {
return Ok(None);
}
let generation = self.generation.load(Ordering::SeqCst);
let mut p = projection(path, path, "read", self.calls.clone());
p.interface_validator = Some(vec![generation as u8]);
if generation != 0 {
p.descriptor.operations[0].returns.r#type = TypeExpr::Integer;
}
p.handler = Arc::new(FrozenResultHandler {
generation,
started: self.started.clone(),
release: self.release.clone(),
calls: self.calls.clone(),
});
WipPublication::self_scoped(p).map(Some)
}
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
Ok(vec![])
}
}
#[tokio::test]
async fn in_flight_result_uses_frozen_host_and_client_descriptors_after_refresh() {
let generation = Arc::new(AtomicUsize::new(0));
let started = Arc::new(tokio::sync::Notify::new());
let release = Arc::new(tokio::sync::Notify::new());
let calls = Arc::new(AtomicUsize::new(0));
let mut registry = WipMountRegistry::new();
registry.allocate_namespace("scope", "github").unwrap();
registry
.mount(projection("/github", "/github", "read", calls.clone()))
.unwrap();
registry
.mount_subtree(WipSubtreeMount {
root: "/github".into(),
provider: Arc::new(ChangingProvider {
generation: generation.clone(),
started: started.clone(),
release: release.clone(),
calls: calls.clone(),
}),
})
.unwrap();
let runtime = WipRuntime::from_mounts(registry, "scope-test".into()).unwrap();
runtime.inspect("/github".into(), false).await.unwrap();
let pending = runtime.invoke(
"/github".into(),
contextual_reference("read", "/github"),
"read".into(),
json!({}),
ToolExecutionContext::direct(),
);
let update = async {
started.notified().await;
generation.store(1, Ordering::SeqCst);
let output = runtime.inspect("/github".into(), true).await.unwrap();
assert!(output.content.unwrap().contains("integer"));
release.notify_one();
};
let (old_result, ()) = tokio::time::timeout(std::time::Duration::from_secs(5), async {
tokio::join!(pending, update)
})
.await
.unwrap();
assert_eq!(
old_result.unwrap().content.as_deref(),
Some("\"old result\"")
);
assert_eq!(calls.load(Ordering::SeqCst), 1);
let state = runtime.state.lock().unwrap();
let record = state
.client
.call_history(&state.session)
.unwrap()
.back()
.unwrap();
assert_eq!(
record.context.descriptor().operations[0].returns.r#type,
TypeExpr::String
);
assert_eq!(
state
.client
.interface(&state.session, &contextual_reference("read", "/github"))
.unwrap()
.descriptor
.as_ref()
.unwrap()
.operations[0]
.returns
.r#type,
TypeExpr::Integer
);
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "workspace-drive"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
feature-storage.workspace = true
rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
uuid = { workspace = true, features = ["v7"] }
# Staging cleanup recognizes this version's local filename protocol. Reinspect
# local publication and durability before upgrading, even within a patch series.
object_store = { version = "=0.13.2", default-features = false, features = ["fs"] }
bytes.workspace = true
futures.workspace = true
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
[dev-dependencies]
tempfile.workspace = true
+936
View File
@@ -0,0 +1,936 @@
//! Synchronous, immutable local blobs for the SQLite drive authority.
//!
//! Call these methods on blocking threads, while holding the authority's SQLite
//! IMMEDIATE transaction. That transaction, not this adapter, serializes reads,
//! uploads and GC across processes. The root and its ancestors must be owned by
//! trusted workspace code: path checks reject existing symlinks, but the
//! path-based LocalFileSystem API cannot prevent hostile concurrent replacement.
use std::collections::BTreeSet;
use std::fs::{self, File};
use std::io;
use std::ops::Range;
use std::path::{Component, Path, PathBuf};
use bytes::Bytes;
use futures::{TryStreamExt, executor::block_on};
use object_store::local::LocalFileSystem;
use object_store::path::Path as ObjectPath;
use object_store::{ObjectStore, ObjectStoreExt, PutMode, PutOptions};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)]
pub enum BlobError {
#[error("blob key must be a canonical lowercase hyphenated UUID: {0:?}")]
InvalidKey(String),
#[error("blob root contains a parent traversal or is empty: {0}")]
InvalidRoot(PathBuf),
#[error("blob path is a symlink or has an unexpected file type: {0}")]
UnsafePath(PathBuf),
#[error("blob range {start}..{end} is invalid for size {size}")]
InvalidRange { start: usize, end: usize, size: u64 },
#[error("blob filesystem operation failed for {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
#[error(transparent)]
Store(#[from] object_store::Error),
}
#[derive(Debug)]
pub struct BlobStore {
root: PathBuf,
store: LocalFileSystem,
// Per-instance, test-only failures: no process-global state or production
// configuration can turn successful I/O into a simulated failure.
#[cfg(test)]
fault: Option<TestFault>,
}
#[cfg(test)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum TestFault {
PartialStagingWrite,
UploadNoSpace,
UploadPermissionDenied,
FileSync,
DirectorySync,
}
impl BlobStore {
/// Configure an instance-local simulated failure for crate authority tests.
/// This constructor is absent from production builds.
#[cfg(test)]
pub(super) fn with_fault(mut self, fault: TestFault) -> Self {
self.fault = Some(fault);
self
}
/// Create and durably link a flat blob directory, rejecting symlinks in
/// every existing component (including ancestors of an absolute root).
pub fn open(root: &Path) -> Result<Self, BlobError> {
if root.as_os_str().is_empty()
|| root.components().any(|c| matches!(c, Component::ParentDir))
{
return Err(BlobError::InvalidRoot(root.to_owned()));
}
let root = if root.is_absolute() {
root.to_owned()
} else {
io_at(root, std::env::current_dir())?.join(root)
};
check_directories(&root, true)?;
let store = LocalFileSystem::new_with_prefix(&root)?;
Ok(Self {
root,
store,
#[cfg(test)]
fault: None,
})
}
/// Atomically create a new blob; an existing key is never overwritten.
/// Success means the inode and its directory entry have been synced.
/// A sync error can leave an unreferenced blob for authority GC to collect.
pub fn put(&self, key: &str, data: &[u8]) -> Result<(), BlobError> {
let location = key_path(key)?;
self.check_root()?;
self.check_blob(key)?;
#[cfg(test)]
self.fail_at(
TestFault::UploadPermissionDenied,
&self.root.join(format!("{key}#1")),
)?;
#[cfg(test)]
if let Some(fault @ (TestFault::PartialStagingWrite | TestFault::UploadNoSpace)) =
self.fault
{
// Simulate the ObjectStore boundary failing after a real partial
// staging write, before publishing a final pathname. Leave the
// artifact as a crashed upload or failed best-effort cleanup would.
use std::io::Write;
let path = self.root.join(format!("{key}#1"));
let mut file = io_at(
&path,
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path),
)?;
io_at(&path, file.write_all(&data[..data.len() / 2]))?;
self.fail_at(fault, &path)?;
}
block_on(self.store.put_opts(
&location,
Bytes::copy_from_slice(data).into(),
PutOptions {
mode: PutMode::Create,
..Default::default()
},
))?;
// object_store 0.13.2 has no with_fsync option. Its put_opts writes a
// create_new staging file, publishes with hard_link for Create, and
// best-effort unlinks staging, without syncing either inode or parent.
// Sync the published inode BEFORE its directory and BEFORE returning
// to the SQLite transaction. No cooperating reader sees it before then.
let path = self.root.join(key);
let file = io_at(&path, File::open(&path))?;
#[cfg(test)]
self.fail_at(TestFault::FileSync, &path)?;
io_at(&path, file.sync_all())?;
self.sync_root()
}
/// Return the actual blob length, not the length of a requested range.
/// The authority must compare this with its expected total length before
/// read/search, even for an empty range or zero-length metadata. Hold the
/// same SQLite IMMEDIATE transaction through size validation and reading.
pub fn size(&self, key: &str) -> Result<u64, BlobError> {
let location = key_path(key)?;
self.check_root()?;
self.check_blob(key)?;
Ok(block_on(self.store.head(&location))?.size)
}
/// Read an exact half-open range. Empty ranges at EOF are valid; reversed
/// ranges and ranges extending past EOF are rejected, not truncated.
pub fn read(&self, key: &str, range: Range<usize>) -> Result<Vec<u8>, BlobError> {
let location = key_path(key)?;
self.check_root()?;
self.check_blob(key)?;
let size = block_on(self.store.head(&location))?.size;
if range.start > range.end || range.end as u64 > size {
return Err(BlobError::InvalidRange {
start: range.start,
end: range.end,
size,
});
}
if range.is_empty() {
return Ok(Vec::new());
}
Ok(block_on(
self.store
.get_range(&location, range.start as u64..range.end as u64),
)?
.to_vec())
}
/// Durably remove a blob. Missing blobs are also synced before success,
/// allowing GC to safely retry after an earlier directory-sync failure.
pub fn delete(&self, key: &str) -> Result<(), BlobError> {
let location = key_path(key)?;
self.check_root()?;
self.check_blob(key)?;
match block_on(self.store.delete(&location)) {
Ok(()) | Err(object_store::Error::NotFound { .. }) => self.sync_root(),
Err(error) => Err(error.into()),
}
}
/// Reclaim at most `limit` crashed-upload staging files. The caller MUST
/// hold the SQLite IMMEDIATE lock: otherwise an in-flight put's staging
/// file could be removed. Foreign names and final blobs are never deleted.
/// A scan can visit the entire flat directory; memory usage is constant.
/// Success includes a directory sync even when there was nothing to remove,
/// so a retry after a previous sync failure still establishes durability.
pub fn cleanup_staging(&self, limit: usize) -> Result<usize, BlobError> {
self.check_root()?;
let mut removed = 0;
if limit > 0 {
for entry in io_at(&self.root, fs::read_dir(&self.root))? {
let entry = io_at(&self.root, entry)?;
let path = entry.path();
let metadata = io_at(&path, fs::symlink_metadata(&path))?;
if metadata.is_symlink() {
return Err(BlobError::UnsafePath(path));
}
let name = entry.file_name();
if !name.to_str().is_some_and(is_staging_name) {
continue;
}
if !metadata.is_file() {
return Err(BlobError::UnsafePath(path));
}
// ObjectStore refuses paths with '#digits'; reclaim its
// recognized staging artifacts directly, without following links.
io_at(&path, fs::remove_file(&path))?;
removed += 1;
if removed == limit {
break;
}
}
}
self.sync_root()?;
Ok(removed)
}
/// List canonical blob keys in ascending lexical order, strictly after the
/// cursor. Staging files and foreign regular files are never exposed.
/// The full local directory must still be traversed: ObjectStore does not
/// promise ordering. Retained candidates use O(limit) memory (plus the
/// LocalFileSystem stream's fixed-size, 1024-entry Tokio buffer).
pub fn list(&self, after: Option<&str>, limit: usize) -> Result<Vec<String>, BlobError> {
if let Some(after) = after {
key_path(after)?;
}
self.check_root()?;
if limit == 0 {
return Ok(Vec::new());
}
// LocalFileSystem recursively lists and follows even out-of-root
// symlinks. Validate this flat directory before giving it to the store.
for entry in io_at(&self.root, fs::read_dir(&self.root))? {
let entry = io_at(&self.root, entry)?;
let path = entry.path();
let metadata = io_at(&path, fs::symlink_metadata(&path))?;
if !metadata.is_file() {
return Err(BlobError::UnsafePath(path));
}
}
block_on(async {
let mut objects = self.store.list(None);
let mut keys = BTreeSet::new();
while let Some(object) = objects.try_next().await? {
let key = object.location.to_string();
if key_path(&key).is_err() || after.is_some_and(|after| key.as_str() <= after) {
continue;
}
if keys.len() == limit {
if keys.last().is_some_and(|last| &key >= last) {
continue;
}
keys.pop_last();
}
keys.insert(key);
}
Ok(keys.into_iter().collect())
})
}
fn sync_root(&self) -> Result<(), BlobError> {
#[cfg(test)]
self.fail_at(TestFault::DirectorySync, &self.root)?;
sync_directory(&self.root)
}
#[cfg(test)]
fn fail_at(&self, point: TestFault, path: &Path) -> Result<(), BlobError> {
if self.fault == Some(point) {
let kind = match point {
TestFault::UploadNoSpace => io::ErrorKind::StorageFull,
TestFault::UploadPermissionDenied => io::ErrorKind::PermissionDenied,
_ => io::ErrorKind::Other,
};
return io_at(
path,
Err(io::Error::new(kind, format!("injected {point:?} failure"))),
);
}
Ok(())
}
fn check_root(&self) -> Result<(), BlobError> {
check_directories(&self.root, false)
}
fn check_blob(&self, key: &str) -> Result<(), BlobError> {
let path = self.root.join(key);
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.is_file() => Ok(()),
Ok(_) => Err(BlobError::UnsafePath(path)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(BlobError::Io { path, source }),
}
}
}
fn key_path(key: &str) -> Result<ObjectPath, BlobError> {
let uuid = Uuid::parse_str(key).map_err(|_| BlobError::InvalidKey(key.to_owned()))?;
if uuid.hyphenated().to_string() != key {
return Err(BlobError::InvalidKey(key.to_owned()));
}
Ok(ObjectPath::from(key))
}
fn is_staging_name(name: &str) -> bool {
let Some((key, suffix)) = name.split_once('#') else {
return false;
};
// object_store 0.13.2 local.rs::new_staged_upload starts an inferred i32
// at 1 and uses counter.to_string(); staged_upload_path appends '#suffix'.
// Restrict to names this adapter's puts can generate, not the broader
// '#digits' pattern that LocalFileSystem merely hides during listing.
key_path(key).is_ok()
&& suffix
.parse::<i32>()
.is_ok_and(|counter| counter > 0 && counter.to_string() == suffix)
}
fn io_at<T>(path: &Path, result: io::Result<T>) -> Result<T, BlobError> {
result.map_err(|source| BlobError::Io {
path: path.to_owned(),
source,
})
}
fn check_directories(root: &Path, create: bool) -> Result<(), BlobError> {
let mut path = PathBuf::new();
for component in root.components() {
match component {
Component::Prefix(_) | Component::RootDir => path.push(component),
Component::CurDir => continue,
Component::ParentDir => return Err(BlobError::InvalidRoot(root.to_owned())),
Component::Normal(name) => path.push(name),
}
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => return Err(BlobError::UnsafePath(path)),
Err(error) if create && error.kind() == io::ErrorKind::NotFound => {
match fs::create_dir(&path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
Err(source) => return Err(BlobError::Io { path, source }),
}
// A concurrent opener may have won create_dir. Never accept
// its entry without checking the type without following links.
let metadata = io_at(&path, fs::symlink_metadata(&path))?;
if !metadata.is_dir() {
return Err(BlobError::UnsafePath(path));
}
}
Err(source) => return Err(BlobError::Io { path, source }),
}
if create {
// Sync existing directories too: a previous failed open may have
// created one but failed before persisting its parent link.
sync_directory(&path)?;
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
sync_directory(parent)?;
}
}
}
Ok(())
}
fn sync_directory(path: &Path) -> Result<(), BlobError> {
// Do not silently weaken durability on platforms/filesystems that cannot
// open and sync directories: open/put/delete must propagate that failure.
let directory = io_at(path, File::open(path))?;
io_at(path, directory.sync_all())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn key() -> String {
Uuid::now_v7().to_string()
}
#[test]
fn put_existing_key_preserves_original_bytes_after_reopen() {
let dir = tempdir().unwrap();
let root = dir.path().join("nested/blobs");
let blob = key();
let store = BlobStore::open(&root).unwrap();
store.put(&blob, b"original").unwrap();
assert!(matches!(
store.put(&blob, b"replacement"),
Err(BlobError::Store(object_store::Error::AlreadyExists { .. }))
));
let reopened = BlobStore::open(&root).unwrap();
assert_eq!(reopened.read(&blob, 0..8).unwrap(), b"original");
}
#[test]
fn read_returns_exact_ranges_and_rejects_out_of_bounds() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let blob = key();
store.put(&blob, b"abcdef").unwrap();
assert_eq!(store.read(&blob, 2..5).unwrap(), b"cde");
assert_eq!(store.read(&blob, 6..6).unwrap(), b"");
for (start, end) in [(4, 2), (0, 7), (7, 7)] {
assert!(matches!(
store.read(&blob, start..end),
Err(BlobError::InvalidRange { .. })
));
}
let empty = key();
store.put(&empty, b"").unwrap();
assert_eq!(store.read(&empty, 0..0).unwrap(), b"");
assert!(matches!(
store.read(&key(), 0..0),
Err(BlobError::Store(object_store::Error::NotFound { .. }))
));
}
#[test]
fn noncanonical_keys_are_rejected_by_every_boundary() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let canonical = "019abcdef012-7000-8000-000000000001";
for invalid in [
"",
"../escape",
"/absolute",
"a/b",
"not-a-uuid",
canonical,
"019abcdef01270008000000000000001",
"019abcde-f012-7000-8000-000000000001/child",
"019ABCDE-F012-7000-8000-000000000001",
"urn:uuid:019abcde-f012-7000-8000-000000000001",
] {
assert!(
matches!(store.put(invalid, b"data"), Err(BlobError::InvalidKey(_))),
"{invalid}"
);
assert!(
matches!(store.read(invalid, 0..1), Err(BlobError::InvalidKey(_))),
"{invalid}"
);
assert!(
matches!(store.size(invalid), Err(BlobError::InvalidKey(_))),
"{invalid}"
);
assert!(
matches!(store.delete(invalid), Err(BlobError::InvalidKey(_))),
"{invalid}"
);
assert!(
matches!(store.list(Some(invalid), 0), Err(BlobError::InvalidKey(_))),
"{invalid}"
);
}
assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[test]
fn list_is_sorted_exclusive_and_hides_staging_and_foreign_files() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let keys = [
"019abcde-f012-7000-8000-000000000001",
"019abcde-f012-7000-8000-000000000002",
"019abcde-f012-7000-8000-000000000003",
];
for blob in keys.iter().rev() {
store.put(blob, b"data").unwrap();
}
fs::write(dir.path().join(format!("{}#1", keys[0])), b"partial").unwrap();
fs::write(dir.path().join("foreign"), b"data").unwrap();
assert_eq!(store.list(None, 2).unwrap(), keys[..2]);
assert_eq!(store.list(Some(keys[0]), 1).unwrap(), keys[1..2]);
assert_eq!(store.list(Some(keys[1]), 10).unwrap(), keys[2..]);
assert!(store.list(Some(keys[2]), 10).unwrap().is_empty());
assert!(store.list(None, 0).unwrap().is_empty());
}
#[test]
fn delete_is_idempotent_and_absent_after_reopen() {
let dir = tempdir().unwrap();
let blob = key();
let store = BlobStore::open(dir.path()).unwrap();
store.put(&blob, b"data").unwrap();
store.delete(&blob).unwrap();
store.delete(&blob).unwrap();
let reopened = BlobStore::open(dir.path()).unwrap();
assert!(reopened.list(None, 10).unwrap().is_empty());
assert!(matches!(
reopened.read(&blob, 0..1),
Err(BlobError::Store(object_store::Error::NotFound { .. }))
));
}
#[test]
fn root_rejects_files_empty_paths_and_parent_traversal() {
let dir = tempdir().unwrap();
let file = dir.path().join("file");
fs::write(&file, b"data").unwrap();
assert!(matches!(
BlobStore::open(&file),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(
BlobStore::open(&file.join("blobs")),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(
BlobStore::open(Path::new("")),
Err(BlobError::InvalidRoot(_))
));
assert!(matches!(
BlobStore::open(&dir.path().join("../blobs")),
Err(BlobError::InvalidRoot(_))
));
}
#[cfg(unix)]
#[test]
fn root_rejects_symlink_components_including_dangling_links() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let target = dir.path().join("target");
fs::create_dir(&target).unwrap();
let link = dir.path().join("link");
symlink(&target, &link).unwrap();
assert!(matches!(
BlobStore::open(&link),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(
BlobStore::open(&link.join("blobs")),
Err(BlobError::UnsafePath(_))
));
let dangling = dir.path().join("dangling");
symlink(dir.path().join("missing"), &dangling).unwrap();
assert!(matches!(
BlobStore::open(&dangling),
Err(BlobError::UnsafePath(_))
));
assert!(!target.join("blobs").exists());
}
#[cfg(unix)]
#[test]
fn blob_symlink_is_rejected_without_reading_or_mutating_target() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let root = dir.path().join("blobs");
let store = BlobStore::open(&root).unwrap();
let target = dir.path().join("target");
fs::write(&target, b"secret").unwrap();
let blob = key();
symlink(&target, root.join(&blob)).unwrap();
assert!(matches!(
store.read(&blob, 0..6),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(
store.put(&blob, b"changed"),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(store.delete(&blob), Err(BlobError::UnsafePath(_))));
assert!(matches!(
store.list(None, 10),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(store.size(&blob), Err(BlobError::UnsafePath(_))));
assert!(matches!(
store.cleanup_staging(10),
Err(BlobError::UnsafePath(_))
));
assert_eq!(fs::read(&target).unwrap(), b"secret");
}
#[cfg(unix)]
#[test]
fn replaced_root_symlink_is_rejected_by_existing_store() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let root = dir.path().join("blobs");
let store = BlobStore::open(&root).unwrap();
let moved = dir.path().join("moved");
fs::rename(&root, &moved).unwrap();
symlink(&moved, &root).unwrap();
let blob = key();
assert!(matches!(
store.put(&blob, b"data"),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(
store.read(&blob, 0..0),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(store.delete(&blob), Err(BlobError::UnsafePath(_))));
assert!(matches!(
store.list(None, 10),
Err(BlobError::UnsafePath(_))
));
assert!(matches!(store.size(&blob), Err(BlobError::UnsafePath(_))));
assert!(matches!(
store.cleanup_staging(10),
Err(BlobError::UnsafePath(_))
));
assert_eq!(fs::read_dir(&moved).unwrap().count(), 0);
}
#[test]
fn list_rejects_subdirectories_instead_of_traversing_them() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
fs::create_dir(dir.path().join("nested")).unwrap();
assert!(matches!(
store.list(None, 10),
Err(BlobError::UnsafePath(_))
));
}
#[test]
fn independent_store_creates_have_exactly_one_winner() {
use std::sync::Barrier;
let dir = tempdir().unwrap();
let first = BlobStore::open(dir.path()).unwrap();
let second = BlobStore::open(dir.path()).unwrap();
let blob = key();
let barrier = Barrier::new(2);
let results = std::thread::scope(|scope| {
let one = scope.spawn(|| {
barrier.wait();
first.put(&blob, b"first")
});
let two = scope.spawn(|| {
barrier.wait();
second.put(&blob, b"second")
});
[one.join().unwrap(), two.join().unwrap()]
});
assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1);
let loser = results.iter().find(|r| r.is_err()).unwrap();
assert!(matches!(
loser,
Err(BlobError::Store(object_store::Error::AlreadyExists { .. }))
));
let expected: &[u8] = if results[0].is_ok() {
b"first"
} else {
b"second"
};
assert_eq!(first.read(&blob, 0..expected.len()).unwrap(), expected);
}
#[test]
fn small_list_pages_choose_earliest_keys_across_full_directory() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let keys: Vec<_> = (1..=37).map(|id| Uuid::from_u128(id).to_string()).collect();
for blob in keys.iter().rev() {
fs::write(dir.path().join(blob), b"data").unwrap();
}
assert_eq!(store.list(None, 3).unwrap(), keys[..3]);
assert_eq!(store.list(Some(&keys[17]), 3).unwrap(), keys[18..21]);
assert_eq!(store.list(Some(&keys[35]), 3).unwrap(), keys[36..]);
}
#[test]
fn staging_cleanup_is_bounded_and_preserves_final_blobs_and_foreign_names() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let referenced = "019abcde-f012-7000-8000-000000000001".to_owned();
store.put(&referenced, b"referenced data").unwrap();
let linked_stage = format!("{referenced}#1");
fs::hard_link(dir.path().join(&referenced), dir.path().join(&linked_stage)).unwrap();
let stages = [
linked_stage,
format!("{}#2", key()),
format!("{}#2147483647", key()),
];
for stage in &stages[1..] {
fs::write(dir.path().join(stage), b"partial").unwrap();
}
let foreign = [
"foreign#1".to_owned(),
format!("{}#1", referenced.to_uppercase()),
format!("{referenced}#0"),
format!("{referenced}#01"),
format!("{referenced}#+1"),
format!("{referenced}#-1"),
format!("{referenced}#2147483648"),
format!("{referenced}#"),
format!("{referenced}#1.extra"),
format!("{referenced}#1#2"),
];
for name in &foreign {
fs::write(dir.path().join(name), b"foreign data").unwrap();
}
assert_eq!(store.cleanup_staging(0).unwrap(), 0);
assert!(stages.iter().all(|name| dir.path().join(name).exists()));
for remaining in (0..3).rev() {
assert_eq!(store.cleanup_staging(1).unwrap(), 1);
assert_eq!(
stages
.iter()
.filter(|name| dir.path().join(name).exists())
.count(),
remaining
);
}
assert_eq!(store.cleanup_staging(10).unwrap(), 0);
assert_eq!(store.read(&referenced, 0..15).unwrap(), b"referenced data");
assert_eq!(store.list(None, 10).unwrap(), vec![referenced.clone()]);
for name in &foreign {
assert_eq!(
fs::read(dir.path().join(name)).unwrap(),
b"foreign data",
"{name}"
);
}
let reopened = BlobStore::open(dir.path()).unwrap();
assert_eq!(reopened.size(&referenced).unwrap(), 15);
}
#[cfg(unix)]
#[test]
fn staging_cleanup_rejects_symlinks_without_unlinking_or_following_them() {
use std::os::unix::fs::symlink;
let dir = tempdir().unwrap();
let target = dir.path().join("target");
fs::write(&target, b"outside").unwrap();
for name in [format!("{}#1", key()), "foreign-link".to_owned()] {
let root = tempdir().unwrap();
let store = BlobStore::open(root.path()).unwrap();
let path = root.path().join(name);
symlink(&target, &path).unwrap();
assert!(matches!(
store.cleanup_staging(10),
Err(BlobError::UnsafePath(_))
));
assert!(fs::symlink_metadata(&path).unwrap().is_symlink());
}
let root = tempdir().unwrap();
let store = BlobStore::open(root.path()).unwrap();
symlink(
dir.path().join("missing"),
root.path().join(format!("{}#1", key())),
)
.unwrap();
assert!(matches!(
store.cleanup_staging(10),
Err(BlobError::UnsafePath(_))
));
assert_eq!(fs::read(target).unwrap(), b"outside");
}
#[test]
fn staging_cleanup_rejects_recognized_directories_and_preserves_foreign_directories() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let foreign = dir.path().join("foreign-directory");
fs::create_dir(&foreign).unwrap();
assert_eq!(store.cleanup_staging(10).unwrap(), 0);
assert!(foreign.is_dir());
let recognized = dir.path().join(format!("{}#1", key()));
fs::create_dir(&recognized).unwrap();
assert!(matches!(
store.cleanup_staging(10),
Err(BlobError::UnsafePath(_))
));
assert!(recognized.is_dir());
}
#[test]
fn size_reports_total_length_even_for_empty_or_short_range_reads() {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let blob = key();
store.put(&blob, b"data").unwrap();
assert_eq!(store.read(&blob, 0..0).unwrap(), b"");
assert_eq!(store.read(&blob, 0..2).unwrap(), b"da");
assert_eq!(store.size(&blob).unwrap(), 4);
// External corruption: range output alone cannot reveal truncation if
// the selected range is empty. The authority can compare total sizes.
fs::write(dir.path().join(&blob), b"").unwrap();
assert_eq!(store.read(&blob, 0..0).unwrap(), b"");
assert_eq!(store.size(&blob).unwrap(), 0);
assert!(matches!(
store.size(&key()),
Err(BlobError::Store(object_store::Error::NotFound { .. }))
));
}
#[test]
fn partial_staging_write_failure_never_reports_success_or_publishes_blob() {
let dir = tempdir().unwrap();
let mut store = BlobStore::open(dir.path()).unwrap();
let blob = key();
store.fault = Some(TestFault::PartialStagingWrite);
assert!(matches!(
store.put(&blob, b"abcdef"),
Err(BlobError::Io { .. })
));
assert_eq!(
fs::read(dir.path().join(format!("{blob}#1"))).unwrap(),
b"abc"
);
assert!(!dir.path().join(&blob).exists());
let reopened = BlobStore::open(dir.path()).unwrap();
assert!(reopened.list(None, 10).unwrap().is_empty());
assert!(matches!(
reopened.size(&blob),
Err(BlobError::Store(object_store::Error::NotFound { .. }))
));
assert_eq!(reopened.cleanup_staging(1).unwrap(), 1);
reopened.put(&blob, b"abcdef").unwrap();
assert_eq!(reopened.read(&blob, 0..6).unwrap(), b"abcdef");
}
#[test]
fn simulated_upload_failures_preserve_error_kind_and_do_not_publish_blobs() {
for (fault, expected_kind, staging_count) in [
(TestFault::UploadNoSpace, io::ErrorKind::StorageFull, 1),
(
TestFault::UploadPermissionDenied,
io::ErrorKind::PermissionDenied,
0,
),
] {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap().with_fault(fault);
let blob = key();
match store.put(&blob, b"abcdef").unwrap_err() {
BlobError::Io { source, .. } => assert_eq!(source.kind(), expected_kind),
error => panic!("unexpected {fault:?} error: {error:?}"),
}
assert!(!dir.path().join(&blob).exists());
assert!(store.list(None, 10).unwrap().is_empty());
if staging_count > 0 {
assert_eq!(
fs::read(dir.path().join(format!("{blob}#1"))).unwrap(),
b"abc"
);
}
assert_eq!(store.cleanup_staging(10).unwrap(), staging_count);
}
}
#[test]
fn put_sync_failures_return_errors_even_when_final_file_exists() {
for fault in [TestFault::FileSync, TestFault::DirectorySync] {
let dir = tempdir().unwrap();
let mut store = BlobStore::open(dir.path()).unwrap();
let blob = key();
store.fault = Some(fault);
assert!(
matches!(store.put(&blob, b"data"), Err(BlobError::Io { .. })),
"{fault:?}"
);
// Publication is not a successful durable upload. It may leave an
// unreferenced final file, which the authority's GC can delete.
assert_eq!(fs::read(dir.path().join(&blob)).unwrap(), b"data");
let reopened = BlobStore::open(dir.path()).unwrap();
reopened.delete(&blob).unwrap();
assert!(!dir.path().join(&blob).exists());
}
}
#[test]
fn delete_sync_failure_returns_error_and_missing_file_retry_syncs() {
let dir = tempdir().unwrap();
let mut store = BlobStore::open(dir.path()).unwrap();
let blob = key();
store.put(&blob, b"data").unwrap();
store.fault = Some(TestFault::DirectorySync);
assert!(matches!(store.delete(&blob), Err(BlobError::Io { .. })));
assert!(!dir.path().join(&blob).exists());
assert!(matches!(store.delete(&blob), Err(BlobError::Io { .. })));
store.fault = None;
store.delete(&blob).unwrap();
}
#[test]
fn staging_cleanup_sync_failure_returns_error_and_empty_retry_syncs() {
let dir = tempdir().unwrap();
let mut store = BlobStore::open(dir.path()).unwrap();
let stage = dir.path().join(format!("{}#1", key()));
fs::write(&stage, b"partial").unwrap();
store.fault = Some(TestFault::DirectorySync);
assert!(matches!(
store.cleanup_staging(1),
Err(BlobError::Io { .. })
));
assert!(!stage.exists());
assert!(matches!(
store.cleanup_staging(1),
Err(BlobError::Io { .. })
));
store.fault = None;
assert_eq!(store.cleanup_staging(1).unwrap(), 0);
}
#[test]
fn synchronous_methods_work_on_tokio_blocking_threads() {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.build()
.unwrap();
runtime.block_on(async {
tokio::task::spawn_blocking(|| {
let dir = tempdir().unwrap();
let store = BlobStore::open(dir.path()).unwrap();
let blob = key();
store.put(&blob, b"data").unwrap();
assert_eq!(store.read(&blob, 0..4).unwrap(), b"data");
assert_eq!(store.list(None, 1).unwrap(), vec![blob.clone()]);
store.delete(&blob).unwrap();
})
.await
.unwrap();
});
}
}
+761
View File
@@ -0,0 +1,761 @@
//! Workspace-owned latest-version storage. Call these blocking operations on a
//! blocking thread, never on an async executor. SQL IMMEDIATE transactions are
//! the cross-process authority for uploads, tree changes, reads, and collection.
mod blob;
use feature_storage::{
FeatureDatabase, FeatureMigration, FeatureRegistration, FeatureStorageError, RegisteredFeature,
ScopedFeatureStorage,
};
use rusqlite::{Connection, OptionalExtension, Transaction, params};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{path::Path, sync::Arc};
use uuid::Uuid;
pub type Result<T> = std::result::Result<T, Error>;
pub const MAX_FILE_BYTES: usize = 16 * 1024 * 1024;
pub const MAX_READ_BYTES: usize = 1024 * 1024;
pub const MAX_PAGE: usize = 200;
pub const MAX_SEARCH_NODES: usize = 128;
pub const MAX_SEARCH_TEXT_BYTES: usize = 64 * 1024;
pub const MAX_NAME_BYTES: usize = 255;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("invalid Drive request: {0}")]
Invalid(String),
#[error("Drive node or revision no longer exists")]
NotFound,
#[error("Drive revision, name, or request identity conflict")]
Conflict,
#[error("Drive access denied")]
Denied,
#[error("Workspace Drive is fenced for deletion")]
Fenced,
#[error("Drive storage failure: {0}")]
Storage(#[from] FeatureStorageError),
#[error("Drive metadata failure: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("Drive blob failure: {0}")]
Blob(#[from] blob::BlobError),
#[error("Drive result encoding failure: {0}")]
Encoding(#[from] serde_json::Error),
}
// Decimal strings at the external boundary avoid JavaScript's 53-bit limit.
macro_rules! decimal_id {
($name:ident) => {
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct $name(i64);
impl $name {
pub fn get(self) -> i64 {
self.0
}
}
impl TryFrom<String> for $name {
type Error = String;
fn try_from(value: String) -> std::result::Result<Self, String> {
let n: i64 = value.parse().map_err(|_| "invalid decimal identifier")?;
if n <= 0 || n.to_string() != value {
return Err("invalid decimal identifier".into());
}
Ok(Self(n))
}
}
impl From<$name> for String {
fn from(value: $name) -> Self {
value.0.to_string()
}
}
};
}
decimal_id!(NodeId);
decimal_id!(Revision);
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
File,
Directory,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct Node {
pub id: NodeId,
pub workspace_id: String,
pub parent_id: Option<NodeId>,
pub name: String,
pub kind: Kind,
pub revision: Revision,
pub size: u64,
pub content_type: Option<String>,
pub updated_by: String,
pub updated_at_ms: i64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
pub enum Mutation {
CreateFolder {
parent: NodeId,
name: String,
},
CreateFile {
parent: NodeId,
name: String,
content_type: String,
bytes: Vec<u8>,
},
Update {
id: NodeId,
expected_revision: Revision,
content_type: String,
bytes: Vec<u8>,
},
Relocate {
id: NodeId,
expected_revision: Revision,
parent: NodeId,
name: String,
},
Delete {
id: NodeId,
expected_revision: Revision,
},
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct MutationResult {
pub node: Node,
pub deleted: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum RequestStatus {
/// No committed result in this authority snapshot. A concurrently running
/// request may still commit; retry with the SAME identity and fingerprint.
Uncommitted,
Committed {
result: MutationResult,
},
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Page {
pub nodes: Vec<Node>,
pub next_after: Option<NodeId>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ReadChunk {
pub id: NodeId,
pub revision: Revision,
pub offset: u64,
pub bytes: Vec<u8>,
pub eof: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TextChunk {
pub id: NodeId,
pub revision: Revision,
pub offset: u64,
pub text: String,
pub eof: bool,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SearchPage {
pub nodes: Vec<Node>,
pub next_after: Option<NodeId>,
pub examined: usize,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CollectionPage {
pub removed: usize,
pub next_after: Option<String>,
}
type Authorizer = dyn Fn(&Connection, bool) -> Result<()> + Send + Sync;
#[derive(Clone)]
pub struct Drive {
authorizer: Option<Arc<Authorizer>>,
database: FeatureDatabase,
blobs: Arc<blob::BlobStore>,
workspace_id: String,
_storage: ScopedFeatureStorage,
}
static MIGRATIONS: &[FeatureMigration] =
&[FeatureMigration::new(1, "drive_tree_and_receipts", migrate)];
fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> {
tx.execute_batch("\
CREATE TABLE drive_state (singleton INTEGER PRIMARY KEY CHECK(singleton=1),
workspace_id TEXT NOT NULL UNIQUE, deleting INTEGER NOT NULL DEFAULT 0 CHECK(deleting IN (0,1)));
CREATE TABLE drive_nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id TEXT NOT NULL REFERENCES drive_state(workspace_id),
parent_id INTEGER,
name TEXT NOT NULL CHECK(length(CAST(name AS BLOB)) <= 255),
kind TEXT NOT NULL CHECK(kind IN ('file','directory')),
revision INTEGER NOT NULL CHECK(revision > 0),
blob_key TEXT, size INTEGER NOT NULL CHECK(size >= 0 AND size <= 16777216),
content_type TEXT, updated_by TEXT NOT NULL, updated_at_ms INTEGER NOT NULL,
UNIQUE(workspace_id,id),
FOREIGN KEY(workspace_id,parent_id) REFERENCES drive_nodes(workspace_id,id),
CHECK((kind='directory' AND blob_key IS NULL AND size=0 AND content_type IS NULL)
OR (kind='file' AND blob_key IS NOT NULL AND content_type IS NOT NULL)));
CREATE UNIQUE INDEX drive_sibling_name ON drive_nodes(workspace_id,ifnull(parent_id,0),name);
CREATE INDEX drive_children ON drive_nodes(parent_id,id);
CREATE INDEX drive_blob_reference ON drive_nodes(blob_key) WHERE blob_key IS NOT NULL;
CREATE UNIQUE INDEX drive_single_root ON drive_nodes(workspace_id) WHERE parent_id IS NULL;
CREATE TABLE drive_receipts (request_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL,
result_json TEXT NOT NULL, committed_at_ms INTEGER NOT NULL);
")?;
Ok(())
}
impl Drive {
pub fn register(storage: &ScopedFeatureStorage) -> feature_storage::Result<RegisteredFeature> {
storage.register(FeatureRegistration::new("workspace-drive", MIGRATIONS))
}
pub fn open(
storage: &ScopedFeatureStorage,
registration: &RegisteredFeature,
root: &Path,
) -> Result<Self> {
let database = storage.open(registration)?;
let workspace_id = storage.workspace_id().to_owned();
let blobs = Arc::new(blob::BlobStore::open(root)?);
database.try_transaction::<_,Error>(|tx| {
tx.execute("INSERT OR IGNORE INTO drive_state(singleton,workspace_id) VALUES (1,?1)", [&workspace_id])?;
let stored: String = tx.query_row("SELECT workspace_id FROM drive_state WHERE singleton=1", [], |r| r.get(0))?;
if stored != workspace_id { return Err(Error::Invalid("metadata belongs to another Workspace".into())); }
tx.execute("INSERT OR IGNORE INTO drive_nodes(workspace_id,parent_id,name,kind,revision,size,updated_by,updated_at_ms)
VALUES (?1,NULL,'','directory',1,0,'server',?2)", params![workspace_id, now_ms()])?;
Ok(())
})?;
Ok(Self {
authorizer: None,
database,
blobs,
workspace_id,
_storage: storage.clone(),
})
}
/// Trusted Server attachment: SQLite IMMEDIATE also locks this authority,
/// so a cached Drive handle cannot race deletion reservation in server.db.
/// This is Host configuration, never a model-selected database path. The
/// attached authority is read-only by convention; Drive never writes it.
pub fn open_with_workspace_authority(
storage: &ScopedFeatureStorage,
registration: &RegisteredFeature,
root: &Path,
authority_database: &Path,
) -> Result<Self> {
let drive = Self::open(storage, registration, root)?;
let path = authority_database
.to_str()
.ok_or_else(|| Error::Invalid("authority database path must be UTF-8".into()))?;
if !authority_database.is_file() {
return Err(Error::Invalid(
"Workspace authority database is missing".into(),
));
}
drive.database.try_with_connection::<_, Error>(|c| {
let existing: Option<String> = c
.query_row(
"SELECT file FROM pragma_database_list WHERE name='yoi_workspace_authority'",
[],
|r| r.get(0),
)
.optional()?;
match existing {
Some(old) if Path::new(&old) != authority_database => Err(Error::Invalid(
"Drive already bound to a different Workspace authority".into(),
)),
Some(_) => Ok(()),
None => {
c.execute("ATTACH DATABASE ?1 AS yoi_workspace_authority", [path])?;
Ok(())
}
}
})?;
Ok(drive)
}
/// Bind immutable caller authority to this handle and its clones. The callback
/// runs within every client operation's IMMEDIATE transaction, never at bind
/// time; attached authority stays locked through blob I/O and publication.
/// Host maintenance must use an unbound handle.
pub fn with_authorizer<F>(&self, authorizer: F) -> Self
where
F: Fn(&Connection, bool) -> Result<()> + Send + Sync + 'static,
{
Self {
authorizer: Some(Arc::new(authorizer)),
..self.clone()
}
}
fn authorize(&self, connection: &Connection, write: bool) -> Result<()> {
match &self.authorizer {
Some(authorizer) => authorizer(connection, write),
None => Ok(()),
}
}
/// Early admission check, not a lease: every subsequent operation still
/// invokes its authorizer in that operation's own transaction.
pub fn check_authorized(&self, write: bool) -> Result<()> {
self.database.try_transaction(|tx| {
self.authorize(tx, write)?;
accepting(tx)
})
}
pub fn root(&self) -> Result<Node> {
self.database.try_transaction(|c| {
self.authorize(c, false)?;
let id = c.query_row(
"SELECT id FROM drive_nodes WHERE parent_id IS NULL",
[],
|r| r.get(0),
)?;
node(c, NodeId(id))
})
}
pub fn metadata(&self, id: NodeId) -> Result<Node> {
self.database.try_transaction(|c| {
self.authorize(c, false)?;
node(c, id)
})
}
/// Keyset pages are ordered by stable ID, not name. Concurrent mutations may
/// appear/disappear between pages. There is no snapshot promise across calls.
pub fn list(&self, parent: NodeId, after: Option<NodeId>, limit: usize) -> Result<Page> {
validate_limit(limit, MAX_PAGE)?;
self.database.try_transaction(|tx| {
self.authorize(tx, false)?;
directory(tx, parent)?;
let mut stmt = tx.prepare(
"SELECT id FROM drive_nodes WHERE parent_id=?1 AND id>?2 ORDER BY id LIMIT ?3",
)?;
let ids = stmt
.query_map(
params![parent.0, after.map_or(0, |v| v.0), limit + 1],
|r| r.get::<_, i64>(0),
)?
.collect::<std::result::Result<Vec<_>, _>>()?;
let has_more = ids.len() > limit;
let nodes = ids
.into_iter()
.take(limit)
.map(|id| node(tx, NodeId(id)))
.collect::<Result<Vec<_>>>()?;
let next_after = has_more.then(|| nodes.last().unwrap().id);
Ok(Page { nodes, next_after })
})
}
pub fn request_status(&self, request_id: &str) -> Result<RequestStatus> {
validate_identity(request_id, "request_id", 128)?;
self.database.try_transaction(|c| {
self.authorize(c, false)?;
let result = c
.query_row(
"SELECT result_json FROM drive_receipts WHERE request_id=?1",
[request_id],
|r| r.get::<_, String>(0),
)
.optional()?;
match result {
Some(json) => Ok(RequestStatus::Committed {
result: serde_json::from_str(&json)?,
}),
None => Ok(RequestStatus::Uncommitted),
}
})
}
/// DB commit is publication. The blob and receipt are never independently
/// treated as a successful mutation. Successful request IDs are retained for
/// the Workspace lifetime; reuse with a different actor/payload is conflict.
pub fn mutate(
&self,
request_id: &str,
actor: &str,
mutation: Mutation,
) -> Result<MutationResult> {
validate_identity(request_id, "request_id", 128)?;
validate_identity(actor, "actor", 256)?;
validate_mutation(&mutation)?;
let fingerprint: String = Sha256::digest(serde_json::to_vec(&(actor, &mutation))?)
.iter()
.map(|b| format!("{b:02x}"))
.collect();
self.database.try_transaction(|tx| {
self.authorize(tx, true)?;
if let Some((old_fingerprint,json)) = tx.query_row("SELECT fingerprint,result_json FROM drive_receipts WHERE request_id=?1",
[request_id], |r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?))).optional()? {
if old_fingerprint != fingerprint { return Err(Error::Conflict); }
return Ok(serde_json::from_str(&json)?);
}
accepting(tx)?;
let at = now_ms();
let result = match mutation {
Mutation::CreateFolder { parent,name } => {
directory(tx,parent)?;
insert(tx,&self.workspace_id,parent,&name,Kind::Directory,None,0,None,actor,at)?
}
Mutation::CreateFile { parent,name,content_type,bytes } => {
directory(tx,parent)?;
let key = Uuid::now_v7().to_string();
self.blobs.put(&key,&bytes)?;
insert(tx,&self.workspace_id,parent,&name,Kind::File,Some(&key),bytes.len(),Some(&content_type),actor,at)?
}
Mutation::Update { id,expected_revision,content_type,bytes } => {
let old = changeable(tx,id,expected_revision)?;
if old.kind != Kind::File { return Err(Error::Invalid("cannot write a directory".into())); }
let key = Uuid::now_v7().to_string();
self.blobs.put(&key,&bytes)?;
cas(tx.execute("UPDATE drive_nodes SET blob_key=?1,size=?2,content_type=?3,revision=revision+1,updated_by=?4,updated_at_ms=?5
WHERE id=?6 AND revision=?7", params![key,bytes.len(),content_type,actor,at,id.0,expected_revision.0])?)?;
MutationResult { node: node(tx,id)?, deleted:false }
}
Mutation::Relocate { id,expected_revision,parent,name } => {
changeable(tx,id,expected_revision)?;
directory(tx,parent)?;
let cyclic: bool = tx.query_row("WITH RECURSIVE ancestors(id,parent_id) AS (
SELECT id,parent_id FROM drive_nodes WHERE id=?1 UNION ALL
SELECT n.id,n.parent_id FROM drive_nodes n JOIN ancestors a ON n.id=a.parent_id)
SELECT EXISTS(SELECT 1 FROM ancestors WHERE id=?2)",params![parent.0,id.0],|r|r.get(0))?;
if cyclic { return Err(Error::Invalid("move would create a cycle".into())); }
unique(tx.execute("UPDATE drive_nodes SET parent_id=?1,name=?2,revision=revision+1,updated_by=?3,updated_at_ms=?4
WHERE id=?5 AND revision=?6",params![parent.0,name,actor,at,id.0,expected_revision.0]))?;
MutationResult { node:node(tx,id)?,deleted:false }
}
Mutation::Delete { id,expected_revision } => {
let old = changeable(tx,id,expected_revision)?;
let children: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM drive_nodes WHERE parent_id=?1)",[id.0],|r|r.get(0))?;
if children { return Err(Error::Conflict); }
cas(tx.execute("DELETE FROM drive_nodes WHERE id=?1 AND revision=?2",params![id.0,expected_revision.0])?)?;
MutationResult { node:old,deleted:true }
}
};
tx.execute("INSERT INTO drive_receipts(request_id,fingerprint,result_json,committed_at_ms) VALUES (?1,?2,?3,?4)",
params![request_id,fingerprint,serde_json::to_string(&result)?,at])?;
Ok(result)
})
}
/// Each chunk requires the originally selected revision. Old revisions are
/// not retained: after update/delete return conflict/not-found, never switch
/// silently to the latest blob. GC cannot run while this read holds SQL lock.
pub fn read(
&self,
id: NodeId,
revision: Revision,
offset: u64,
length: usize,
) -> Result<ReadChunk> {
validate_limit(length, MAX_READ_BYTES)?;
self.database.try_transaction(|tx| {
self.authorize(tx, false)?;
accepting(tx)?;
let selected = node(tx, id)?;
if selected.revision != revision {
return Err(Error::Conflict);
}
if selected.kind != Kind::File {
return Err(Error::Invalid("cannot read a directory".into()));
}
if offset > selected.size {
return Err(Error::Invalid("offset beyond end of file".into()));
}
let end = offset.saturating_add(length as u64).min(selected.size);
let key: String = tx.query_row(
"SELECT blob_key FROM drive_nodes WHERE id=?1",
[id.0],
|r| r.get(0),
)?;
if self.blobs.size(&key)? != selected.size {
return Err(Error::Invalid("referenced blob length mismatch".into()));
}
let bytes = self.blobs.read(&key, offset as usize..end as usize)?;
if bytes.len() != (end - offset) as usize {
return Err(Error::Invalid("referenced blob length mismatch".into()));
}
Ok(ReadChunk {
id,
revision,
offset,
bytes,
eof: end == selected.size,
})
})
}
/// UTF-8 text over byte offsets. Callers must select character-aligned
/// ranges; invalid UTF-8 (including a split character) is an explicit error.
pub fn read_text(
&self,
id: NodeId,
revision: Revision,
offset: u64,
length: usize,
) -> Result<TextChunk> {
let chunk = self.read(id, revision, offset, length)?;
let text = String::from_utf8(chunk.bytes).map_err(|_| {
Error::Invalid("range is not valid UTF-8; select character-aligned offsets".into())
})?;
Ok(TextChunk {
id,
revision,
offset,
text,
eof: chunk.eof,
})
}
/// Search examines at most `budget` nodes after the cursor. Text inspection
/// is limited to UTF-8 text/* files <=64KiB, and matches are bounded by budget.
/// An empty matching page may still carry a continuation cursor.
pub fn search(
&self,
query: &str,
text: bool,
after: Option<NodeId>,
budget: usize,
) -> Result<SearchPage> {
validate_identity(query, "query", 256)?;
validate_limit(budget, MAX_SEARCH_NODES)?;
self.database.try_transaction(|tx| {
self.authorize(tx, false)?;
accepting(tx)?;
let mut stmt = tx.prepare("SELECT id FROM drive_nodes WHERE parent_id IS NOT NULL AND id>?1 ORDER BY id LIMIT ?2")?;
let ids = stmt.query_map(params![after.map_or(0,|v|v.0),budget+1],|r|r.get::<_,i64>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
let more = ids.len()>budget;
let mut nodes = Vec::new();
let mut last = None;
let mut examined = 0;
for id in ids.into_iter().take(budget) {
let n = node(tx,NodeId(id))?;
last = Some(n.id); examined += 1;
let mut matched = n.name.contains(query);
if !matched && text && n.kind==Kind::File && n.size<=MAX_SEARCH_TEXT_BYTES as u64 && n.content_type.as_deref().is_some_and(|t|t.starts_with("text/")) {
let key: String = tx.query_row("SELECT blob_key FROM drive_nodes WHERE id=?1",[id],|r|r.get(0))?;
if self.blobs.size(&key)? != n.size { return Err(Error::Invalid("referenced blob length mismatch".into())); }
let bytes = self.blobs.read(&key,0..n.size as usize)?;
if bytes.len()!=n.size as usize { return Err(Error::Invalid("referenced blob length mismatch".into())); }
matched = std::str::from_utf8(&bytes).is_ok_and(|s|s.contains(query));
}
if matched { nodes.push(n); }
}
Ok(SearchPage { nodes, next_after:if more {last} else {None}, examined })
})
}
/// Collection shares the exact database lock with uploads/readers. No grace
/// period or process-local mutex is used as a safety proof. Repeat full sweeps
/// from None, since new random keys can sort before a previous cursor.
pub fn collect(&self, after: Option<&str>, limit: usize) -> Result<CollectionPage> {
validate_limit(limit, MAX_PAGE)?;
self.database.try_transaction(|tx| {
accepting(tx)?;
let keys = self.blobs.list(after, limit + 1)?;
let more = keys.len() > limit;
let mut removed = self.blobs.cleanup_staging(limit)?;
let mut last = None;
for key in keys.into_iter().take(limit) {
let referenced: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM drive_nodes WHERE blob_key=?1)",
[&key],
|r| r.get(0),
)?;
if !referenced {
self.blobs.delete(&key)?;
removed += 1;
}
last = Some(key);
}
Ok(CollectionPage {
removed,
next_after: if more { last } else { None },
})
})
}
pub fn fence(&self) -> Result<()> {
self.database.try_transaction(|tx| {
tx.execute("UPDATE drive_state SET deleting=1 WHERE singleton=1", [])?;
Ok(())
})
}
/// Retryable purge. Fence is committed first. Root and deletion tombstone
/// remain until Host FeatureStorage deletes the metadata scope.
pub fn purge(&self) -> Result<()> {
self.fence()?;
self.database.try_transaction(|tx| {
while self.blobs.cleanup_staging(MAX_PAGE)? > 0 {}
loop {
let keys = self.blobs.list(None, MAX_PAGE)?;
if keys.is_empty() {
break;
}
for key in keys {
self.blobs.delete(&key)?;
}
}
tx.execute("DELETE FROM drive_nodes WHERE parent_id IS NOT NULL", [])?;
tx.execute("DELETE FROM drive_receipts", [])?;
Ok(())
})
}
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(i64::MAX as u128) as i64
}
fn validate_limit(n: usize, max: usize) -> Result<()> {
if n == 0 || n > max {
return Err(Error::Invalid(format!("limit must be 1..={max}")));
}
Ok(())
}
fn validate_identity(s: &str, what: &str, max: usize) -> Result<()> {
if s.is_empty() || s.len() > max || s.chars().any(char::is_control) {
return Err(Error::Invalid(format!("invalid {what}")));
}
Ok(())
}
fn validate_name(s: &str) -> Result<()> {
validate_identity(s, "name", MAX_NAME_BYTES)?;
if s == "." || s == ".." || s.contains(['/', '\\']) {
return Err(Error::Invalid("name is not a path".into()));
}
Ok(())
}
fn validate_content(content_type: &str, bytes: &[u8]) -> Result<()> {
validate_identity(content_type, "content_type", 128)?;
if !content_type.is_ascii() || !content_type.contains('/') {
return Err(Error::Invalid("invalid content_type".into()));
}
if bytes.len() > MAX_FILE_BYTES {
return Err(Error::Invalid(format!(
"file exceeds {MAX_FILE_BYTES} bytes"
)));
}
Ok(())
}
fn validate_mutation(m: &Mutation) -> Result<()> {
match m {
Mutation::CreateFolder { name, .. } | Mutation::Relocate { name, .. } => {
validate_name(name)
}
Mutation::CreateFile {
name,
content_type,
bytes,
..
} => {
validate_name(name)?;
validate_content(content_type, bytes)
}
Mutation::Update {
content_type,
bytes,
..
} => validate_content(content_type, bytes),
Mutation::Delete { .. } => Ok(()),
}
}
fn accepting(c: &Connection) -> Result<()> {
if c.query_row(
"SELECT deleting FROM drive_state WHERE singleton=1",
[],
|r| r.get::<_, bool>(0),
)? {
return Err(Error::Fenced);
}
let attached: bool = c.query_row(
"SELECT EXISTS(SELECT 1 FROM pragma_database_list WHERE name='yoi_workspace_authority')",
[],
|r| r.get(0),
)?;
if attached {
let state:Option<String>=c.query_row("SELECT state FROM yoi_workspace_authority.workspaces WHERE workspace_id=(SELECT workspace_id FROM drive_state WHERE singleton=1)",[],|r|r.get(0)).optional()?;
if state.as_deref() != Some("active") {
return Err(Error::Fenced);
}
}
Ok(())
}
fn node(c: &Connection, id: NodeId) -> Result<Node> {
c.query_row("SELECT id,workspace_id,parent_id,name,kind,revision,size,content_type,updated_by,updated_at_ms FROM drive_nodes WHERE id=?1",[id.0],|r| {
Ok(Node {id:NodeId(r.get(0)?),workspace_id:r.get(1)?,parent_id:r.get::<_,Option<i64>>(2)?.map(NodeId),name:r.get(3)?,
kind:if r.get::<_,String>(4)?=="file" {Kind::File} else {Kind::Directory},revision:Revision(r.get(5)?),
size:r.get::<_,i64>(6)? as u64,content_type:r.get(7)?,updated_by:r.get(8)?,updated_at_ms:r.get(9)?})
}).optional()?.ok_or(Error::NotFound)
}
fn directory(c: &Connection, id: NodeId) -> Result<()> {
if node(c, id)?.kind != Kind::Directory {
Err(Error::Invalid("parent must be a directory".into()))
} else {
Ok(())
}
}
fn changeable(c: &Connection, id: NodeId, revision: Revision) -> Result<Node> {
let n = node(c, id)?;
if n.parent_id.is_none() {
return Err(Error::Invalid("root is immutable".into()));
}
if n.revision != revision {
return Err(Error::Conflict);
}
if revision.0 == i64::MAX {
return Err(Error::Invalid("revision exhausted".into()));
}
Ok(n)
}
fn unique(result: rusqlite::Result<usize>) -> Result<usize> {
match result {
Err(rusqlite::Error::SqliteFailure(e, _))
if e.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE =>
{
Err(Error::Conflict)
}
other => Ok(other?),
}
}
fn cas(changed: usize) -> Result<()> {
if changed == 1 {
Ok(())
} else {
Err(Error::Conflict)
}
}
#[allow(clippy::too_many_arguments)]
fn insert(
tx: &Transaction<'_>,
workspace: &str,
parent: NodeId,
name: &str,
kind: Kind,
key: Option<&str>,
size: usize,
content_type: Option<&str>,
actor: &str,
at: i64,
) -> Result<MutationResult> {
unique(tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,revision,blob_key,size,content_type,updated_by,updated_at_ms)
VALUES (?1,?2,?3,?4,1,?5,?6,?7,?8,?9)",params![workspace,parent.0,name,if kind==Kind::File {"file"} else {"directory"},key,size,content_type,actor,at]))?;
Ok(MutationResult {
node: node(tx, NodeId(tx.last_insert_rowid()))?,
deleted: false,
})
}
#[cfg(test)]
mod tests;
+481
View File
@@ -0,0 +1,481 @@
use super::*;
fn fixture(t: &tempfile::TempDir) -> Drive {
let s = feature_storage::FeatureStorage::new(t.path().join("metadata"))
.workspace("ws")
.unwrap();
let r = Drive::register(&s).unwrap();
Drive::open(&s, &r, &t.path().join("blobs")).unwrap()
}
fn create(parent: NodeId, name: &str) -> Mutation {
Mutation::CreateFile {
parent,
name: name.into(),
content_type: "text/plain".into(),
bytes: b"persisted".to_vec(),
}
}
#[test]
fn migration_enforces_workspace_parent_and_unique_names() {
let t = tempfile::tempdir().unwrap();
let d = fixture(&t);
assert_eq!(d.database.schema_version().unwrap(), 1);
let root = d.root().unwrap();
d.mutate("a", "actor", create(root.id, "a")).unwrap();
assert!(matches!(
d.mutate("duplicate", "actor", create(root.id, "a")),
Err(Error::Conflict)
));
let result=d.database.try_transaction::<_,Error>(|tx| {
tx.execute("INSERT INTO drive_nodes(workspace_id,parent_id,name,kind,revision,size,updated_by,updated_at_ms)
VALUES ('other',?1,'bad','directory',1,0,'actor',0)",[root.id.0])?;
Ok(())
});
assert!(result.is_err());
assert_eq!(d.list(root.id, None, 10).unwrap().nodes.len(), 1);
}
#[test]
fn saved_blob_before_metadata_failure_is_not_published_and_restart_collects_it() {
let t = tempfile::tempdir().unwrap();
let d = fixture(&t);
let root = d.root().unwrap();
d.database.transaction(|tx| {tx.execute_batch("CREATE TRIGGER fail_node BEFORE INSERT ON drive_nodes WHEN NEW.parent_id IS NOT NULL
BEGIN SELECT RAISE(ABORT,'injected metadata failure'); END;")?;Ok(())}).unwrap();
assert!(
d.mutate("failed", "actor", create(root.id, "failed"))
.is_err()
);
assert_eq!(d.blobs.list(None, 20).unwrap().len(), 1);
assert!(d.list(root.id, None, 10).unwrap().nodes.is_empty());
assert_eq!(
d.request_status("failed").unwrap(),
RequestStatus::Uncommitted
);
d.database
.transaction(|tx| {
tx.execute_batch("DROP TRIGGER fail_node")?;
Ok(())
})
.unwrap();
drop(d);
let d = fixture(&t);
assert_eq!(d.collect(None, 20).unwrap().removed, 1);
let result = d
.mutate("failed", "actor", create(root.id, "failed"))
.unwrap();
assert_eq!(
d.read(result.node.id, result.node.revision, 0, 100)
.unwrap()
.bytes,
b"persisted"
);
}
#[test]
fn commit_failure_rolls_back_metadata_and_receipt_together() {
let t = tempfile::tempdir().unwrap();
let d = fixture(&t);
let root = d.root().unwrap();
d.database.transaction(|tx| {tx.execute_batch("CREATE TABLE deferred_commit_failure (node INTEGER REFERENCES drive_nodes(id) DEFERRABLE INITIALLY DEFERRED);
CREATE TRIGGER fail_commit AFTER INSERT ON drive_receipts BEGIN INSERT INTO deferred_commit_failure VALUES (-1); END;")?;Ok(())}).unwrap();
assert!(
d.mutate("failed", "actor", create(root.id, "failed"))
.is_err()
);
assert!(d.list(root.id, None, 10).unwrap().nodes.is_empty());
assert_eq!(
d.request_status("failed").unwrap(),
RequestStatus::Uncommitted
);
d.database
.transaction(|tx| {
tx.execute_batch("DROP TRIGGER fail_commit; DROP TABLE deferred_commit_failure")?;
Ok(())
})
.unwrap();
assert_eq!(d.collect(None, 10).unwrap().removed, 1);
assert!(
!d.mutate("failed", "actor", create(root.id, "failed"))
.unwrap()
.deleted
);
}
#[test]
fn collection_cannot_remove_in_flight_upload_or_selected_read() {
// Independent managers/SQLite connections simulate multiple server processes,
// not clones sharing a process-local lock. All calls use the same DB authority.
let t = tempfile::tempdir().unwrap();
let d = fixture(&t);
let other = fixture(&t);
let root = d.root().unwrap();
let first = d
.mutate("first", "actor", create(root.id, "file"))
.unwrap()
.node;
let go = Arc::new(std::sync::Barrier::new(2));
let g = go.clone();
let reader = std::thread::spawn(move || {
g.wait();
for _ in 0..20 {
match other.read(first.id, first.revision, 0, 100) {
Ok(chunk) => assert_eq!(chunk.bytes, b"persisted"),
Err(Error::Conflict) => {}
e => panic!("unexpected selected read outcome: {e:?}"),
}
other.collect(None, 200).unwrap();
}
});
go.wait();
let mut current = first;
for i in 0..20 {
current = d
.mutate(
&format!("update-{i}"),
"actor",
Mutation::Update {
id: current.id,
expected_revision: current.revision,
content_type: "text/plain".into(),
bytes: vec![i; 1024],
},
)
.unwrap()
.node;
}
reader.join().unwrap();
d.collect(None, 200).unwrap();
assert_eq!(d.blobs.list(None, 200).unwrap().len(), 1);
assert_eq!(
d.read(current.id, current.revision, 0, 1024).unwrap().bytes,
vec![19; 1024]
);
}
#[test]
fn attached_server_authority_serializes_deletion_with_cached_drive_mutations() {
let t = tempfile::tempdir().unwrap();
let authority = t.path().join("server.db");
let mut server = Connection::open(&authority).unwrap();
feature_storage::configure_connection(&server).unwrap();
server.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY,state TEXT NOT NULL); INSERT INTO workspaces VALUES ('ws','active');").unwrap();
let s = feature_storage::FeatureStorage::new(t.path().join("metadata"))
.workspace("ws")
.unwrap();
let reg = Drive::register(&s).unwrap();
let d = Drive::open_with_workspace_authority(&s, &reg, &t.path().join("blobs"), &authority)
.unwrap();
let root = d.root().unwrap();
let child = d
.mutate("before", "actor", create(root.id, "before"))
.unwrap()
.node;
// An IMMEDIATE Drive transaction locks both databases. Reserve deletion
// cannot commit while a previously admitted mutation is publishing a blob.
server.busy_timeout(std::time::Duration::ZERO).unwrap();
d.database.transaction(|_| {
assert!(matches!(server.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate),Err(rusqlite::Error::SqliteFailure(e,_)) if e.code==rusqlite::ErrorCode::DatabaseBusy));
Ok(())
}).unwrap();
server
.execute(
"UPDATE workspaces SET state='deleting' WHERE workspace_id='ws'",
[],
)
.unwrap();
// No separate Drive::fence call needed, including for already issued handles.
assert!(matches!(
d.mutate("after", "actor", create(root.id, "after")),
Err(Error::Fenced)
));
assert!(matches!(
d.read(child.id, child.revision, 0, 100),
Err(Error::Fenced)
));
assert!(matches!(d.collect(None, 10), Err(Error::Fenced)));
d.purge().unwrap();
}
#[test]
fn restarted_collection_removes_crashed_staging_without_removing_referenced_hardlink() {
let t = tempfile::tempdir().unwrap();
let d = fixture(&t);
let root = d.root().unwrap();
let n = d
.mutate("published", "actor", create(root.id, "file"))
.unwrap()
.node;
let key: String = d
.database
.with_connection(|c| {
Ok(c.query_row(
"SELECT blob_key FROM drive_nodes WHERE id=?1",
[n.id.0],
|r| r.get(0),
)?)
})
.unwrap();
let blobs = t.path().join("blobs");
std::fs::hard_link(blobs.join(&key), blobs.join(format!("{key}#1"))).unwrap();
std::fs::write(
blobs.join(format!("{}#1", Uuid::now_v7())),
b"partial upload",
)
.unwrap();
drop(d);
let d = fixture(&t);
assert_eq!(d.collect(None, 200).unwrap().removed, 2);
assert_eq!(
d.read(n.id, n.revision, 0, 100).unwrap().bytes,
b"persisted"
);
assert_eq!(std::fs::read_dir(blobs).unwrap().count(), 1);
}
#[test]
fn interrupted_full_permission_and_sync_failed_uploads_do_not_publish_metadata_or_receipt() {
for fault in [
blob::TestFault::PartialStagingWrite,
blob::TestFault::UploadNoSpace,
blob::TestFault::UploadPermissionDenied,
blob::TestFault::FileSync,
blob::TestFault::DirectorySync,
] {
let t = tempfile::tempdir().unwrap();
let mut d = fixture(&t);
let root = d.root().unwrap();
let original = d
.mutate("original", "actor", create(root.id, "file"))
.unwrap()
.node;
d.blobs = Arc::new(
blob::BlobStore::open(&t.path().join("blobs"))
.unwrap()
.with_fault(fault),
);
let mutation = Mutation::Update {
id: original.id,
expected_revision: original.revision,
content_type: "text/plain".into(),
bytes: b"failed replacement".to_vec(),
};
assert!(
matches!(
d.mutate("failed", "actor", mutation.clone()),
Err(Error::Blob(_))
),
"fault {fault:?}"
);
assert_eq!(
d.metadata(original.id).unwrap(),
original,
"fault {fault:?}"
);
assert_eq!(
d.request_status("failed").unwrap(),
RequestStatus::Uncommitted,
"fault {fault:?}"
);
d.blobs = Arc::new(blob::BlobStore::open(&t.path().join("blobs")).unwrap());
d.collect(None, 200).unwrap();
assert_eq!(
d.read(original.id, original.revision, 0, 100)
.unwrap()
.bytes,
b"persisted"
);
assert_eq!(
std::fs::read_dir(t.path().join("blobs")).unwrap().count(),
1
);
let replaced = d.mutate("failed", "actor", mutation).unwrap().node;
assert_eq!(
d.read(replaced.id, replaced.revision, 0, 100)
.unwrap()
.bytes,
b"failed replacement"
);
}
}
fn grant_fixture(t: &tempfile::TempDir) -> (Drive, Connection) {
let authority = t.path().join("server.db");
let server = Connection::open(&authority).unwrap();
feature_storage::configure_connection(&server).unwrap();
server
.execute_batch(
"CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY,state TEXT NOT NULL);
INSERT INTO workspaces VALUES ('ws','active');
CREATE TABLE grants(write_access INTEGER NOT NULL,revoked INTEGER NOT NULL);
INSERT INTO grants VALUES (1,0);",
)
.unwrap();
let storage = feature_storage::FeatureStorage::new(t.path().join("metadata"))
.workspace("ws")
.unwrap();
let registration = Drive::register(&storage).unwrap();
(
Drive::open_with_workspace_authority(
&storage,
&registration,
&t.path().join("blobs"),
&authority,
)
.unwrap(),
server,
)
}
fn grant_authorizer(conn: &Connection, write: bool) -> Result<()> {
assert!(
!conn.is_autocommit(),
"authority must be checked in the operation transaction"
);
let allowed: bool = conn.query_row(
"SELECT revoked=0 AND (?1=0 OR write_access=1) FROM yoi_workspace_authority.grants",
[write],
|r| r.get(0),
)?;
if allowed { Ok(()) } else { Err(Error::Denied) }
}
#[test]
fn bound_authorizer_checks_every_read_write_and_receipt_replay_without_caching() {
let t = tempfile::tempdir().unwrap();
let (raw, server) = grant_fixture(&t);
let root = raw.root().unwrap();
let bound = raw.with_authorizer(grant_authorizer);
let mutation = create(root.id, "file");
let result = bound.mutate("request", "worker", mutation.clone()).unwrap();
bound.check_authorized(true).unwrap();
server
.execute("UPDATE grants SET write_access=0", [])
.unwrap();
assert!(bound.check_authorized(false).is_ok());
assert!(matches!(bound.check_authorized(true), Err(Error::Denied)));
assert!(bound.root().is_ok());
assert!(bound.metadata(result.node.id).is_ok());
assert!(bound.list(root.id, None, 10).is_ok());
assert!(bound.search("file", true, None, 10).is_ok());
assert!(
bound
.read(result.node.id, result.node.revision, 0, 100)
.is_ok()
);
assert!(
bound
.read_text(result.node.id, result.node.revision, 0, 100)
.is_ok()
);
assert!(bound.request_status("request").is_ok());
assert!(
matches!(
bound.mutate("request", "worker", mutation.clone()),
Err(Error::Denied)
),
"read-only receipt replay is a write request"
);
let clone = bound.clone();
server.execute("UPDATE grants SET revoked=1", []).unwrap();
assert!(matches!(clone.check_authorized(false), Err(Error::Denied)));
assert!(matches!(clone.check_authorized(true), Err(Error::Denied)));
assert!(matches!(clone.root(), Err(Error::Denied)));
assert!(matches!(clone.metadata(result.node.id), Err(Error::Denied)));
assert!(matches!(clone.list(root.id, None, 10), Err(Error::Denied)));
assert!(matches!(
clone.search("file", true, None, 10),
Err(Error::Denied)
));
assert!(matches!(
clone.read(result.node.id, result.node.revision, 0, 100),
Err(Error::Denied)
));
assert!(matches!(
clone.read_text(result.node.id, result.node.revision, 0, 100),
Err(Error::Denied)
));
assert!(matches!(
clone.request_status("request"),
Err(Error::Denied)
));
assert!(matches!(
clone.mutate("request", "worker", mutation),
Err(Error::Denied)
));
assert!(matches!(
clone.mutate("denied", "worker", create(root.id, "denied")),
Err(Error::Denied)
));
assert_eq!(
raw.blobs.list(None, 10).unwrap().len(),
1,
"denied write must not publish a blob"
);
assert_eq!(
raw.request_status("denied").unwrap(),
RequestStatus::Uncommitted
);
assert_eq!(raw.list(root.id, None, 10).unwrap().nodes.len(), 1);
raw.collect(None, 10).unwrap();
}
#[test]
fn revoke_serializes_with_publication_and_cached_handle_denies_after_revoke_commit() {
use std::sync::{Mutex, mpsc};
use std::time::Duration;
let t = tempfile::tempdir().unwrap();
let (raw, mut server) = grant_fixture(&t);
let root = raw.root().unwrap();
let (entered_tx, entered_rx) = mpsc::channel();
let (release_tx, release_rx) = mpsc::channel();
let release_rx = Mutex::new(release_rx);
let publishing = raw.with_authorizer(move |conn, write| {
grant_authorizer(conn, write)?;
entered_tx.send(()).unwrap();
release_rx
.lock()
.unwrap()
.recv_timeout(Duration::from_secs(5))
.unwrap();
Ok(())
});
let cached = raw.with_authorizer(grant_authorizer);
let writer = std::thread::spawn(move || {
publishing.mutate("publishing", "worker", create(root.id, "published"))
});
entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
server.busy_timeout(Duration::ZERO).unwrap();
assert!(
matches!(server.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate),
Err(rusqlite::Error::SqliteFailure(e,_)) if e.code==rusqlite::ErrorCode::DatabaseBusy),
"revoke cannot acquire authority while mutation publishes"
);
release_tx.send(()).unwrap();
let published = writer.join().unwrap().unwrap();
let revoke = server
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
.unwrap();
revoke.execute("UPDATE grants SET revoked=1", []).unwrap();
revoke.commit().unwrap();
assert_eq!(raw.metadata(published.node.id).unwrap(), published.node);
assert_eq!(
raw.request_status("publishing").unwrap(),
RequestStatus::Committed {
result: published.clone()
}
);
assert!(matches!(
cached.request_status("publishing"),
Err(Error::Denied)
));
assert!(matches!(
cached.mutate("publishing", "worker", create(root.id, "published")),
Err(Error::Denied)
));
assert!(matches!(
cached.mutate("after-revoke", "worker", create(root.id, "denied")),
Err(Error::Denied)
));
assert_eq!(raw.blobs.list(None, 10).unwrap().len(), 1);
}
+618
View File
@@ -0,0 +1,618 @@
use feature_storage::FeatureStorage;
use std::{
path::Path,
sync::{Arc, Barrier},
};
use workspace_drive::{
Drive, Error, Kind, MAX_FILE_BYTES, MAX_READ_BYTES, Mutation, Node, RequestStatus,
};
fn open(metadata: &Path, blobs: &Path, workspace: &str) -> Drive {
let storage = FeatureStorage::new(metadata).workspace(workspace).unwrap();
let registered = Drive::register(&storage).unwrap();
Drive::open(&storage, &registered, blobs).unwrap()
}
fn folder(d: &Drive, parent: &Node, name: &str) -> Node {
d.mutate(
name,
"account",
Mutation::CreateFolder {
parent: parent.id,
name: name.into(),
},
)
.unwrap()
.node
}
fn file(
d: &Drive,
parent: &Node,
request: &str,
name: &str,
content_type: &str,
bytes: &[u8],
) -> Node {
d.mutate(
request,
"account",
Mutation::CreateFile {
parent: parent.id,
name: name.into(),
content_type: content_type.into(),
bytes: bytes.to_vec(),
},
)
.unwrap()
.node
}
fn read(d: &Drive, n: &Node) -> Vec<u8> {
d.read(n.id, n.revision, 0, MAX_READ_BYTES).unwrap().bytes
}
fn update(n: &Node, bytes: &[u8]) -> Mutation {
Mutation::Update {
id: n.id,
expected_revision: n.revision,
content_type: "text/markdown".into(),
bytes: bytes.to_vec(),
}
}
#[test]
fn markdown_image_empty_and_folders_survive_restart_with_stable_identity() {
let t = tempfile::tempdir().unwrap();
let db = t.path().join("metadata");
let blobs = t.path().join("drives/ws/blobs");
let d = open(&db, &blobs, "ws");
let root = d.root().unwrap();
let dir = folder(&d, &root, "日本語");
let markdown = file(
&d,
&dir,
"markdown",
"資料.md",
"text/markdown",
"# 日本語\n".as_bytes(),
);
let image = file(
&d,
&dir,
"image",
"画像.png",
"image/png",
b"\x89PNG\r\n\x1a\n",
);
let empty = file(&d, &dir, "empty", "空", "text/plain", b"");
assert_eq!(dir.kind, Kind::Directory);
assert!(read(&d, &empty).is_empty());
let latest = d
.mutate("update", "account", update(&markdown, b"# latest"))
.unwrap()
.node;
drop(d);
let d = open(&db, &blobs, "ws");
assert_eq!(d.root().unwrap(), root);
assert_eq!(d.metadata(dir.id).unwrap(), dir);
assert_eq!(d.metadata(latest.id).unwrap(), latest);
assert_eq!(read(&d, &latest), b"# latest");
assert_eq!(read(&d, &image), b"\x89PNG\r\n\x1a\n");
assert_eq!(d.list(dir.id, None, 200).unwrap().nodes.len(), 3);
}
#[test]
fn relocate_and_paged_list_only_use_metadata_even_when_blob_is_missing() {
let t = tempfile::tempdir().unwrap();
let blobs = t.path().join("blobs");
let d = open(&t.path().join("db"), &blobs, "ws");
let root = d.root().unwrap();
let a = folder(&d, &root, "a");
let b = folder(&d, &root, "b");
let f = file(&d, &a, "f", "f", "text/plain", b"data");
let before = std::fs::read_dir(&blobs)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect::<Vec<_>>();
let moved = d
.mutate(
"move",
"account",
Mutation::Relocate {
id: f.id,
expected_revision: f.revision,
parent: b.id,
name: "改名".into(),
},
)
.unwrap()
.node;
let after = std::fs::read_dir(&blobs)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect::<Vec<_>>();
assert_eq!(before, after);
assert_eq!(read(&d, &moved), b"data");
std::fs::remove_file(blobs.join(&before[0])).unwrap();
assert_eq!(d.list(b.id, None, 1).unwrap().nodes, vec![moved.clone()]);
assert!(matches!(
d.read(moved.id, moved.revision, 0, 1),
Err(Error::Blob(_))
));
let first = d.list(root.id, None, 1).unwrap();
assert_eq!(first.nodes, vec![a]);
let second = d.list(root.id, first.next_after, 1).unwrap();
assert_eq!(second.nodes, vec![b]);
assert!(second.next_after.is_none());
}
#[test]
fn parallel_revision_and_name_competitors_use_database_authority() {
let t = tempfile::tempdir().unwrap();
let db = t.path().join("db");
let blobs = t.path().join("blobs");
let d = open(&db, &blobs, "ws");
let other = open(&db, &blobs, "ws");
let root = d.root().unwrap();
let f = file(&d, &root, "original", "f", "text/plain", b"old");
let barrier = Arc::new(Barrier::new(2));
let b = barrier.clone();
let f2 = f.clone();
let c = other.clone();
let worker = std::thread::spawn(move || {
b.wait();
c.mutate("update-two", "account", update(&f2, b"two"))
});
barrier.wait();
let one = d.mutate("update-one", "account", update(&f, b"one"));
let two = worker.join().unwrap();
assert_eq!(usize::from(one.is_ok()) + usize::from(two.is_ok()), 1);
assert!(matches!(one, Err(Error::Conflict)) || matches!(two, Err(Error::Conflict)));
let b = Arc::new(Barrier::new(2));
let bb = b.clone();
let r = root.clone();
let worker = std::thread::spawn(move || {
bb.wait();
other.mutate(
"same-two",
"account",
Mutation::CreateFolder {
parent: r.id,
name: "same".into(),
},
)
});
b.wait();
let one = d.mutate(
"same-one",
"account",
Mutation::CreateFolder {
parent: root.id,
name: "same".into(),
},
);
let two = worker.join().unwrap();
assert_eq!(usize::from(one.is_ok()) + usize::from(two.is_ok()), 1);
assert!(matches!(one, Err(Error::Conflict)) || matches!(two, Err(Error::Conflict)));
}
#[test]
fn concurrent_cycle_moves_and_parent_delete_child_create_preserve_tree() {
let t = tempfile::tempdir().unwrap();
let db = t.path().join("db");
let blobs = t.path().join("blobs");
let d = open(&db, &blobs, "ws");
let other = open(&db, &blobs, "ws");
let root = d.root().unwrap();
let a = folder(&d, &root, "a");
let b = folder(&d, &root, "b");
let barrier = Arc::new(Barrier::new(2));
let bb = barrier.clone();
let aa = a.clone();
let b2 = b.clone();
let c = other.clone();
let worker = std::thread::spawn(move || {
bb.wait();
c.mutate(
"b-to-a",
"account",
Mutation::Relocate {
id: b2.id,
expected_revision: b2.revision,
parent: aa.id,
name: "b".into(),
},
)
});
barrier.wait();
let one = d.mutate(
"a-to-b",
"account",
Mutation::Relocate {
id: a.id,
expected_revision: a.revision,
parent: b.id,
name: "a".into(),
},
);
let two = worker.join().unwrap();
assert_eq!(usize::from(one.is_ok()) + usize::from(two.is_ok()), 1);
let p = folder(&d, &root, "parent");
let p2 = p.clone();
let barrier = Arc::new(Barrier::new(2));
let bb = barrier.clone();
let worker = std::thread::spawn(move || {
bb.wait();
other.mutate(
"create-child",
"account",
Mutation::CreateFolder {
parent: p2.id,
name: "child".into(),
},
)
});
barrier.wait();
let deleted = d.mutate(
"delete-parent",
"account",
Mutation::Delete {
id: p.id,
expected_revision: p.revision,
},
);
let created = worker.join().unwrap();
assert_eq!(
usize::from(deleted.is_ok()) + usize::from(created.is_ok()),
1
);
if created.is_ok() {
assert_eq!(d.list(p.id, None, 10).unwrap().nodes.len(), 1);
} else {
assert!(matches!(d.metadata(p.id), Err(Error::NotFound)));
}
}
#[test]
fn receipts_survive_response_loss_and_restart_without_double_mutation() {
let t = tempfile::tempdir().unwrap();
let db = t.path().join("db");
let blobs = t.path().join("blobs");
let d = open(&db, &blobs, "ws");
let root = d.root().unwrap();
let m = Mutation::CreateFile {
parent: root.id,
name: "lost-response".into(),
content_type: "text/plain".into(),
bytes: b"first".to_vec(),
};
assert!(matches!(
d.request_status("create").unwrap(),
RequestStatus::Uncommitted
));
let committed = d.mutate("create", "account", m.clone()).unwrap();
drop(d);
let d = open(&db, &blobs, "ws");
assert_eq!(
d.request_status("create").unwrap(),
RequestStatus::Committed {
result: committed.clone()
}
);
assert_eq!(d.mutate("create", "account", m.clone()).unwrap(), committed);
assert!(matches!(
d.mutate("create", "someone-else", m),
Err(Error::Conflict)
));
let update = update(&committed.node, b"second");
let updated = d.mutate("update", "account", update.clone()).unwrap();
assert_eq!(d.mutate("update", "account", update).unwrap(), updated);
assert_eq!(d.list(root.id, None, 20).unwrap().nodes.len(), 1);
}
#[test]
fn deletion_recreation_does_not_reuse_id_or_silently_switch_read_revision() {
let t = tempfile::tempdir().unwrap();
let d = open(&t.path().join("db"), &t.path().join("blobs"), "ws");
let root = d.root().unwrap();
let old = file(&d, &root, "old", "file", "text/plain", b"old");
let next = d
.mutate("update", "account", update(&old, b"new"))
.unwrap()
.node;
assert!(matches!(
d.read(old.id, old.revision, 0, 1),
Err(Error::Conflict)
));
d.mutate(
"delete",
"account",
Mutation::Delete {
id: next.id,
expected_revision: next.revision,
},
)
.unwrap();
let recreated = file(&d, &root, "recreated", "file", "text/plain", b"recreated");
assert!(recreated.id > old.id);
assert!(matches!(d.metadata(old.id), Err(Error::NotFound)));
assert_eq!(read(&d, &recreated), b"recreated");
assert!(d.collect(None, 200).unwrap().removed >= 2);
assert_eq!(read(&d, &recreated), b"recreated");
}
#[test]
fn bounded_search_and_read_reject_paths_oversize_and_invalid_parents() {
let t = tempfile::tempdir().unwrap();
let d = open(&t.path().join("db"), &t.path().join("blobs"), "ws");
let root = d.root().unwrap();
for name in ["../escape", "a/b", "a\\b", ".", "..", "", "bad\0name"] {
assert!(matches!(
d.mutate(
&uuid::Uuid::now_v7().to_string(),
"account",
Mutation::CreateFolder {
parent: root.id,
name: name.into()
}
),
Err(Error::Invalid(_))
));
}
let f = file(
&d,
&root,
"f",
"f",
"text/markdown",
"日本語 needle".as_bytes(),
);
let g = file(&d, &root, "g", "needle.png", "image/png", b"needle");
assert!(matches!(
d.read(f.id, f.revision, 0, MAX_READ_BYTES + 1),
Err(Error::Invalid(_))
));
assert!(matches!(
d.read(f.id, f.revision, f.size + 1, 1),
Err(Error::Invalid(_))
));
assert!(matches!(
d.mutate("huge", "account", update(&f, &vec![0; MAX_FILE_BYTES + 1])),
Err(Error::Invalid(_))
));
assert!(matches!(d.list(f.id, None, 10), Err(Error::Invalid(_))));
assert!(matches!(
d.mutate(
"invalid-parent",
"account",
Mutation::CreateFolder {
parent: f.id,
name: "no".into()
}
),
Err(Error::Invalid(_))
));
let first = d.search("needle", true, None, 1).unwrap();
assert_eq!(first.nodes, vec![f]);
assert_eq!(first.examined, 1);
let second = d.search("needle", true, first.next_after, 1).unwrap();
assert_eq!(second.nodes, vec![g]);
assert!(second.next_after.is_none());
assert!(
d.search("missing", true, None, 1)
.unwrap()
.next_after
.is_some()
);
}
#[test]
fn fence_purge_restart_and_workspace_boundaries_are_persistent() {
let t = tempfile::tempdir().unwrap();
let db = t.path().join("db");
let blobs = t.path().join("drives/a/blobs");
let a = open(&db, &blobs, "a");
let b = open(&db, &t.path().join("drives/b/blobs"), "b");
let ar = a.root().unwrap();
let br = b.root().unwrap();
let af = file(&a, &ar, "af", "f", "text/plain", b"a");
let bf = file(&b, &br, "bf", "f", "text/plain", b"b");
let ad = folder(&a, &ar, "folder");
file(&a, &ad, "child", "child", "text/plain", b"child");
a.fence().unwrap();
drop(a);
let a = open(&db, &blobs, "a");
assert!(matches!(
a.mutate("after-fence", "account", update(&af, b"bad")),
Err(Error::Fenced)
));
assert!(matches!(a.collect(None, 1), Err(Error::Fenced)));
a.purge().unwrap();
a.purge().unwrap();
assert!(a.list(ar.id, None, 10).unwrap().nodes.is_empty());
assert_eq!(std::fs::read_dir(&blobs).unwrap().count(), 0);
assert_eq!(read(&b, &bf), b"b");
assert_eq!(a.root().unwrap().workspace_id, "a");
assert_eq!(b.root().unwrap().workspace_id, "b");
}
#[test]
fn stopped_writer_backup_restores_ids_revisions_receipts_and_sequence_with_blobs() {
let t = tempfile::tempdir().unwrap();
let metadata = t.path().join("metadata");
let blobs = t.path().join("blobs");
let manager = FeatureStorage::new(&metadata);
let scope = manager.workspace("ws").unwrap();
let registered = Drive::register(&scope).unwrap();
let d = Drive::open(&scope, &registered, &blobs).unwrap();
let root = d.root().unwrap();
let f = file(&d, &root, "f", "file", "text/plain", b"one");
let latest = d
.mutate("update", "account", update(&f, b"two"))
.unwrap()
.node;
let deleted = folder(&d, &root, "deleted");
d.mutate(
"delete",
"account",
Mutation::Delete {
id: deleted.id,
expected_revision: deleted.revision,
},
)
.unwrap();
// All writers/GC stopped at this boundary. FeatureStorage's DB snapshot alone
// is not a Drive backup; copy the same stopped generation's blobs as well.
drop(d);
let snapshot = t.path().join("snapshot");
scope.backup(&snapshot).unwrap();
manager.shutdown().unwrap();
let restored_blobs = t.path().join("restored/blobs");
std::fs::create_dir_all(&restored_blobs).unwrap();
for file in std::fs::read_dir(&blobs).unwrap() {
let file = file.unwrap();
std::fs::copy(file.path(), restored_blobs.join(file.file_name())).unwrap();
}
let restored = FeatureStorage::new(t.path().join("restored/metadata"));
let scope = restored.workspace("ws").unwrap();
scope.restore(&snapshot).unwrap();
let registered = Drive::register(&scope).unwrap();
let d = Drive::open(&scope, &registered, &restored_blobs).unwrap();
assert_eq!(d.metadata(latest.id).unwrap(), latest);
assert_eq!(read(&d, &latest), b"two");
assert!(matches!(
d.request_status("update").unwrap(),
RequestStatus::Committed { .. }
));
let new = folder(&d, &root, "new");
assert!(new.id > deleted.id);
}
#[test]
fn external_ids_and_revisions_keep_full_integer_precision() {
let max = "9223372036854775807";
let id: workspace_drive::NodeId = serde_json::from_str(&format!("\"{max}\"")).unwrap();
let revision: workspace_drive::Revision = serde_json::from_str(&format!("\"{max}\"")).unwrap();
assert_eq!(serde_json::to_string(&id).unwrap(), format!("\"{max}\""));
assert_eq!(
serde_json::to_string(&revision).unwrap(),
format!("\"{max}\"")
);
for bad in ["0", "-1", "01", "9223372036854775808"] {
assert!(serde_json::from_str::<workspace_drive::NodeId>(&format!("\"{bad}\"")).is_err());
}
assert!(serde_json::from_str::<workspace_drive::NodeId>("9007199254740993").is_err());
}
#[test]
fn text_read_is_bounded_utf8_and_referenced_size_corruption_is_diagnosed() {
let t = tempfile::tempdir().unwrap();
let blobs = t.path().join("blobs");
let d = open(&t.path().join("db"), &blobs, "ws");
let root = d.root().unwrap();
let f = file(&d, &root, "f", "f", "text/plain", "日本語".as_bytes());
assert_eq!(d.read_text(f.id, f.revision, 0, 3).unwrap().text, "日");
assert!(matches!(
d.read_text(f.id, f.revision, 1, 2),
Err(Error::Invalid(_))
));
let path = std::fs::read_dir(&blobs)
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
std::fs::write(&path, b"corrupt length").unwrap();
assert!(matches!(
d.read(f.id, f.revision, 0, 1),
Err(Error::Invalid(_))
));
assert!(matches!(
d.search("not-the-name", true, None, 10),
Err(Error::Invalid(_))
));
// Missing zero-sized references are not silently successful empty reads.
let empty = file(&d, &root, "empty", "empty", "text/plain", b"");
for e in std::fs::read_dir(&blobs).unwrap() {
let e = e.unwrap();
if e.path() != path {
std::fs::remove_file(e.path()).unwrap();
}
}
assert!(matches!(
d.read(empty.id, empty.revision, 0, 1),
Err(Error::Blob(_))
));
}
#[test]
fn simultaneous_same_request_replays_one_create_and_folder_deletion_is_empty_only() {
let t = tempfile::tempdir().unwrap();
let db = t.path().join("db");
let blobs = t.path().join("blobs");
let d = open(&db, &blobs, "ws");
let other = open(&db, &blobs, "ws");
let root = d.root().unwrap();
let m = Mutation::CreateFolder {
parent: root.id,
name: "same".into(),
};
let mm = m.clone();
let barrier = Arc::new(Barrier::new(2));
let bb = barrier.clone();
let worker = std::thread::spawn(move || {
bb.wait();
other.mutate("same-request", "account", mm).unwrap()
});
barrier.wait();
let one = d.mutate("same-request", "account", m).unwrap();
let two = worker.join().unwrap();
assert_eq!(one, two);
assert_eq!(d.list(root.id, None, 10).unwrap().nodes.len(), 1);
let child = folder(&d, &one.node, "child");
assert!(matches!(
d.mutate(
"nonempty",
"account",
Mutation::Delete {
id: one.node.id,
expected_revision: one.node.revision
}
),
Err(Error::Conflict)
));
assert_eq!(d.metadata(child.id).unwrap(), child);
assert!(matches!(
d.mutate(
"root-delete",
"account",
Mutation::Delete {
id: root.id,
expected_revision: root.revision
}
),
Err(Error::Invalid(_))
));
let changed = d
.mutate(
"change",
"account",
Mutation::Relocate {
id: child.id,
expected_revision: child.revision,
parent: root.id,
name: "child".into(),
},
)
.unwrap()
.node;
assert!(matches!(
d.mutate(
"stale-delete",
"account",
Mutation::Delete {
id: child.id,
expected_revision: child.revision
}
),
Err(Error::Conflict)
));
assert_eq!(d.metadata(changed.id).unwrap(), changed);
}
+2
View File
@@ -11,12 +11,14 @@ name = "yoi-server"
path = "src/main.rs"
[dependencies]
api-macros = { workspace = true, features = ["axum"] }
async-trait.workspace = true
axum = { workspace = true, features = ["ws"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
futures.workspace = true
config-source.workspace = true
feature-storage.workspace = true
workspace-drive.workspace = true
subjektiv.workspace = true
flow = { path = "../flow" }
manifest.workspace = true
+151 -12
View File
@@ -750,9 +750,9 @@ impl SqliteWorkspaceAuthority {
assigned_at: assignment.assigned_at,
})
.collect::<Vec<_>>();
let current_coder = role_assignments
let current_worker = role_assignments
.iter()
.find(|assignment| assignment.role == TicketAssignmentRole::Coder)
.find(|assignment| assignment.role == TicketAssignmentRole::Worker)
.and_then(|assignment| {
assignment
.principal
@@ -779,9 +779,9 @@ impl SqliteWorkspaceAuthority {
let has_orchestrator = active_role_assignments
.iter()
.any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator);
let has_coder = active_role_assignments
let has_worker = active_role_assignments
.iter()
.any(|assignment| assignment.role == TicketAssignmentRole::Coder);
.any(|assignment| assignment.role == TicketAssignmentRole::Worker);
let targets = ticket
.meta
.targets
@@ -799,7 +799,6 @@ impl SqliteWorkspaceAuthority {
})
.collect::<Result<Vec<_>>>()?;
let write_repository_keys = ticket_write_target_repository_keys(&targets);
let has_target = !write_repository_keys.is_empty();
let has_blockers = !ticket.relations.blockers.is_empty();
let mut assignment_diagnostics = Vec::new();
if let Some(legacy_assignee) = ticket
@@ -818,17 +817,20 @@ impl SqliteWorkspaceAuthority {
ticket.meta.workflow_state,
TicketWorkflowState::Planning | TicketWorkflowState::Ready
) && !has_orchestrator
&& !has_coder,
&& !has_worker,
can_unassign_orchestrator: has_orchestrator
&& matches!(
ticket.meta.workflow_state,
TicketWorkflowState::Planning | TicketWorkflowState::Ready
),
can_queue: true,
can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready
&& !has_orchestrator
&& !has_coder
&& has_target
can_start_manual_worker: matches!(
ticket.meta.workflow_state,
TicketWorkflowState::Planning
| TicketWorkflowState::Ready
| TicketWorkflowState::Queued
| TicketWorkflowState::InProgress
) && !has_worker
&& !has_blockers,
queue_tickets: dependency_check.queue_tickets.clone(),
blockers: action_blockers,
@@ -960,7 +962,7 @@ impl SqliteWorkspaceAuthority {
linked_objectives,
implementation_reports,
assignments,
current_coder,
current_worker,
assignment_diagnostics,
action_eligibility,
merge_requests,
@@ -2353,7 +2355,7 @@ fn ticket_query_item(
evidence: detail.evidence.clone(),
merge_requests: detail.merge_requests.clone(),
merge_request: detail.merge_request.clone(),
current_coder: detail.current_coder.clone(),
current_worker: detail.current_worker.clone(),
}
}
@@ -3193,6 +3195,143 @@ mod tests {
);
}
#[tokio::test]
async fn manual_worker_eligibility_accepts_explicit_states_without_reviving_released_work() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("workspace.db");
let store = SqliteWorkspaceStore::open(&path).unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "w".into(),
owner_account_id: "owner-account".into(),
display_name: "W".into(),
state: "active".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.await
.unwrap();
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch("PRAGMA foreign_keys=ON;").unwrap();
store
.upsert_worker_registry(&crate::store::WorkerRegistryRecord {
workspace_id: "w".into(),
worker: worker_runtime::identity::RuntimeWorkerRef::new("runtime", "worker"),
display_name: "Worker".into(),
profile: Some("builtin:default".into()),
retention_state: "normal".into(),
transcript_ref: None,
session_ref: None,
summary_ref: None,
diagnostics_ref: None,
created_at: "1".into(),
updated_at: "1".into(),
})
.unwrap();
let backend = SqliteTicketBackend::open_verified(&path, "w").unwrap();
let ticket = backend
.create(ticket::NewTicket::new("Explicit work"))
.unwrap();
let authority = SqliteWorkspaceAuthority::new(&path, "w").unwrap();
for (state, allowed) in [
("planning", true),
("ready", true),
("queued", true),
("inprogress", true),
("done", false),
("closed", false),
] {
conn.execute(
"UPDATE typed_tickets SET workflow_state=?1 WHERE ticket_id=?2",
rusqlite::params![state, ticket.id],
)
.unwrap();
assert_eq!(
authority
.ticket(&ticket.id)
.unwrap()
.action_eligibility
.can_start_manual_worker,
allowed,
"{state}"
);
}
conn.execute(
"UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id=?1",
[&ticket.id],
)
.unwrap();
let record = crate::store::TicketRoleAssignmentRecord {
workspace_id: "w".into(),
ticket_id: ticket.id.clone(),
assignment_id: "assignment".into(),
role: TicketAssignmentRole::Worker,
principal: TicketAssignmentPrincipal::Worker {
runtime_id: "runtime".into(),
worker_id: "worker".into(),
},
assigned_by: "owner-account".into(),
assigned_at: "2".into(),
};
let orchestrator = crate::store::TicketRoleAssignmentRecord {
role: TicketAssignmentRole::Orchestrator,
assignment_id: "orchestrator".into(),
principal: TicketAssignmentPrincipal::WorkspaceAgent {
agent_key: "workspace-orchestrator".into(),
},
..record.clone()
};
store
.set_current_ticket_role_assignment(
&orchestrator,
None,
"orchestrator-event",
"orchestrator-op",
false,
)
.unwrap();
assert!(
authority
.ticket(&ticket.id)
.unwrap()
.action_eligibility
.can_start_manual_worker
);
store
.start_ticket_with_worker_assignment(&record, "event", "operation", None)
.unwrap();
assert!(
!authority
.ticket(&ticket.id)
.unwrap()
.action_eligibility
.can_start_manual_worker
);
conn.execute(
"UPDATE typed_tickets SET workflow_state='closed' WHERE ticket_id=?1",
[&ticket.id],
)
.unwrap();
conn.execute(
"UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id=?1",
[&ticket.id],
)
.unwrap();
let detail = authority.ticket(&ticket.id).unwrap();
assert!(detail.action_eligibility.can_start_manual_worker);
assert_eq!(detail.assignments.len(), 2);
let worker = detail
.assignments
.iter()
.find(|a| a.role == "worker")
.unwrap();
assert_eq!(worker.assignment_id, record.assignment_id);
assert_eq!(
detail.current_worker.unwrap().assignment_id,
record.assignment_id
);
}
#[tokio::test]
async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() {
let dir = tempfile::tempdir().unwrap();
+1 -4
View File
@@ -104,13 +104,10 @@ impl ResolvedWorkspaceBackendConfig {
host_config: &ServerHostConfigFile,
) -> Result<Self> {
let workspace_root = workspace_root.as_ref();
let data_root = ServerConfig::default_workspace_backend_data_root(&workspace.workspace_id);
let database_path = ServerConfig::default_server_database_path();
let (browser_public_url, browser_rp_id) =
resolve_browser_public_url(&host_config.browser.public_url)?;
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), workspace);
server.database_path = database_path.clone();
server.embedded_runtime_store_root = data_root.join("embedded-runtime");
let database_path = server.database_path.clone();
server.max_records = DEFAULT_MAX_RECORDS;
server.remote_runtime_sources = Vec::new();
server.auth = AuthConfig::Passkey {
+27 -6
View File
@@ -484,7 +484,7 @@ pub(crate) fn worker_spawn_idempotency(
let Some(assignment) = request.ticket_assignment.as_ref() else {
return Ok(None);
};
let encoded = serde_json::to_vec(request)
let encoded = serde_json::to_vec(&(request, request.resolved_workdir_attachments.as_slice()))
.map_err(|error| format!("serialize Worker spawn idempotency input: {error}"))?;
Ok(Some((
assignment.operation_id.clone(),
@@ -578,7 +578,7 @@ pub enum WorkerSpawnIntent {
pub enum TicketWorkerRole {
Intake,
Orchestrator,
Coder,
Worker,
Reviewer,
}
@@ -6457,7 +6457,7 @@ fn worker_spawn_intent_label(intent: &WorkerSpawnIntent) -> &'static str {
WorkerSpawnIntent::TicketRole { role, .. } => match role {
TicketWorkerRole::Intake => "ticket_intake",
TicketWorkerRole::Orchestrator => "ticket_orchestrator",
TicketWorkerRole::Coder => "ticket_coder",
TicketWorkerRole::Worker => "ticket_worker",
TicketWorkerRole::Reviewer => "ticket_reviewer",
},
}
@@ -6524,6 +6524,27 @@ mod tests {
use std::sync::{Arc, Mutex};
use std::thread;
#[test]
fn ticket_worker_spawn_intent_uses_worker_wire_role() {
let intent = WorkerSpawnIntent::TicketRole {
ticket_id: "ticket".into(),
role: TicketWorkerRole::Worker,
};
let encoded = json!({"kind": "ticket_role", "ticket_id": "ticket", "role": "worker"});
assert_eq!(serde_json::to_value(&intent).unwrap(), encoded);
assert_eq!(
serde_json::from_value::<WorkerSpawnIntent>(encoded).unwrap(),
intent
);
assert!(
serde_json::from_value::<WorkerSpawnIntent>(
json!({"kind": "ticket_role", "ticket_id": "ticket", "role": "coder"})
)
.is_err()
);
assert_eq!(worker_spawn_intent_label(&intent), "ticket_worker");
}
#[test]
fn remote_restore_transport_failure_requires_reconciliation() {
let provider = RemoteWorkerRuntime::new(
@@ -7541,7 +7562,7 @@ mod tests {
WorkerSpawnRequest {
intent: WorkerSpawnIntent::TicketRole {
ticket_id: "00001KVZSGT0Q".to_string(),
role: TicketWorkerRole::Coder,
role: TicketWorkerRole::Worker,
},
requested_worker_name: None,
singleton_key: None,
@@ -7830,7 +7851,7 @@ mod tests {
WorkerSpawnRequest {
intent: WorkerSpawnIntent::TicketRole {
ticket_id: "00001KVZSGT0Q".to_string(),
role: TicketWorkerRole::Coder,
role: TicketWorkerRole::Worker,
},
requested_worker_name: Some("friendly-name-is-not-authority".to_string()),
singleton_key: None,
@@ -7942,7 +7963,7 @@ mod tests {
WorkerSpawnRequest {
intent: WorkerSpawnIntent::TicketRole {
ticket_id: "00001KVZSGT0Q".to_string(),
role: TicketWorkerRole::Coder,
role: TicketWorkerRole::Worker,
},
requested_worker_name: None,
singleton_key: None,
+41 -10
View File
@@ -335,8 +335,8 @@ CREATE TABLE ticket_assignment_operations (
operation_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')),
ticket_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'coder'
CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')),
role TEXT NOT NULL DEFAULT 'worker'
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
principal_kind TEXT NOT NULL DEFAULT 'worker'
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
principal_id TEXT,
@@ -346,8 +346,14 @@ CREATE TABLE ticket_assignment_operations (
expected_assignment_id TEXT,
created_at TEXT NOT NULL,
request_fingerprint TEXT,
claim_state TEXT NOT NULL DEFAULT 'committed'
CHECK(claim_state IN ('pending', 'failed', 'committed')),
failure_reason TEXT,
binding_recovery_json TEXT,
PRIMARY KEY (workspace_id, operation_id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
CHECK(claim_state = 'committed' OR assignment_id IS NULL),
CHECK((claim_state = 'failed') = (failure_reason IS NOT NULL))
);
CREATE TABLE ticket_assignment_ticket_tombstones (
workspace_id TEXT NOT NULL,
@@ -367,8 +373,8 @@ CREATE TABLE ticket_assignment_worker_tombstones (
CREATE TABLE ticket_current_worker_assignments (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'coder'
CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')),
role TEXT NOT NULL DEFAULT 'worker'
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
assignment_id TEXT NOT NULL,
principal_kind TEXT NOT NULL DEFAULT 'worker'
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
@@ -393,8 +399,8 @@ CREATE TABLE ticket_current_worker_assignments (
CREATE TABLE ticket_worker_assignment_events (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'coder'
CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')),
role TEXT NOT NULL DEFAULT 'worker'
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
event_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('assigned', 'reassigned', 'unassigned')),
assignment_id TEXT,
@@ -410,8 +416,8 @@ CREATE TABLE ticket_worker_assignments (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
assignment_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'coder'
CHECK(role IN ('orchestrator', 'coder', 'owner', 'contributor')),
role TEXT NOT NULL DEFAULT 'worker'
CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')),
principal_kind TEXT NOT NULL DEFAULT 'worker'
CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')),
principal_id TEXT,
@@ -1215,7 +1221,7 @@ CREATE INDEX ticket_current_principal_idx
ON ticket_current_worker_assignments(workspace_id, principal_kind, principal_id, runtime_id, worker_id);
CREATE UNIQUE INDEX ticket_current_singleton_role_idx
ON ticket_current_worker_assignments(workspace_id, ticket_id, role)
WHERE role IN ('orchestrator', 'coder');
WHERE role IN ('orchestrator', 'worker');
CREATE INDEX typed_ticket_events_workspace_kind_ticket
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id
@@ -1623,3 +1629,28 @@ CREATE TABLE workspace_config_grants (
);
CREATE UNIQUE INDEX workspace_config_grants_active_worker
ON workspace_config_grants(workspace_id, runtime_id, worker_id) WHERE revoked = 0;
CREATE TABLE workspace_drive_grants (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
runtime_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')),
revoked INTEGER NOT NULL DEFAULT 0 CHECK(revoked IN (0, 1)),
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
revoked_by TEXT,
revoked_at TEXT,
CHECK((revoked=0 AND revoked_by IS NULL AND revoked_at IS NULL)
OR (revoked=1 AND revoked_by IS NOT NULL AND revoked_at IS NOT NULL))
);
CREATE UNIQUE INDEX workspace_drive_grants_active_worker
ON workspace_drive_grants(workspace_id, runtime_id, worker_id) WHERE revoked=0;
-- Retain grant audit across Worker deletion, but never carry its authority into
-- a later lifetime reusing the same Runtime/Worker identity.
CREATE TRIGGER workspace_drive_grants_worker_deleted BEFORE DELETE ON worker_registry BEGIN
UPDATE workspace_drive_grants SET revoked=1, revoked_by='server:worker-removal',
revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE workspace_id=OLD.workspace_id AND runtime_id=OLD.runtime_id
AND worker_id=OLD.worker_id AND revoked=0;
END;
+3
View File
@@ -36,6 +36,7 @@ mod worker_projection;
pub mod worker_source;
pub mod workspace_catalog;
mod workspace_deletion;
mod workspace_drive_host;
pub mod workspace_signing_identity;
mod workspace_subscription;
@@ -151,6 +152,8 @@ pub enum Error {
WorkerSourceIdentity(String),
#[error("Workspace signing identity error ({code}): {message}")]
WorkspaceSigningIdentity { code: String, message: String },
#[error("Workspace Drive error: {0}")]
Drive(#[from] workspace_drive::Error),
#[error("store error: {0}")]
Store(String),
}
+47 -8
View File
@@ -69,6 +69,10 @@ pub struct WorkerRemovalPlanRequest {
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WorkerRemovalBlocker {
Hold,
PendingTicketClaim {
operation_id: String,
ticket_id: String,
},
UnfinishedWork {
assignment_id: String,
ticket_id: String,
@@ -232,6 +236,9 @@ impl SqliteWorkspaceStore {
if let Some((assignment_id,ticket_id))=tx.query_row("SELECT a.assignment_id,a.ticket_id FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? {
blockers.push(WorkerRemovalBlocker::UnfinishedWork{assignment_id,ticket_id});
}
if let Some((operation_id,ticket_id))=tx.query_row("SELECT operation_id,ticket_id FROM ticket_assignment_operations WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND claim_state='pending' AND action IN ('assign','reassign')",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? {
blockers.push(WorkerRemovalBlocker::PendingTicketClaim{operation_id,ticket_id});
}
let fp=fingerprint(req,&worker.updated_at,inv,&policy,&blockers)?;
let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp);
let archive_id=(policy.session_disposition==SessionDisposition::Archive).then(||stable("wra",&fp));
@@ -276,7 +283,7 @@ impl SqliteWorkspaceStore {
mark_stale(&tx,&plan,"hold added")?;tx.commit()?;
return Err(stale_error(&plan,"hold added"));
}
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|r|r.get(0))?;
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3 UNION ALL SELECT 1 FROM ticket_assignment_operations WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND claim_state='pending' AND action IN ('assign','reassign'))",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|r|r.get(0))?;
if assigned{
mark_stale(&tx,&plan,"unfinished work added")?;tx.commit()?;
return Err(stale_error(&plan,"unfinished work added"));
@@ -462,7 +469,7 @@ impl SqliteWorkspaceStore {
let worker=load_worker(&tx,workspace_id,&plan.worker)?.ok_or_else(||StoreError::InvalidInput("Worker missing before commit".into()))?;
if worker.updated_at!=plan.worker_revision{return Err(StoreError::InvalidInput(format!("stale:{}:Worker revision changed",plan.plan_id)));}
if worker.retention_state=="pinned" { return Err(StoreError::InvalidInput(format!("stale:{}:hold added",plan.plan_id))); }
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|row|row.get(0))?;
let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3 UNION ALL SELECT 1 FROM ticket_assignment_operations WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND claim_state='pending' AND action IN ('assign','reassign'))",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|row|row.get(0))?;
if assigned { return Err(StoreError::InvalidInput(format!("stale:{}:unfinished work added",plan.plan_id))); }
let now=Utc::now().to_rfc3339();
if let Some(a)=&result.archive{
@@ -1092,7 +1099,7 @@ fn parse_state(v: &str) -> rusqlite::Result<WorkerRemovalPlanState> {
mod tests {
use super::*;
use crate::store::{
ControlPlaneStore, TicketCoderAssignmentRecord, WorkerCatalogChange, WorkerRegistryRecord,
ControlPlaneStore, TicketWorkerAssignmentRecord, WorkerCatalogChange, WorkerRegistryRecord,
};
use worker_runtime::identity::WorkerId;
fn worker_id() -> WorkerId {
@@ -1431,7 +1438,7 @@ mod tests {
s: &SqliteWorkspaceStore,
assignment_id: &str,
) -> crate::Result<()> {
let assignment = TicketCoderAssignmentRecord {
let assignment = TicketWorkerAssignmentRecord {
workspace_id: "w".into(),
ticket_id: "ticket".into(),
assignment_id: assignment_id.into(),
@@ -1439,7 +1446,7 @@ mod tests {
assigned_by: "test".into(),
assigned_at: "t".into(),
};
s.set_current_ticket_coder_assignment(
s.set_current_ticket_worker_assignment(
&assignment,
None,
&format!("event-{assignment_id}"),
@@ -1474,6 +1481,38 @@ mod tests {
store
}
#[test]
fn pending_ticket_claim_blocks_removal_and_is_rechecked_after_planning() {
let s = setup();
let old = s.plan_worker_removal(&req(), &inv()).unwrap();
s.reserve_ticket_assignment_operation(
"w",
"claim",
"ticket",
"r",
Some(&worker_id().to_string()),
"intent",
"t",
)
.unwrap();
let blocked = s.plan_worker_removal(&req(), &inv()).unwrap();
assert!(
matches!(&blocked.blockers[..], [WorkerRemovalBlocker::PendingTicketClaim{operation_id,ticket_id}] if operation_id=="claim" && ticket_id=="ticket")
);
assert!(
s.begin_worker_removal("w", &old.plan_id, &old.input_fingerprint)
.is_err()
);
s.fail_ticket_assignment_operation("w", "claim", "No Runtime effects dispatched")
.unwrap();
assert!(
s.plan_worker_removal(&req(), &inv())
.unwrap()
.blockers
.is_empty()
);
}
#[test]
fn assignment_and_orphan_are_authoritative() {
let s = setup();
@@ -1758,7 +1797,7 @@ mod tests {
let replaced = tx.execute(
"UPDATE ticket_current_worker_assignments
SET assignment_id='new-assignment', updated_at='new'
WHERE workspace_id='w' AND ticket_id='ticket' AND role='coder'
WHERE workspace_id='w' AND ticket_id='ticket' AND role='worker'
AND assignment_id='assignment'
AND EXISTS (
SELECT 1 FROM ticket_assignment_work_releases
@@ -2088,7 +2127,7 @@ mod tests {
).map_err(StoreError::from)).unwrap();
assert_eq!(revision, "rev1");
let assignment = TicketCoderAssignmentRecord {
let assignment = TicketWorkerAssignmentRecord {
workspace_id: "w".into(),
ticket_id: "new-ticket".into(),
assignment_id: "new-assignment".into(),
@@ -2101,7 +2140,7 @@ mod tests {
};
assert!(
store
.set_current_ticket_coder_assignment(
.set_current_ticket_worker_assignment(
&assignment,
None,
"event",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,217 @@
use super::*;
use workspace_drive::{Error as DriveError, Mutation};
#[test]
fn custom_database_does_not_change_explicit_drive_placement() {
let temp = tempfile::tempdir().unwrap();
let mut config = test_server_config(temp.path());
config.database_path = temp.path().join("custom-authority/authority.sqlite");
config.data_root = temp.path().join("explicit-yoi-data");
let mut workspace = test_workspace();
workspace.workspace_id = "other-internal-id".into();
workspace.display_name = "../Not a storage key".into();
let scoped = config
.for_catalog_workspace(&workspace, Vec::new())
.unwrap();
assert_eq!(
scoped.drive_blob_root().unwrap(),
config.data_root.join("drives/other-internal-id/blobs")
);
}
#[tokio::test]
async fn cached_drive_handle_rejects_mutation_after_deletion_reservation() {
let temp = tempfile::tempdir().unwrap();
let mut config = test_server_config(temp.path());
config.repositories.clear();
let store = Arc::new(test_control_store(&config));
seed_test_registered_workspace(store.as_ref(), &config)
.await
.unwrap();
let owner = format!("account-{}", config.workspace_id);
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "retained-workspace".into(),
owner_account_id: owner.clone(),
display_name: "Retain".into(),
state: "active".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.await
.unwrap();
let api = WorkspaceApi::new(config.clone(), store.clone())
.await
.unwrap();
let cached = api.drive().await.unwrap();
let for_root = cached.clone();
let parent = tokio::task::spawn_blocking(move || for_root.root().unwrap().id)
.await
.unwrap();
let preflight = store
.workspace_deletion_preflight(&owner, &config.workspace_id)
.unwrap();
store
.reserve_workspace_deletion(
&owner,
&config.workspace_id,
&WorkspaceDeletionRequest {
operation_id: "delete-cached-drive".into(),
expected_revision: preflight.expected_revision,
confirmation: preflight.display_name,
},
)
.unwrap();
// No WorkspaceApi access or host/domain fence between reservation and the
// cached operation: the committed server authority must reject it directly.
let result = tokio::task::spawn_blocking(move || {
cached.mutate(
"cached-write",
"owner",
Mutation::CreateFolder {
parent,
name: "forbidden".into(),
},
)
})
.await
.unwrap();
assert!(matches!(result, Err(DriveError::Fenced)), "{result:?}");
}
#[tokio::test]
async fn deleting_workspace_reopens_fenced_drive_and_purges_blobs_before_feature_storage() {
let temp = tempfile::tempdir().unwrap();
let mut config = test_server_config(temp.path());
config.repositories.clear();
let store = Arc::new(test_control_store(&config));
seed_test_registered_workspace(store.as_ref(), &config)
.await
.unwrap();
let owner = format!("account-{}", config.workspace_id);
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "retained-workspace".into(),
owner_account_id: owner.clone(),
display_name: "Retain".into(),
state: "active".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.await
.unwrap();
let root = config.drive_blob_root().unwrap();
// Use a separate embedded-runtime namespace for the first API: this test
// reconstructs Drive authority in-process rather than spawning/restarting
// product processes (runtime observers may retain their namespace).
let first_api = WorkspaceApi::new(config.clone(), store.clone())
.await
.unwrap();
// Merely constructing a Workspace API must not create blob storage.
assert!(!root.exists());
let drive = first_api.drive().await.unwrap();
let parent = tokio::task::spawn_blocking(move || {
let parent = drive.root().unwrap().id;
drive
.mutate(
"upload",
"owner",
Mutation::CreateFile {
parent,
name: "file".into(),
content_type: "text/plain".into(),
bytes: b"content".to_vec(),
},
)
.unwrap();
parent
})
.await
.unwrap();
let preflight = store
.workspace_deletion_preflight(&owner, &config.workspace_id)
.unwrap();
store
.reserve_workspace_deletion(
&owner,
&config.workspace_id,
&WorkspaceDeletionRequest {
operation_id: "delete-drive-workspace".into(),
expected_revision: preflight.expected_revision,
confirmation: preflight.display_name,
},
)
.unwrap();
// Blocked operations are not automatically resumed on restart, but their
// Workspace remains deleting and must still reconstruct a fenced Drive.
store
.update_workspace_deletion_operation(
"delete-drive-workspace",
WorkspaceDeletionState::Blocked,
&[],
&[],
None,
)
.unwrap();
// Simulate a crash between the catalog reservation and Drive fencing.
let feature_storage = first_api.feature_storage.clone();
tokio::task::spawn_blocking(move || feature_storage.shutdown())
.await
.unwrap()
.unwrap();
drop(first_api);
let recovered = WorkspaceServerApi::new(config.clone(), store.clone());
let api = recovered
.api_for_workspace(&config.workspace_id)
.await
.unwrap()
.unwrap();
assert!(matches!(
api.drive().await,
Err(Error::WorkspaceConfigConflict(_))
));
let reopened = api
.drive_host
.open(
&api.feature_storage,
root.clone(),
config.database_path.clone(),
)
.await
.unwrap();
let mutation = tokio::task::spawn_blocking(move || {
reopened.mutate(
"restart-write",
"owner",
Mutation::CreateFolder {
parent,
name: "forbidden".into(),
},
)
})
.await
.unwrap();
assert!(matches!(mutation, Err(DriveError::Fenced)), "{mutation:?}");
let completed = recovered
.execute_workspace_deletion("delete-drive-workspace")
.await
.unwrap();
assert_eq!(completed.state, WorkspaceDeletionState::Succeeded);
assert!(!root.exists());
assert!(
!config
.database_path
.parent()
.unwrap()
.join("feature-storage")
.join(&config.workspace_id)
.exists()
);
assert!(
store
.get_workspace("retained-workspace")
.await
.unwrap()
.is_some()
);
}
@@ -74,8 +74,8 @@ impl EvidenceApiFixture {
.unwrap();
let assignment_id = "evidence-assignment".to_string();
api.store
.set_current_ticket_coder_assignment(
&TicketCoderAssignmentRecord {
.set_current_ticket_worker_assignment(
&TicketWorkerAssignmentRecord {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: ticket_id.clone(),
assignment_id: assignment_id.clone(),
@@ -319,7 +319,7 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_
fixture
.api
.store
.get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id)
.get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id)
.unwrap()
.is_some()
);
@@ -512,7 +512,7 @@ async fn open_source_without_runtime_is_typed_unavailable_not_stale_after_rescop
fixture
.api
.store
.get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id)
.get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id)
.unwrap()
.is_some()
);
@@ -0,0 +1,657 @@
use super::*;
async fn claim(
fixture: &ManualCoderAssignmentFixture,
request: server_api::SetTicketRoleAssignmentRequest,
) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> {
scoped_set_ticket_assignment(
State(fixture.api.clone()),
AxumPath((
TEST_WORKSPACE_ID.into(),
fixture.ticket_id.clone(),
"worker".into(),
)),
Json(request),
)
.await
}
#[tokio::test]
async fn explicit_existing_worker_claim_accepts_no_resources_and_preserves_assignment_on_retry() {
let fixture = manual_worker_assignment_fixture().await;
for id in [&fixture.main_workdir_id, &fixture.docs_workdir_id] {
fixture
.api
.store
.detach_worker_workdir(
TEST_WORKSPACE_ID,
&fixture.worker,
Some(id),
TEST_CREATED_AT,
)
.unwrap();
}
sync_runtime_worker_workdir_attachments(&fixture.api, &fixture.worker).unwrap();
let backend = browser_ticket_backend(&fixture.api).unwrap();
backend
.edit_item(
fixture.ticket_id.clone().into(),
TicketItemEdit {
targets: Some(TicketTargetsEdit::Clear),
..Default::default()
},
)
.unwrap();
backend
.set_workflow_state(
fixture.ticket_id.clone().into(),
TicketStateChange::new(
"ready",
"planning",
"Explicit investigation",
"No repository resources required",
),
)
.unwrap();
let request = manual_worker_assignment_request(&fixture, "claim-without-resources");
assert!(request.workdir_bindings.is_empty());
let Json(first) = claim(&fixture, request.clone()).await.unwrap();
let Json(replay) = claim(&fixture, request).await.unwrap();
assert_eq!(
first.assignment.unwrap().assignment_id,
replay.assignment.unwrap().assignment_id
);
assert_eq!(
fixture
.api
.authority
.ticket(&fixture.ticket_id)
.unwrap()
.state,
"inprogress"
);
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 1);
assert!(
fixture
.runtime
.logical_attachments(&fixture.worker.worker_id)
.is_empty()
);
}
#[tokio::test]
async fn explicit_existing_worker_claim_narrows_read_only_targets_without_orchestrator_conflict() {
let fixture = manual_worker_assignment_fixture().await;
let backend = browser_ticket_backend(&fixture.api).unwrap();
backend
.edit_item(
fixture.ticket_id.clone().into(),
TicketItemEdit {
targets: Some(TicketTargetsEdit::Set {
targets: vec![
test_ticket_target(
"test-repository",
"develop",
TicketTargetAccess::ReadOnly,
),
test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly),
],
}),
..Default::default()
},
)
.unwrap();
assign_test_orchestrator(&fixture.api, &fixture.ticket_id);
let request = manual_worker_assignment_request(&fixture, "claim-read-only");
let _ = claim(&fixture, request).await.unwrap();
for link in fixture
.api
.store
.list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker)
.unwrap()
.into_iter()
.filter(|link| link.unlinked_at.is_none())
{
assert_eq!(
link.capabilities,
workdir::WorkdirSessionCapabilities::READ_ONLY
);
let workdir = fixture
.api
.store
.get_workdir_registry(TEST_WORKSPACE_ID, &link.workdir_id)
.unwrap()
.unwrap();
assert_eq!(
effective_worker_workdir_capabilities(
&fixture.api,
&fixture.worker,
&workdir,
link.capabilities
)
.unwrap(),
workdir::WorkdirSessionCapabilities::READ_ONLY
);
}
assert!(
fixture
.runtime
.logical_attachments(&fixture.worker.worker_id)
.iter()
.all(|a| a.capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY)
);
assert!(
require_ticket_read_write_target(
&fixture.api,
&fixture.ticket_id,
&test_repository_id(&fixture.api),
"develop"
)
.is_err()
);
}
#[tokio::test]
async fn explicit_existing_worker_claim_requires_connections_instead_of_ambient_attachments() {
let fixture = manual_worker_assignment_fixture().await;
let mut request = manual_worker_assignment_request(&fixture, "claim-ambient-denied");
request.workdir_bindings.clear();
let response = claim(&fixture, request).await.unwrap_err().into_response();
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
status,
StatusCode::CONFLICT,
"{}",
String::from_utf8_lossy(&bytes)
);
assert!(String::from_utf8_lossy(&bytes).contains("exact live Workdir connections"));
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 0);
assert!(
fixture
.api
.store
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, "claim-ambient-denied")
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn ambiguous_existing_worker_restore_keeps_claim_pending_until_accepted_recovery() {
let fixture = manual_worker_assignment_fixture().await;
*fixture.runtime.next_restore_outcome.lock().unwrap() =
Some(server_api::WorkerRestoreState::ReconciliationRequired);
let request = manual_worker_assignment_request(&fixture, "claim-unknown");
assert!(claim(&fixture, request.clone()).await.is_err());
let operation = fixture
.api
.store
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, "claim-unknown")
.unwrap()
.unwrap();
assert_eq!(operation.claim_state, "pending");
assert!(operation.assignment_id.is_none());
assert_eq!(operation.worker, Some(fixture.worker.clone()));
assert!(operation.request_fingerprint.is_some());
let recovery = fixture
.api
.config_store
.get_ticket_claim_recovery(TEST_WORKSPACE_ID, "claim-unknown")
.unwrap()
.unwrap();
assert_eq!(
recovery.item_revision,
fixture
.api
.authority
.ticket(&fixture.ticket_id)
.unwrap()
.item_revision
);
assert_eq!(recovery.selected.len(), request.workdir_bindings.len());
for selected in &request.workdir_bindings {
assert!(recovery.selected.contains(selected));
}
assert!(
recovery
.original_links
.iter()
.all(|link| link.capabilities == workdir::WorkdirSessionCapabilities::ALL)
);
assert_eq!(
recovery
.effective_links
.iter()
.find(|link| link.alias == "docs")
.unwrap()
.capabilities,
workdir::WorkdirSessionCapabilities::READ_ONLY
);
// This fake proves the domain transition, not execution-level restore deduplication.
*fixture.runtime.next_restore_outcome.lock().unwrap() =
Some(server_api::WorkerRestoreState::ReconciliationRequired);
assert!(claim(&fixture, request.clone()).await.is_err());
let pending = fixture
.api
.store
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, "claim-unknown")
.unwrap()
.unwrap();
assert_eq!(pending.claim_state, "pending");
assert!(pending.assignment_id.is_none());
assert_eq!(pending.request_fingerprint, operation.request_fingerprint);
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 2);
assert!(
fixture
.api
.store
.reserve_ticket_assignment_operation(
TEST_WORKSPACE_ID,
"competing-launch",
&fixture.ticket_id,
&fixture.worker.runtime_id,
None,
"other-intent",
TEST_CREATED_AT
)
.is_err()
);
let worker = WorkspaceWorker::resolve(
&fixture.api,
&fixture.worker.runtime_id,
&fixture.worker.worker_id,
)
.unwrap();
let denied = worker
.input(
&WorkerOperationContext::Backend,
WorkerInputRequest {
kind: WorkerInputKind::User,
content: "Do not run while claim is unresolved".into(),
segments: None,
submission_request_id: None,
},
)
.await
.unwrap_err();
assert!(denied.error.to_string().contains("pending Ticket claim"));
assert_eq!(
fixture
.api
.authority
.ticket(&fixture.ticket_id)
.unwrap()
.state,
"ready"
);
assert!(
fixture
.api
.store
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id)
.unwrap()
.is_none()
);
let Json(accepted) = claim(&fixture, request.clone()).await.unwrap();
let assignment = accepted.assignment.unwrap();
let committed = fixture
.api
.store
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, "claim-unknown")
.unwrap()
.unwrap();
assert_eq!(committed.claim_state, "committed");
assert_eq!(
committed.assignment_id,
Some(assignment.assignment_id.clone())
);
assert_eq!(
fixture
.api
.authority
.ticket(&fixture.ticket_id)
.unwrap()
.state,
"inprogress"
);
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 3);
assert_eq!(
fixture
.api
.config_store
.get_ticket_claim_recovery(TEST_WORKSPACE_ID, "claim-unknown")
.unwrap()
.unwrap(),
recovery
);
let Json(replay) = claim(&fixture, request).await.unwrap();
assert_eq!(
replay.assignment.unwrap().assignment_id,
assignment.assignment_id
);
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 3);
}
#[tokio::test]
async fn pending_manual_worker_claim_rejects_changed_bindings_without_restore() {
let fixture = manual_worker_assignment_fixture().await;
*fixture.runtime.next_restore_outcome.lock().unwrap() =
Some(server_api::WorkerRestoreState::ReconciliationRequired);
let mut request = manual_worker_assignment_request(&fixture, "claim-changed-bindings");
assert!(claim(&fixture, request.clone()).await.is_err());
let admitted = fixture
.api
.store
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id)
.unwrap()
.unwrap();
request.workdir_bindings[0].connection_id = "changed-connection".into();
let response = claim(&fixture, request.clone())
.await
.unwrap_err()
.into_response();
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
status,
StatusCode::CONFLICT,
"{}",
String::from_utf8_lossy(&bytes)
);
assert!(String::from_utf8_lossy(&bytes).contains("different input"));
let pending = fixture
.api
.store
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id)
.unwrap()
.unwrap();
assert_eq!(pending.claim_state, "pending");
assert!(pending.assignment_id.is_none());
assert_eq!(pending.request_fingerprint, admitted.request_fingerprint);
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 1);
}
#[tokio::test]
async fn pending_manual_worker_claim_without_pinned_intent_rejects_replay_without_restore() {
let fixture = manual_worker_assignment_fixture().await;
*fixture.runtime.next_restore_outcome.lock().unwrap() =
Some(server_api::WorkerRestoreState::ReconciliationRequired);
let request = manual_worker_assignment_request(&fixture, "claim-missing-intent");
assert!(claim(&fixture, request.clone()).await.is_err());
// Simulate an interrupted/legacy pending receipt with no exact restore intent.
let request_id = format!("manual-worker-restore:{}", request.operation_id);
fixture.api.config_store.with_conn(|conn| {
let deleted = conn.execute(
"DELETE FROM worker_restore_intents WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND request_id=?4",
rusqlite::params![TEST_WORKSPACE_ID, fixture.worker.runtime_id, fixture.worker.worker_id, request_id],
)?;
assert_eq!(deleted, 1);
Ok(())
}).unwrap();
let response = claim(&fixture, request.clone())
.await
.unwrap_err()
.into_response();
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
status,
StatusCode::CONFLICT,
"{}",
String::from_utf8_lossy(&bytes)
);
assert!(String::from_utf8_lossy(&bytes).contains("no pinned restore intent"));
let pending = fixture
.api
.store
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id)
.unwrap()
.unwrap();
assert_eq!(pending.claim_state, "pending");
assert!(pending.assignment_id.is_none());
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 1);
assert!(
fixture
.api
.config_store
.get_internal_worker_restore_intent(TEST_WORKSPACE_ID, &fixture.worker, &request_id)
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits_without_input() {
let workspace = tempfile::tempdir().unwrap();
let (api, execution) = test_api_with_recording_backend(workspace.path()).await;
execution.accept_restores();
let worker = spawn_ticket_check_source(&api, "manual-claim-reconciliation");
assert!(
api.store
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker)
.unwrap()
.is_empty()
);
let backend = browser_ticket_backend(&api).unwrap();
let mut input = ticket::NewTicket::new("Manual investigation without resources");
input.workflow_state = Some(TicketWorkflowState::Ready);
let ticket_id = backend.create(input).unwrap().id;
backend
.edit_item(
ticket_id.clone().into(),
TicketItemEdit {
targets: Some(TicketTargetsEdit::Clear),
..Default::default()
},
)
.unwrap();
backend
.set_workflow_state(
ticket_id.clone().into(),
TicketStateChange::new(
"ready",
"planning",
"Explicit investigation",
"No repository resources required",
),
)
.unwrap();
let admitted_revision = api.authority.ticket(&ticket_id).unwrap().item_revision;
let request = server_api::SetTicketRoleAssignmentRequest {
operation_id: "claim-runtime-reconciliation".into(),
principal: server_api::TicketAssignmentPrincipal::Worker {
runtime_id: worker.runtime_id.clone(),
worker_id: worker.worker_id.clone(),
},
expected_assignment_id: None,
workdir_bindings: Vec::new(),
};
let submit_claim = || {
scoped_set_ticket_assignment(
State(api.clone()),
AxumPath((TEST_WORKSPACE_ID.into(), ticket_id.clone(), "worker".into())),
Json(request.clone()),
)
};
let uncertain_restore = || {
worker_runtime::execution::WorkerExecutionSpawnResult::Errored(
worker_runtime::execution::WorkerExecutionResult::rejected(
worker_runtime::execution::WorkerExecutionOperation::Restore,
"uncertain manual claim restore",
),
)
};
*execution.restore_failure.lock().unwrap() = Some(uncertain_restore());
let response = submit_claim().await.unwrap_err().into_response();
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
status,
StatusCode::CONFLICT,
"{}",
String::from_utf8_lossy(&bytes)
);
assert!(String::from_utf8_lossy(&bytes).contains("unknown"));
let pending = api
.store
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id)
.unwrap()
.unwrap();
assert_eq!(pending.claim_state, "pending");
assert!(pending.assignment_id.is_none());
assert_eq!(pending.worker, Some(worker.clone()));
assert!(pending.request_fingerprint.is_some());
assert_eq!(api.authority.ticket(&ticket_id).unwrap().state, "planning");
assert!(
api.store
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket_id)
.unwrap()
.is_none()
);
let recovery = api
.config_store
.get_ticket_claim_recovery(TEST_WORKSPACE_ID, &request.operation_id)
.unwrap()
.unwrap();
assert_eq!(recovery.item_revision, admitted_revision);
assert!(recovery.selected.is_empty());
assert!(recovery.original_links.is_empty());
assert!(recovery.effective_links.is_empty());
let request_id = format!("manual-worker-restore:{}", request.operation_id);
let pinned = api
.config_store
.get_internal_worker_restore_intent(TEST_WORKSPACE_ID, &worker, &request_id)
.unwrap()
.unwrap();
assert_eq!(pinned.request_id, request_id);
let original = execution.restore_operations.lock().unwrap()[0];
assert_eq!(
execution.restore_operations.lock().unwrap().as_slice(),
&[original]
);
assert!(execution.reconcile_operations.lock().unwrap().is_empty());
let handle = WorkspaceWorker::resolve(&api, &worker.runtime_id, &worker.worker_id).unwrap();
let denied = handle
.input(
&WorkerOperationContext::Backend,
WorkerInputRequest {
kind: WorkerInputKind::User,
content: "Do not submit while the manual claim is pending".into(),
segments: None,
submission_request_id: None,
},
)
.await
.unwrap_err();
assert!(denied.error.to_string().contains("pending Ticket claim"));
*execution.restore_failure.lock().unwrap() = Some(uncertain_restore());
let response = submit_claim().await.unwrap_err().into_response();
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
status,
StatusCode::CONFLICT,
"{}",
String::from_utf8_lossy(&bytes)
);
assert!(String::from_utf8_lossy(&bytes).contains("pending"));
let still_pending = api
.store
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id)
.unwrap()
.unwrap();
assert_eq!(still_pending.claim_state, "pending");
assert!(still_pending.assignment_id.is_none());
assert_eq!(
still_pending.request_fingerprint,
pending.request_fingerprint
);
assert_eq!(
api.authority.ticket(&ticket_id).unwrap().item_revision,
admitted_revision
);
assert_eq!(
api.config_store
.get_internal_worker_restore_intent(TEST_WORKSPACE_ID, &worker, &request_id)
.unwrap()
.unwrap(),
pinned
);
assert_eq!(
execution.reconcile_operations.lock().unwrap().as_slice(),
&[original]
);
let Json(accepted) = submit_claim().await.unwrap();
let assignment = accepted.assignment.unwrap();
assert_eq!(assignment.principal, request.principal);
let committed = api
.store
.get_ticket_assignment_operation(TEST_WORKSPACE_ID, &request.operation_id)
.unwrap()
.unwrap();
assert_eq!(committed.claim_state, "committed");
assert_eq!(
committed.assignment_id,
Some(assignment.assignment_id.clone())
);
assert_eq!(committed.request_fingerprint, pending.request_fingerprint);
assert_eq!(
api.authority.ticket(&ticket_id).unwrap().state,
"inprogress"
);
assert_eq!(
api.store
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket_id)
.unwrap()
.unwrap()
.assignment_id,
assignment.assignment_id
);
assert_eq!(
api.config_store
.get_ticket_claim_recovery(TEST_WORKSPACE_ID, &request.operation_id)
.unwrap()
.unwrap(),
recovery
);
assert!(
api.store
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker)
.unwrap()
.is_empty()
);
// The executor logs reconcile_restore in restore_operations as well. There is
// exactly one fresh dispatch; both subsequent entries reconcile its same id.
assert_eq!(
execution.restore_operations.lock().unwrap().as_slice(),
&[original, original, original]
);
assert_eq!(
execution.reconcile_operations.lock().unwrap().as_slice(),
&[original, original]
);
let Json(replay) = submit_claim().await.unwrap();
assert_eq!(
replay.assignment.unwrap().assignment_id,
assignment.assignment_id
);
assert_eq!(execution.restore_operations.lock().unwrap().len(), 3);
assert_eq!(execution.reconcile_operations.lock().unwrap().len(), 2);
assert!(
!execution
.protocol_methods()
.iter()
.any(|(_, method)| matches!(
method,
protocol::Method::Submit { .. }
| protocol::Method::SubmitTracked { .. }
| protocol::Method::SubmitIfIdle { .. }
)),
"manual claims and denied input must not generate a Submit"
);
}
File diff suppressed because it is too large Load Diff
@@ -359,6 +359,8 @@ impl WorkspaceWorker {
"send_input"
};
let _guard = self.authorize_operation(context, permission).await?;
let session_lock = current_worker_session_lock(&self.api, &self.identity);
let _session_guard = session_lock.lock().await;
self.ensure_interactive()?;
if matches!(context, WorkerOperationContext::WorkerControl { .. })
&& request.kind == WorkerInputKind::User
@@ -373,6 +375,19 @@ impl WorkspaceWorker {
}
fn ensure_interactive(&self) -> ApiResult<()> {
if let Some(claim) = self
.api
.store
.get_pending_ticket_assignment_operation_for_worker(
self.api.workspace_id(),
&self.identity,
)?
{
return Err(Error::TicketAssignmentConflict(format!(
"Worker has pending Ticket claim {}; reconcile that exact operation before admitting input",
claim.operation_id,
)).into());
}
if self
.api
.store
@@ -578,6 +593,8 @@ impl WorkspaceWorkerMethodSender {
.worker
.authorize_operation(context, "send_input")
.await?;
let session_lock = current_worker_session_lock(&self.worker.api, &self.worker.identity);
let _session_guard = session_lock.lock().await;
self.worker.ensure_interactive()?;
let WorkerOperationContext::Browser { source, .. } = context else {
return Err(Error::WorkspacePermissionDenied(
+736
View File
@@ -0,0 +1,736 @@
//! Authorized Workspace document API. Storage, CAS and receipts stay in workspace-drive.
//! HTTP upload consumes frames with a hard bound; DB publication occurs only after validation.
use super::*;
use futures::StreamExt;
use server_api::*;
use sha2::{Digest, Sha256};
use workspace_drive::{Drive, Mutation, NodeId, Revision};
fn mutation_actor(context: &ServerRequestContext) -> DriveResult<String> {
if let Some(source) = &context.runtime_source {
let worker = source.worker_id.as_ref().ok_or_else(|| failure(403))?;
Ok(format!(
"worker:{}",
serde_json::to_string(&(&source.runtime_id, worker)).map_err(|_| failure(400))?
))
} else {
Ok(format!(
"user:{}",
context.actor.as_ref().ok_or_else(|| failure(403))?.user_id
))
}
}
type DriveResult<T> = std::result::Result<T, DriveApiError>;
const TRANSFER_CHUNK: usize = 64 * 1024;
pub(super) fn failure(status: u16) -> DriveApiError {
let code = match status {
401 | 403 => DriveApiErrorCode::Denied,
404 => DriveApiErrorCode::NotFound,
409 => DriveApiErrorCode::Conflict,
413 => DriveApiErrorCode::Limit,
500..=599 => DriveApiErrorCode::StorageUnavailable,
_ => DriveApiErrorCode::Invalid,
};
DriveApiError::new(code)
}
fn domain_error(error: workspace_drive::Error, mutation: bool) -> DriveApiError {
use workspace_drive::Error;
match error {
Error::Denied => failure(403),
Error::NotFound => failure(404),
Error::Conflict | Error::Fenced => failure(409),
Error::Invalid(_) => failure(400),
Error::Blob(_) => failure(503),
_ if mutation => DriveApiError::new(DriveApiErrorCode::OutcomeUnknown),
_ => failure(503),
}
}
async fn blocking<T: Send + 'static>(
mutation: bool,
action: impl FnOnce() -> workspace_drive::Result<T> + Send + 'static,
) -> DriveResult<T> {
tokio::task::spawn_blocking(action)
.await
.map_err(|_| {
if mutation {
DriveApiError::new(DriveApiErrorCode::OutcomeUnknown)
} else {
failure(503)
}
})?
.map_err(|error| domain_error(error, mutation))
}
fn node_id(value: &str) -> DriveResult<NodeId> {
value.to_owned().try_into().map_err(|_| failure(400))
}
fn revision(value: &str) -> DriveResult<Revision> {
value.to_owned().try_into().map_err(|_| failure(400))
}
fn entry(workspace: &str, reference: &DriveEntryRef) -> DriveResult<NodeId> {
if reference.workspace_id != workspace {
return Err(failure(403));
}
node_id(&reference.node_id)
}
fn scoped_id(workspace: &str, entry_workspace: &str, id: &str) -> DriveResult<NodeId> {
if workspace != entry_workspace {
return Err(failure(403));
}
node_id(id)
}
fn safe_content_type(value: &str) -> DriveResult<String> {
// Deliberately accept a bare MIME type only, not attacker-controlled header parameters.
if value.len() > 128
|| !value.contains('/')
|| value.matches('/').count() != 1
|| value.split('/').any(str::is_empty)
|| !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"!#$&^_.+-/".contains(&b))
{
return Err(failure(400));
}
Ok(value.to_ascii_lowercase())
}
fn disposition(name: &str) -> String {
// ASCII fallback plus RFC 5987 UTF-8 filename. Never place raw names in headers.
let encoded: String = name
.as_bytes()
.iter()
.map(|b| {
if b.is_ascii_alphanumeric() || b"-._".contains(b) {
(*b as char).to_string()
} else {
format!("%{b:02X}")
}
})
.collect();
format!("attachment; filename=\"download\"; filename*=UTF-8''{encoded}")
}
fn error_response(error: DriveApiError) -> Response {
let status = match error.code {
DriveApiErrorCode::Denied => 403,
DriveApiErrorCode::NotFound => 404,
DriveApiErrorCode::Conflict => 409,
DriveApiErrorCode::Limit => 413,
DriveApiErrorCode::Invalid => 400,
DriveApiErrorCode::StorageUnavailable | DriveApiErrorCode::OutcomeUnknown => 503,
};
(StatusCode::from_u16(status).unwrap(), Json(error)).into_response()
}
// No raw storage diagnostic is ever placed in a public error or stream failure.
fn stream_error() -> std::io::Error {
std::io::Error::other("Drive transfer unavailable")
}
/// Stream HTTP frames while enforcing both announced and observed limits. Storage currently
/// accepts a bounded whole-file Vec; this is binary buffering, never JSON/base64 expansion.
async fn receive(body: axum::body::Body, declared: u64, digest: &str) -> DriveResult<Vec<u8>> {
if declared > workspace_drive::MAX_FILE_BYTES as u64 {
return Err(failure(413));
}
if digest.len() != 64
|| !digest
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
{
return Err(failure(400));
}
let mut stream = body.into_data_stream();
let mut bytes = Vec::new();
let mut hasher = Sha256::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| failure(400))?;
let size = bytes
.len()
.checked_add(chunk.len())
.ok_or_else(|| failure(413))?;
if size > workspace_drive::MAX_FILE_BYTES {
return Err(failure(413));
}
if size > declared as usize {
return Err(failure(400));
}
hasher.update(&chunk);
bytes.extend_from_slice(&chunk);
}
if bytes.len() != declared as usize
|| hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
!= digest
{
return Err(failure(400));
}
Ok(bytes)
}
fn public_node(node: workspace_drive::Node) -> DriveEntry {
let workspace_id = node.workspace_id.clone();
let time = chrono::DateTime::from_timestamp_millis(node.updated_at_ms)
.map(|t| t.to_rfc3339())
.unwrap_or_default();
DriveEntry {
entry: DriveEntryRef {
workspace_id: workspace_id.clone(),
node_id: node.id.get().to_string(),
},
parent: node.parent_id.map(|id| DriveEntryRef {
workspace_id,
node_id: id.get().to_string(),
}),
name: node.name,
kind: match node.kind {
workspace_drive::Kind::File => DriveEntryKind::File,
workspace_drive::Kind::Directory => DriveEntryKind::Folder,
},
revision: node.revision.get().to_string(),
size: (node.kind == workspace_drive::Kind::File).then_some(node.size as u32),
content_type: node.content_type,
updated_by: node.updated_by,
updated_at: time,
latest_url: format!(
"/api/w/{}/drive/{}?entry_workspace_id={}&id={}",
node.workspace_id,
if node.kind == workspace_drive::Kind::File {
"download"
} else {
"metadata"
},
node.workspace_id,
node.id.get()
),
}
}
fn cursor(workspace: &str, id: Option<NodeId>) -> Option<String> {
id.map(|id| format!("{workspace}:{}", id.get()))
}
fn after(workspace: &str, value: Option<String>) -> DriveResult<Option<NodeId>> {
value
.map(|value| {
let (scope, id) = value.rsplit_once(':').ok_or_else(|| failure(400))?;
scoped_id(workspace, scope, id)
})
.transpose()
}
fn limit(value: Option<u32>, maximum: usize) -> DriveResult<usize> {
let limit = value.unwrap_or(maximum as u32) as usize;
if limit == 0 || limit > maximum {
return Err(failure(413));
}
Ok(limit)
}
pub(super) async fn root(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace: &str,
) -> DriveResult<DriveEntry> {
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
blocking(false, move || drive.root()).await.map(public_node)
}
pub(super) async fn metadata(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace: &str,
query: DriveEntryQuery,
) -> DriveResult<DriveEntry> {
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
blocking(false, move || drive.metadata(id))
.await
.map(public_node)
}
pub(super) async fn list(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace: &str,
query: DriveListQuery,
) -> DriveResult<DriveListResponse> {
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
let after = after(workspace, query.after)?;
let limit = limit(query.limit, workspace_drive::MAX_PAGE)?;
let page = blocking(false, move || drive.list(id, after, limit)).await?;
Ok(DriveListResponse {
entries: page.nodes.into_iter().map(public_node).collect(),
next_after: cursor(workspace, page.next_after),
})
}
pub(super) async fn search(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace: &str,
query: DriveSearchQuery,
) -> DriveResult<DriveListResponse> {
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
let after = after(workspace, query.after)?;
let limit = limit(query.limit, workspace_drive::MAX_SEARCH_NODES)?;
let page = blocking(false, move || {
drive.search(&query.query, query.include_text, after, limit)
})
.await?;
Ok(DriveListResponse {
entries: page.nodes.into_iter().map(public_node).collect(),
next_after: cursor(workspace, page.next_after),
})
}
pub(super) async fn read_text(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace: &str,
query: DriveReadTextQuery,
) -> DriveResult<DriveReadTextResponse> {
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
let max = query.max_bytes as usize;
if max == 0 || max > DRIVE_TEXT_MAX_BYTES {
return Err(failure(413));
}
let (node, chunk) = blocking(false, move || {
let node = drive.metadata(id)?;
let chunk = drive.read(id, node.revision, 0, max)?;
Ok((node, chunk))
})
.await?;
let text = match std::str::from_utf8(&chunk.bytes) {
Ok(text) => text,
Err(error) if !chunk.eof && error.error_len().is_none() => {
std::str::from_utf8(&chunk.bytes[..error.valid_up_to()]).map_err(|_| failure(400))?
}
Err(_) => return Err(failure(400)),
};
Ok(DriveReadTextResponse {
entry: public_node(node),
text: text.into(),
truncated: !chunk.eof,
})
}
pub(super) async fn read_chunk(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace: &str,
query: DriveReadChunkQuery,
) -> DriveResult<BinaryBody> {
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
let revision = revision(&query.expected_revision)?;
if query.length == 0 || query.length > DRIVE_CHUNK_MAX_BYTES {
return Err(failure(413));
}
let chunk = blocking(false, move || {
drive.read(id, revision, query.offset.into(), query.length as usize)
})
.await?;
Ok(chunk.bytes.into())
}
fn text_bytes(text: String) -> DriveResult<Vec<u8>> {
if text.len() > DRIVE_TEXT_MAX_BYTES {
return Err(failure(413));
}
Ok(text.into_bytes())
}
fn mutation(workspace: &str, mutation: DriveMutation) -> DriveResult<Mutation> {
Ok(match mutation {
DriveMutation::CreateFolder { parent, name } => Mutation::CreateFolder {
parent: entry(workspace, &parent)?,
name,
},
DriveMutation::CreateText {
parent,
name,
text,
content_type,
} => Mutation::CreateFile {
parent: entry(workspace, &parent)?,
name,
content_type: safe_content_type(&content_type)?,
bytes: text_bytes(text)?,
},
DriveMutation::UpdateText {
id,
expected_revision,
text,
content_type,
} => Mutation::Update {
id: entry(workspace, &id)?,
expected_revision: revision(&expected_revision)?,
content_type: safe_content_type(&content_type)?,
bytes: text_bytes(text)?,
},
DriveMutation::Relocate {
id,
expected_revision,
parent,
name,
} => Mutation::Relocate {
id: entry(workspace, &id)?,
expected_revision: revision(&expected_revision)?,
parent: entry(workspace, &parent)?,
name,
},
DriveMutation::Delete {
id,
expected_revision,
} => Mutation::Delete {
id: entry(workspace, &id)?,
expected_revision: revision(&expected_revision)?,
},
})
}
fn result(request_id: String, result: workspace_drive::MutationResult) -> DriveMutationResponse {
DriveMutationResponse {
request_id,
entry: (!result.deleted).then(|| public_node(result.node)),
}
}
pub(super) async fn mutate(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace: &str,
request: DriveMutationRequest,
) -> DriveResult<DriveMutationResponse> {
let drive = drive_grants::authorized_drive(api, context, workspace, true).await?;
let actor = mutation_actor(context)?;
let operation = mutation(workspace, request.mutation)?;
let request_id = request.request_id.clone();
let mutated = blocking(true, move || {
drive.mutate(&request.request_id, &actor, operation)
})
.await?;
Ok(result(request_id, mutated))
}
fn upload_mutation(
workspace: &str,
query: &DriveUploadQuery,
bytes: Vec<u8>,
) -> DriveResult<Mutation> {
if query.entry_workspace_id != workspace {
return Err(failure(403));
}
let content_type = safe_content_type(&query.content_type)?;
match query.operation {
DriveUploadOperation::Create if query.id.is_none() && query.expected_revision.is_none() => {
Ok(Mutation::CreateFile {
parent: node_id(query.parent_id.as_deref().ok_or_else(|| failure(400))?)?,
name: query.name.clone().ok_or_else(|| failure(400))?,
content_type,
bytes,
})
}
DriveUploadOperation::Update if query.parent_id.is_none() && query.name.is_none() => {
Ok(Mutation::Update {
id: node_id(query.id.as_deref().ok_or_else(|| failure(400))?)?,
expected_revision: revision(
query
.expected_revision
.as_deref()
.ok_or_else(|| failure(400))?,
)?,
content_type,
bytes,
})
}
_ => Err(failure(400)),
}
}
pub(super) async fn upload(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace: &str,
query: DriveUploadQuery,
body: BinaryBody,
) -> DriveResult<DriveMutationResponse> {
let drive = drive_grants::authorized_drive(api, context, workspace, true).await?;
let actor = mutation_actor(context)?;
let bytes = receive(
axum::body::Body::from(body.into_bytes()),
query.size.into(),
&query.sha256,
)
.await?;
publish_upload(drive, actor, workspace, query, bytes).await
}
async fn publish_upload(
drive: Drive,
actor: String,
workspace: &str,
query: DriveUploadQuery,
bytes: Vec<u8>,
) -> DriveResult<DriveMutationResponse> {
let mutation = upload_mutation(workspace, &query, bytes)?;
let request_id = query.request_id.clone();
let mutated = blocking(true, move || {
drive.mutate(&query.request_id, &actor, mutation)
})
.await?;
Ok(result(request_id, mutated))
}
pub(super) async fn request_status(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace: &str,
request_id: String,
) -> DriveResult<DriveRequestStatusResponse> {
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
let id = request_id.clone();
let status = blocking(false, move || drive.request_status(&id)).await?;
Ok(match status {
workspace_drive::RequestStatus::Uncommitted => DriveRequestStatusResponse {
request_id,
state: DriveRequestState::Uncommitted,
response: None,
},
workspace_drive::RequestStatus::Committed { result: mutation } => {
DriveRequestStatusResponse {
request_id: request_id.clone(),
state: DriveRequestState::Committed,
response: Some(result(request_id, mutation)),
}
}
})
}
async fn select_download(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace: &str,
query: DriveDownloadQuery,
) -> DriveResult<(Drive, workspace_drive::Node)> {
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
let for_node = drive.clone();
let node = blocking(false, move || for_node.metadata(id)).await?;
if node.kind != workspace_drive::Kind::File {
return Err(failure(400));
}
if query
.expected_revision
.as_deref()
.is_some_and(|v| v != node.revision.get().to_string())
{
return Err(failure(409));
}
safe_content_type(node.content_type.as_deref().unwrap_or("")).map_err(|_| failure(503))?;
Ok((drive, node))
}
pub(super) async fn download(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace: &str,
query: DriveDownloadQuery,
) -> DriveResult<server_api::server_api_responses::DriveDownload> {
let (drive, node) = select_download(api, context, workspace, query).await?;
let mut bytes = Vec::new();
let mut offset = 0;
loop {
let copy = drive.clone();
let chunk = blocking(false, move || {
copy.read(node.id, node.revision, offset, TRANSFER_CHUNK)
})
.await?;
offset += chunk.bytes.len() as u64;
bytes.extend_from_slice(&chunk.bytes);
if chunk.eof {
break;
}
}
Ok(server_api::server_api_responses::DriveDownload::Status200 {
body: bytes.into(),
header_content_disposition: disposition(&node.name),
header_cache_control: "private, no-store".into(),
header_etag: validator(&node),
header_x_content_type_options: "nosniff".into(),
header_content_security_policy: "sandbox; default-src 'none'".into(),
})
}
fn validator(node: &workspace_drive::Node) -> String {
format!(
"\"drive-{}-{}-{}\"",
node.workspace_id,
node.id.get(),
node.revision.get()
)
}
pub(super) fn router(service: Arc<ServerApiContractService>) -> Router {
Router::new()
.merge(streaming_router(service.clone()))
.merge(server_api::server_api_axum::drive_root(service.clone()))
.merge(server_api::server_api_axum::drive_metadata(service.clone()))
.merge(server_api::server_api_axum::drive_list(service.clone()))
.merge(server_api::server_api_axum::drive_search(service.clone()))
.merge(server_api::server_api_axum::drive_read_text(
service.clone(),
))
.merge(server_api::server_api_axum::drive_read_chunk(
service.clone(),
))
.merge(server_api::server_api_axum::drive_mutate(service.clone()))
.merge(server_api::server_api_axum::drive_request_status(
service.clone(),
))
.merge(server_api::server_api_axum::drive_grant_create(
service.clone(),
))
.merge(server_api::server_api_axum::drive_grant_revoke(
service.clone(),
))
.merge(server_api::server_api_axum::drive_grant_list(
service.clone(),
))
.layer(DefaultBodyLimit::max(512 * 1024))
.layer(middleware::from_fn(normalize_rejections))
}
async fn normalize_rejections(request: Request, next: Next) -> Response {
let response = next.run(request).await;
if matches!(response.status().as_u16(), 415 | 422) {
error_response(failure(400))
} else if matches!(response.status().as_u16(), 400 | 413)
&& response
.headers()
.get(CONTENT_TYPE)
.is_none_or(|value| value != "application/json")
{
error_response(failure(response.status().as_u16()))
} else {
response
}
}
fn streaming_router(service: Arc<ServerApiContractService>) -> Router {
// The operation inventory is the route authority, not duplicated string literals.
use api_macros::Operation;
use axum::routing::put;
use server_api::server_api_operations::{DriveDownload, DriveUpload};
Router::new()
.route(DriveUpload::METADATA.path, put(streaming_upload))
.route(DriveDownload::METADATA.path, get(streaming_download))
.with_state(service)
}
async fn streaming_upload(
State(service): State<Arc<ServerApiContractService>>,
AxumPath(workspace): AxumPath<String>,
Extension(context): Extension<ServerRequestContext>,
query: std::result::Result<Query<DriveUploadQuery>, axum::extract::rejection::QueryRejection>,
request: Request,
) -> Response {
let result = async {
let api = service.workspace_api().map_err(|_| failure(404))?;
let drive = drive_grants::authorized_drive(api, &context, &workspace, true).await?;
let actor = mutation_actor(&context)?;
let Query(query) = query.map_err(|_| failure(400))?;
// Reject malformed metadata, foreign refs and over-limit declarations before reading.
upload_mutation(&workspace, &query, Vec::new())?;
if query.size as usize > workspace_drive::MAX_FILE_BYTES {
return Err(failure(413));
}
if let Some(length) = request.headers().get(axum::http::header::CONTENT_LENGTH) {
let length: u64 = length
.to_str()
.ok()
.and_then(|s| s.parse().ok())
.ok_or_else(|| failure(400))?;
if length > workspace_drive::MAX_FILE_BYTES as u64 {
return Err(failure(413));
}
if length != u64::from(query.size) {
return Err(failure(400));
}
}
let bytes = receive(request.into_body(), query.size.into(), &query.sha256).await?;
// The bound Drive authorizer runs again under attached-authority IMMEDIATE,
// so a grant revoked during reception cannot publish or replay a receipt.
publish_upload(drive, actor, &workspace, query, bytes).await
}
.await;
match result {
Ok(result) => Json(result).into_response(),
Err(error) => error_response(error),
}
}
async fn streaming_download(
State(service): State<Arc<ServerApiContractService>>,
AxumPath(workspace): AxumPath<String>,
Extension(context): Extension<ServerRequestContext>,
query: std::result::Result<Query<DriveDownloadQuery>, axum::extract::rejection::QueryRejection>,
) -> Response {
let result = async {
let api = service.workspace_api().map_err(|_| failure(404))?;
let Query(query) = query.map_err(|_| failure(400))?;
let (drive, node) = select_download(api, &context, &workspace, query).await?;
// Validate the referenced blob before HTTP success, including a zero-byte file.
let first_drive = drive.clone();
let first = blocking(false, move || {
first_drive.read(node.id, node.revision, 0, TRANSFER_CHUNK)
})
.await?;
let id = node.id;
let revision = node.revision;
let state = (drive, id, revision, Some(first), 0_u64, false);
let stream = futures::stream::try_unfold(
state,
|(drive, id, revision, first, offset, done)| async move {
if done {
return Ok(None);
}
let chunk = match first {
Some(chunk) => chunk,
None => {
let copy = drive.clone();
blocking(false, move || {
copy.read(id, revision, offset, TRANSFER_CHUNK)
})
.await
.map_err(|_| stream_error())?
}
};
let next = offset + chunk.bytes.len() as u64;
let eof = chunk.eof;
Ok::<_, std::io::Error>(Some((
axum::body::Bytes::from(chunk.bytes),
(drive, id, revision, None, next, eof),
)))
},
);
let mut response = axum::body::Body::from_stream(stream).into_response();
let headers = response.headers_mut();
headers.insert(
CONTENT_TYPE,
HeaderValue::from_str(&safe_content_type(
node.content_type.as_deref().unwrap_or(""),
)?)
.map_err(|_| failure(503))?,
);
headers.insert(
axum::http::header::CONTENT_LENGTH,
HeaderValue::from_str(&node.size.to_string()).map_err(|_| failure(503))?,
);
headers.insert(
axum::http::header::CONTENT_DISPOSITION,
HeaderValue::from_str(&disposition(&node.name)).map_err(|_| failure(503))?,
);
headers.insert(
axum::http::header::ETAG,
HeaderValue::from_str(&validator(&node)).map_err(|_| failure(503))?,
);
headers.insert(CACHE_CONTROL, HeaderValue::from_static("private, no-store"));
headers.insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
headers.insert(
"content-security-policy",
HeaderValue::from_static("sandbox; default-src 'none'"),
);
Ok::<_, DriveApiError>(response)
}
.await;
match result {
Ok(response) => response,
Err(error) => error_response(error),
}
}
@@ -0,0 +1,229 @@
//! Drive authority is rechecked inside the metadata transaction against the
//! attached Server DB. A bound handle is not an authorization cache or lease.
use super::*;
use rusqlite::params;
use server_api::{
DriveApiError, DriveApiErrorCode, DriveGrantCreateRequest, DriveGrantListQuery,
DriveGrantListResponse, DriveGrantResponse, ServerRequestContext,
};
type DriveResult<T> = std::result::Result<T, DriveApiError>;
fn fail(code: DriveApiErrorCode) -> DriveApiError {
DriveApiError::new(code)
}
pub(super) fn pre_error(error: impl Into<ApiError>) -> DriveApiError {
let error = error.into();
fail(match api_error_status(&error.error).as_u16() {
401 | 403 => DriveApiErrorCode::Denied,
404 => DriveApiErrorCode::NotFound,
409 => DriveApiErrorCode::Conflict,
413 => DriveApiErrorCode::Limit,
500..=599 => DriveApiErrorCode::StorageUnavailable,
_ => DriveApiErrorCode::Invalid,
})
}
fn mutation_error(error: Error) -> DriveApiError {
if matches!(error, Error::Sqlite(_) | Error::Store(_)) {
fail(DriveApiErrorCode::OutcomeUnknown)
} else {
pre_error(error)
}
}
fn worker_request(context: &ServerRequestContext) -> bool {
context.runtime_source.is_some()
|| context.worker_source.is_some()
|| context.transport_headers.iter().any(|(name, _)| {
name.eq_ignore_ascii_case("x-yoi-runtime-id")
|| name.eq_ignore_ascii_case("x-yoi-worker-id")
})
}
fn browser_actor(context: &ServerRequestContext) -> DriveResult<&RequestActor> {
if worker_request(context) {
return Err(fail(DriveApiErrorCode::Denied));
}
context
.actor
.as_ref()
.ok_or_else(|| fail(DriveApiErrorCode::Denied))
}
pub(super) async fn authorized_drive(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace_id: &str,
write: bool,
) -> DriveResult<workspace_drive::Drive> {
validate_workspace_scope(api, workspace_id).map_err(pre_error)?;
let workspace = workspace_id.to_owned();
let drive = if worker_request(context) {
// Never fall back to an actor when a Worker/Runtime source is present,
// incomplete, or inconsistent with its transport identity.
let headers = current_worker_contract_headers(context)
.map_err(|_| fail(DriveApiErrorCode::Denied))?;
let worker = current_worker_identity(api, workspace_id, &headers)
.map_err(|_| fail(DriveApiErrorCode::Denied))?;
if context.worker_source.as_ref().is_some_and(|source| {
source.runtime_id != worker.runtime_id || source.worker_id != worker.worker_id
}) {
return Err(fail(DriveApiErrorCode::Denied));
}
let drive = api.drive().await.map_err(pre_error)?;
drive.with_authorizer(move |conn, write| {
if !crate::store::drive_worker_is_live(conn, true, &workspace, &worker)? {
return Err(workspace_drive::Error::Denied);
}
let granted: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM yoi_workspace_authority.workspace_drive_grants
WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND revoked=0 AND (?4=0 OR access='read_write'))",
params![workspace,worker.runtime_id,worker.worker_id,write],|row|row.get(0))?;
if !granted { return Err(workspace_drive::Error::Denied); }
Ok(())
})
} else {
let actor = browser_actor(context)?;
let account = actor.account_id.clone();
let user = actor.user_id.clone();
let drive = api.drive().await.map_err(pre_error)?;
// Ordinary Workspace use is member-facing: the current Server policy
// admits persisted authenticated users, not only the Workspace owner.
drive.with_authorizer(move |conn, _write| {
let member: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM yoi_workspace_authority.workspaces ws
CROSS JOIN yoi_workspace_authority.users u
WHERE ws.workspace_id=?1 AND ws.state='active' AND u.account_id=?2 AND u.user_id=?3)",
params![workspace,account,user],|row|row.get(0))?;
if !member { return Err(workspace_drive::Error::Denied); }
Ok(())
})
};
// Reject missing/read-only grants before upload body reception. This check
// is admission only: every later operation rechecks under its own lock.
tokio::task::spawn_blocking(move || {
drive.check_authorized(write)?;
Ok::<_, workspace_drive::Error>(drive)
})
.await
.map_err(|_| fail(DriveApiErrorCode::StorageUnavailable))?
.map_err(|error| {
fail(match error {
workspace_drive::Error::Denied => DriveApiErrorCode::Denied,
workspace_drive::Error::Fenced | workspace_drive::Error::Conflict => {
DriveApiErrorCode::Conflict
}
workspace_drive::Error::NotFound => DriveApiErrorCode::NotFound,
workspace_drive::Error::Invalid(_) => DriveApiErrorCode::Invalid,
_ => DriveApiErrorCode::StorageUnavailable,
})
})
}
pub(super) async fn create_grant(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace_id: &str,
request: DriveGrantCreateRequest,
) -> DriveResult<DriveGrantResponse> {
let actor = browser_actor(context)?;
require_workspace_owner(api, workspace_id, actor, "Drive grants")
.await
.map_err(pre_error)?;
for identity in [&request.runtime_id, &request.worker_id] {
if identity.is_empty() || identity.len() > 256 || identity.chars().any(char::is_control) {
return Err(fail(DriveApiErrorCode::Invalid));
}
}
let worker = RuntimeWorkerRef::new(&request.runtime_id, &request.worker_id);
let summary = api
.runtime
.worker(&worker)
.map_err(|_| fail(DriveApiErrorCode::NotFound))?;
if summary.workspace.workspace_id.as_deref() != Some(workspace_id) {
return Err(fail(DriveApiErrorCode::Denied));
}
let grant = DriveGrantResponse {
grant_id: String::new(),
workspace_id: workspace_id.into(),
runtime_id: request.runtime_id,
worker_id: request.worker_id,
access: request.access,
revoked: false,
created_by: actor.account_id.clone(),
created_at: String::new(),
revoked_by: None,
revoked_at: None,
};
let store = api.store.clone();
tokio::task::spawn_blocking(move || store.create_workspace_drive_grant(&grant))
.await
.map_err(|_| fail(DriveApiErrorCode::OutcomeUnknown))?
.map_err(mutation_error)
}
pub(super) async fn revoke_grant(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace_id: &str,
grant_id: &str,
) -> DriveResult<DriveGrantResponse> {
let actor = browser_actor(context)?;
require_workspace_owner(api, workspace_id, actor, "Drive grants")
.await
.map_err(pre_error)?;
validate_grant_id(grant_id)?;
let (workspace, grant, actor) = (
workspace_id.to_owned(),
grant_id.to_owned(),
actor.account_id.clone(),
);
let store = api.store.clone();
tokio::task::spawn_blocking(move || {
store.revoke_workspace_drive_grant(&workspace, &grant, &actor)
})
.await
.map_err(|_| fail(DriveApiErrorCode::OutcomeUnknown))?
.map_err(mutation_error)?
.ok_or_else(|| fail(DriveApiErrorCode::NotFound))
}
fn validate_grant_id(id: &str) -> DriveResult<()> {
if !id
.parse::<i64>()
.is_ok_and(|n| n > 0 && n.to_string() == id)
{
return Err(fail(DriveApiErrorCode::Invalid));
}
Ok(())
}
pub(super) async fn list_grants(
api: &WorkspaceApi,
context: &ServerRequestContext,
workspace_id: &str,
query: DriveGrantListQuery,
) -> DriveResult<DriveGrantListResponse> {
let actor = browser_actor(context)?;
require_workspace_owner(api, workspace_id, actor, "Drive grants")
.await
.map_err(pre_error)?;
let limit = query.limit.unwrap_or(100) as usize;
if limit == 0 || limit > 200 {
return Err(fail(DriveApiErrorCode::Invalid));
}
if let Some(after) = &query.after {
validate_grant_id(after)?;
}
let workspace = workspace_id.to_owned();
let store = api.store.clone();
let mut grants = tokio::task::spawn_blocking(move || {
store.list_workspace_drive_grants(&workspace, limit + 1, query.after.as_deref())
})
.await
.map_err(|_| fail(DriveApiErrorCode::StorageUnavailable))?
.map_err(pre_error)?;
let more = grants.len() > limit;
grants.truncate(limit);
let next_after = if more {
grants.last().map(|g| g.grant_id.clone())
} else {
None
};
Ok(DriveGrantListResponse { grants, next_after })
}
@@ -129,7 +129,7 @@ mod workspace_config_integration {
#[tokio::test]
async fn workspace_config_signed_editor_routes_cannot_bypass_wip_authority() {
let mut fixture = manual_coder_assignment_fixture().await;
let mut fixture = manual_worker_assignment_fixture().await;
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
let api = &fixture.api;
@@ -222,7 +222,7 @@ mod workspace_config_integration {
#[tokio::test]
async fn workspace_config_entrypoint_support_is_exact_and_canonical_mutations_stay_guarded() {
let fixture = manual_coder_assignment_fixture().await;
let fixture = manual_worker_assignment_fixture().await;
let api = &fixture.api;
grant(api, &fixture.worker, Access::ReadWrite).await;
let attached = attach(api, &fixture.worker).await;
@@ -299,7 +299,7 @@ mod workspace_config_integration {
#[tokio::test]
async fn workspace_config_grants_owner_scope_and_signed_routes() {
let mut fixture = manual_coder_assignment_fixture().await;
let mut fixture = manual_worker_assignment_fixture().await;
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
let api = &fixture.api;
@@ -615,7 +615,7 @@ mod workspace_config_integration {
#[tokio::test]
async fn workspace_config_readonly_revoke_and_conditional_lifetime_fences() {
let fixture = manual_coder_assignment_fixture().await;
let fixture = manual_worker_assignment_fixture().await;
let api = &fixture.api;
let read_grant = grant(api, &fixture.worker, Access::ReadOnly).await;
assert!(
@@ -827,7 +827,7 @@ mod workspace_config_integration {
#[tokio::test]
async fn workspace_config_two_workers_ui_cas_and_identical_virtual_replacement() {
let fixture = manual_coder_assignment_fixture().await;
let fixture = manual_worker_assignment_fixture().await;
let api = &fixture.api;
let second = second_worker(&fixture).await;
grant(api, &fixture.worker, Access::ReadWrite).await;
@@ -982,7 +982,7 @@ mod workspace_config_integration {
#[tokio::test]
async fn workspace_config_invalid_bounds_and_failed_save_do_not_corrupt_tree() {
let fixture = manual_coder_assignment_fixture().await;
let fixture = manual_worker_assignment_fixture().await;
let api = &fixture.api;
grant(api, &fixture.worker, Access::ReadWrite).await;
let attached = attach(api, &fixture.worker).await;
@@ -1114,7 +1114,7 @@ mod workspace_config_integration {
#[tokio::test]
async fn workspace_config_operation_and_revoke_share_the_detach_session_lock() {
let fixture = manual_coder_assignment_fixture().await;
let fixture = manual_worker_assignment_fixture().await;
let api = &fixture.api;
let granted = grant(api, &fixture.worker, Access::ReadWrite).await;
let attached = attach(api, &fixture.worker).await;
@@ -1168,7 +1168,7 @@ mod workspace_config_integration {
}
#[tokio::test]
async fn workspace_config_grant_restart_source_immutability_and_owner_acceptance_guard() {
let fixture = manual_coder_assignment_fixture().await;
let fixture = manual_worker_assignment_fixture().await;
let api = &fixture.api;
let granted = grant(api, &fixture.worker, Access::ReadWrite).await;
let attached = attach(api, &fixture.worker).await;
@@ -1255,7 +1255,7 @@ mod workspace_config_integration {
#[tokio::test]
async fn workspace_config_auxiliary_secret_effects_are_unknown_not_false_rollback() {
let fixture = manual_coder_assignment_fixture().await;
let fixture = manual_worker_assignment_fixture().await;
let api = &fixture.api;
grant(api, &fixture.worker, Access::ReadWrite).await;
let attached = attach(api, &fixture.worker).await;
@@ -1324,7 +1324,7 @@ mod workspace_config_integration {
}
#[tokio::test]
async fn workspace_config_observe_node_limit_is_complete_or_error_not_silent_truncation() {
let fixture = manual_coder_assignment_fixture().await;
let fixture = manual_worker_assignment_fixture().await;
let api = &fixture.api;
grant(api, &fixture.worker, Access::ReadWrite).await;
let attached = attach(api, &fixture.worker).await;
@@ -259,7 +259,7 @@ async fn assert_config_catalog(runtime: &worker::wip::WipRuntime, attached: &Att
async fn workspace_config_real_selected_invoke_backend_wip_edit_create_delete_import_and_unknown() {
use worker::feature::builtin::workspace_config::wip::mount_workspace_config_wip;
use worker::feature::builtin::workspace_config::{CONTENT_ROOT, WorkspaceConfigFeature};
let mut fixture = manual_coder_assignment_fixture().await;
let mut fixture = manual_worker_assignment_fixture().await;
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
let granted = grant(&fixture.api, &fixture.worker, Access::ReadWrite).await;
@@ -706,7 +706,7 @@ async fn workspace_config_actual_remote_and_embedded_clients_use_authenticated_w
decode_runtime_request_source_claims,
};
// Remote Runtime-owned HTTP adapter, including its real request signer.
let mut fixture = manual_coder_assignment_fixture().await;
let mut fixture = manual_worker_assignment_fixture().await;
let identity = RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id).unwrap();
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
let claims = Arc::new(Mutex::new(Vec::new()));
File diff suppressed because it is too large Load Diff
@@ -87,6 +87,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"worker_workdir_links",
"workspace_config_entries",
"workspace_config_grants",
"workspace_drive_grants",
"workspace_config_tree_revisions",
"workspace_config_trees",
"workspace_create_operations",
@@ -0,0 +1,24 @@
CREATE TABLE workspace_drive_grants (
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
runtime_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')),
revoked INTEGER NOT NULL DEFAULT 0 CHECK(revoked IN (0, 1)),
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
revoked_by TEXT,
revoked_at TEXT,
CHECK((revoked=0 AND revoked_by IS NULL AND revoked_at IS NULL)
OR (revoked=1 AND revoked_by IS NOT NULL AND revoked_at IS NOT NULL))
);
CREATE UNIQUE INDEX workspace_drive_grants_active_worker
ON workspace_drive_grants(workspace_id, runtime_id, worker_id) WHERE revoked=0;
-- Retain grant audit across Worker deletion, but never carry its authority into
-- a later lifetime reusing the same Runtime/Worker identity.
CREATE TRIGGER workspace_drive_grants_worker_deleted BEFORE DELETE ON worker_registry BEGIN
UPDATE workspace_drive_grants SET revoked=1, revoked_by='server:worker-removal',
revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE workspace_id=OLD.workspace_id AND runtime_id=OLD.runtime_id
AND worker_id=OLD.worker_id AND revoked=0;
END;
@@ -0,0 +1,315 @@
//! Trusted Host placement and lifecycle for Workspace Drive (no HTTP/DTO surface).
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::Mutex;
use workspace_drive::Drive;
use crate::{Error, Result, WorkspaceFeatureStorage};
pub(crate) fn drive_blob_root(data_root: &Path, workspace_id: &str) -> Result<PathBuf> {
if !data_root.is_absolute() {
return Err(Error::Config(
"Server data_root must be an absolute path".into(),
));
}
if workspace_id.is_empty()
|| workspace_id.len() > 128
|| !workspace_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(Error::Config(
"Drive requires an internal Workspace storage identifier".into(),
));
}
Ok(data_root.join("drives").join(workspace_id).join("blobs"))
}
// Reject redirected Host-managed ancestors before opening or deleting storage.
// These directories are server-owned, not writable by Drive API clients.
fn reject_symlink_ancestors(path: &Path) -> Result<()> {
for ancestor in path.ancestors() {
match std::fs::symlink_metadata(ancestor) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(Error::Config(
"Drive storage path contains a symbolic link".into(),
));
}
Ok(metadata) if !metadata.is_dir() => {
return Err(Error::Config(
"Drive storage path contains a non-directory".into(),
));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
Ok(())
}
pub(crate) struct WorkspaceDriveHost {
registration: crate::RegisteredFeature,
purged: AtomicBool,
drive: Mutex<Option<Drive>>,
}
impl WorkspaceDriveHost {
pub(crate) async fn new(storage: &WorkspaceFeatureStorage) -> Result<Self> {
let storage = storage.clone();
let registration = tokio::task::spawn_blocking(move || Drive::register(&storage))
.await
.map_err(|error| Error::Store(format!("Drive registration task failed: {error}")))?
.map_err(|error| Error::Store(format!("failed to register Drive storage: {error}")))?;
Ok(Self {
registration,
purged: AtomicBool::new(false),
drive: Mutex::new(None),
})
}
pub(crate) async fn open(
&self,
storage: &WorkspaceFeatureStorage,
root: PathBuf,
authority_database: PathBuf,
) -> Result<Drive> {
let mut cached = self.drive.lock().await;
if let Some(drive) = cached.as_ref() {
return Ok(drive.clone());
}
let storage = storage.clone();
let registration = self.registration.clone();
let drive = tokio::task::spawn_blocking(move || {
reject_symlink_ancestors(&root)?;
// Domain IMMEDIATE transactions lock the attached server authority
// as well as Drive metadata. Deletion reservation and cached-handle
// operations therefore serialize with other server DB writes.
Drive::open_with_workspace_authority(
&storage,
&registration,
&root,
&authority_database,
)
.map_err(Error::from)
})
.await
.map_err(|error| Error::Store(format!("Drive open task failed: {error}")))??;
*cached = Some(drive.clone());
Ok(drive)
}
pub(crate) async fn fence(
&self,
storage: &WorkspaceFeatureStorage,
root: PathBuf,
authority_database: PathBuf,
) -> Result<()> {
if self.purged.load(Ordering::Acquire) {
return Ok(());
}
let drive = self.open(storage, root, authority_database).await?;
tokio::task::spawn_blocking(move || drive.fence().map_err(Error::from))
.await
.map_err(|error| Error::Store(format!("Drive fence task failed: {error}")))?
}
pub(crate) async fn purge(
&self,
storage: &WorkspaceFeatureStorage,
root: PathBuf,
authority_database: PathBuf,
) -> Result<()> {
if self.purged.load(Ordering::Acquire) {
return Ok(());
}
let drive = self.open(storage, root.clone(), authority_database).await?;
tokio::task::spawn_blocking(move || -> Result<()> {
reject_symlink_ancestors(&root)?;
// Domain purge persists its tombstone before removing blobs/nodes/receipts.
drive.purge()?;
// Remove the empty blob root, never the data root or another Workspace.
// Missing roots are successful retries; other failures retain FeatureStorage.
match std::fs::remove_dir_all(&root) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
})
.await
.map_err(|error| Error::Store(format!("Drive purge task failed: {error}")))??;
// A later signing-material/finalization failure can retry after the
// feature DB has been closed and removed by FeatureStorage.delete.
self.purged.store(true, Ordering::Release);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use workspace_drive::Mutation;
fn seed_authority(data_root: &Path) -> PathBuf {
let path = data_root.join("server.db");
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE workspaces (workspace_id TEXT PRIMARY KEY, state TEXT NOT NULL);
INSERT INTO workspaces VALUES ('workspace-a', 'active');",
)
.unwrap();
path
}
#[test]
fn drive_path_uses_explicit_data_root_and_internal_workspace_id() {
let temp = tempfile::tempdir().unwrap();
let data = temp.path().join("data");
assert_eq!(
drive_blob_root(&data, "workspace-a").unwrap(),
data.join("drives/workspace-a/blobs")
);
for id in [
"",
".",
"..",
"../other",
"/absolute",
"a/b",
"a\\b",
"display name",
] {
assert!(drive_blob_root(&data, id).is_err(), "accepted {id:?}");
}
assert!(drive_blob_root(Path::new("relative"), "workspace-a").is_err());
}
#[tokio::test]
async fn drive_fence_survives_host_reconstruction_before_purge() {
let temp = tempfile::tempdir().unwrap();
let feature_root = temp.path().join("feature-storage");
let root = drive_blob_root(temp.path(), "workspace-a").unwrap();
let authority = seed_authority(temp.path());
let storage = crate::FeatureStorage::new(&feature_root)
.workspace("workspace-a")
.unwrap();
let host = WorkspaceDriveHost::new(&storage).await.unwrap();
let drive = host
.open(&storage, root.clone(), authority.clone())
.await
.unwrap();
let parent = tokio::task::spawn_blocking(move || drive.root().unwrap().id)
.await
.unwrap();
host.fence(&storage, root.clone(), authority.clone())
.await
.unwrap();
drop(host);
drop(storage);
let storage = crate::FeatureStorage::new(&feature_root)
.workspace("workspace-a")
.unwrap();
let host = WorkspaceDriveHost::new(&storage).await.unwrap();
let reopened = host.open(&storage, root, authority.clone()).await.unwrap();
let result = tokio::task::spawn_blocking(move || {
reopened.mutate(
"after-restart",
"owner",
Mutation::CreateFolder {
parent,
name: "forbidden".into(),
},
)
})
.await
.unwrap();
assert!(
matches!(result, Err(workspace_drive::Error::Fenced)),
"{result:?}"
);
}
#[tokio::test]
async fn drive_purge_removes_only_its_blob_root_and_retries_after_feature_delete() {
let temp = tempfile::tempdir().unwrap();
let feature_root = temp.path().join("feature-storage");
let root = drive_blob_root(temp.path(), "workspace-a").unwrap();
let authority = seed_authority(temp.path());
let other_root = drive_blob_root(temp.path(), "workspace-b").unwrap();
std::fs::create_dir_all(&other_root).unwrap();
std::fs::write(other_root.join("keep"), b"other workspace").unwrap();
let storage = crate::FeatureStorage::new(&feature_root)
.workspace("workspace-a")
.unwrap();
let host = WorkspaceDriveHost::new(&storage).await.unwrap();
let drive = host
.open(&storage, root.clone(), authority.clone())
.await
.unwrap();
tokio::task::spawn_blocking(move || {
let parent = drive.root().unwrap().id;
drive
.mutate(
"upload",
"owner",
Mutation::CreateFile {
parent,
name: "file".into(),
content_type: "text/plain".into(),
bytes: b"content".to_vec(),
},
)
.unwrap();
})
.await
.unwrap();
host.purge(&storage, root.clone(), authority.clone())
.await
.unwrap();
assert!(!root.exists());
assert_eq!(
std::fs::read(other_root.join("keep")).unwrap(),
b"other workspace"
);
let feature_exists = feature_root.join("workspace-a/features/workspace-drive.sqlite");
assert!(
feature_exists.exists(),
"purge must retain the fenced DB: {}",
feature_exists.display()
);
let storage_for_delete = storage.clone();
tokio::task::spawn_blocking(move || storage_for_delete.delete())
.await
.unwrap()
.unwrap();
host.fence(&storage, root.clone(), authority.clone())
.await
.unwrap();
host.purge(&storage, root.clone(), authority.clone())
.await
.unwrap();
assert!(!root.exists());
}
#[cfg(unix)]
#[tokio::test]
async fn redirected_drive_root_blocks_cleanup_and_preserves_external_files() {
let temp = tempfile::tempdir().unwrap();
let root = drive_blob_root(temp.path(), "workspace-a").unwrap();
let authority = seed_authority(temp.path());
let outside = temp.path().join("outside");
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("keep"), b"external").unwrap();
let storage = crate::FeatureStorage::new(temp.path().join("feature-storage"))
.workspace("workspace-a")
.unwrap();
let host = WorkspaceDriveHost::new(&storage).await.unwrap();
host.fence(&storage, root.clone(), authority.clone())
.await
.unwrap();
std::fs::remove_dir_all(&root).unwrap();
std::os::unix::fs::symlink(&outside, &root).unwrap();
assert!(host.purge(&storage, root, authority).await.is_err());
assert_eq!(std::fs::read(outside.join("keep")).unwrap(), b"external");
}
}
+3 -3
View File
@@ -181,9 +181,9 @@ truncation under the existing search contract.
## Typical sequence
1. Discover `/checkouts` with depth 1; inspect its Interface and call `list`.
2. Discover an entrance or known deep directory, inspect it, call `glob`/`grep`.
3. Follow a returned file `path`, discover/inspect, call line `read`.
1. Use Tree on `/checkouts` with depth 1; Inspect `/checkouts` directly and Invoke `list`.
2. Inspect an entrance or a known deep directory directly; Invoke `glob`/`grep`.
3. Follow a returned file `path`, Inspect that path and Invoke line `read`.
4. Call `edit` or `write`; Client-managed validators protect the exact observed
object while the shared tracker enforces the prior Read.
5. Rediscover after external changes; a stale rejection is not permission to skip
+4 -4
View File
@@ -165,16 +165,16 @@ Ticket/Objective/Merge Request routes retain their T-698 contracts.
## First-Workspace example
1. `Discover(path="/", depth=1)` finds `repositories`, `workdirs` and
1. `Tree(path="/", depth=1)` finds `repositories`, `workdirs` and
`workdir-attachments` when permitted.
2. Inspect the returned Repository collection Interface and call `list` with a
2. Inspect the Repository collection path and Invoke `list` with a
bounded limit. An empty registered/visible catalog explicitly returns an empty
list; do not invent a key. Follow `next_cursor` when supplied.
3. Discover the returned item `path`, inspect its Interface and call `read`.
3. Inspect the returned item `path` directly and Invoke `read`.
`default_selector: null` means no configured default, not `main`.
4. Inspect the Workdir collection and `create` using the discovered formal key,
with optional selector, display name and Runtime under the existing contract.
5. Follow created `item.path`, discover/inspect and `attach` with a chosen
5. Follow created `item.path`, Inspect directly and Invoke `attach` with a chosen
Worker-local alias. This is separate from creation.
6. Follow `attachments_path`, `list`, then discover/read the returned connection
path to confirm alias, Workdir and effective capabilities.
+23 -11
View File
@@ -24,15 +24,17 @@ worker = { mode = "wip" }
Omission resolves to `tools`, so existing manifests, profiles, persisted snapshots, and restored Workers retain the ordinary individual-tool surface. A direct Internal SubWorker selected with `profile = "inherit"` inherits the parent mode as reusable behavior. `default` and named profile selections use that profile's own mode and do not implicitly inherit WIP.
The implementation uses the crates.io `wip-client`, `wip-protocol`, and `wip-http` **0.1.0** releases. It does not use Git/path dependencies or the old `wip-https` name. Those releases require Rust 1.88; the workspace declares `rust-version = "1.88"`, and the changed crates inherit it. WIP Host/interface crates are not needed for the async compatibility adapter: the published client/protocol/HTTP state machines remain authoritative while Yoi supplies an in-process async Host transport.
The implementation uses the published **WIP 0.2.0** Client, HTTP, Protocol and official Text View packages from **crates.io**. Exact workspace version constraints and Cargo.lock registry checksums reproduce one Protocol type source; no local attachment, path override, Git snapshot or old registry 0.1.0 substitutes for the release. Canonical scope/Text View contracts were checked against wip-reference **6086f3c5ef10aa1464ce9c824750d7f667217bfd**. See [validation and resolution checks](../development/wip-integration-validation.md). CI/Nix dependency acquisition requires no upstream Git credentials. Yoi retains its async provider/transport and existing execution boundaries; upstream owns Client cache/lifecycle, codecs and signatures.
## LLM surface and route contract
A WIP Worker exposes exactly these generic LLM tools instead of exposing every enabled ordinary tool twice:
- `Discover`: observe an object path and bounded descendants, beginning at `/`.
- `Inspect`: fetch the descriptor for an opaque interface reference returned by discovery.
- `Call`: call an operation using fresh object and interface observations.
- `Tree(path, depth)`: retrieve the full indexable range within depth 0..8, or report incomplete/loading/failed/limited coverage as an error. Children at the requested boundary remain unobserved (null), not empty. Cached nonindexable paths are never included by prefix scanning Known Space.
- `Inspect(path)`: directly acquire an Object, then every published Interface, and return complete official Object/Interface signatures in declaration order. It requires no prior Tree or manual descriptor fetching. The path is explicit JSON envelope context outside the path-free signature. Summaries are complete; runtime metadata and documentation details are not appended to signatures.
- `Invoke(path, interface, operation, arguments)`: use a structured `{scope, name}` reference and named argument record. The Client owns validators and observed scope_ref. Missing/stale/failed observations are recovered with at most one pre-dispatch retrieval per subject. A dispatched operation is never automatically retried.
Tree and Inspect accept explicit `refresh: true` for blocked observations. An ensure API returning None is never itself readiness evidence: freshness, complete edge coverage and retained data are checked. Loading and capacity pressure are reported rather than superseded or busy-looped.
The compatibility projection mounts enabled ordinary tools at:
@@ -40,13 +42,13 @@ The compatibility projection mounts enabled ordinary tools at:
/tools/<exact-tool-registration-name>
```
Each object has one opaque interface reference:
Each compatibility Object publishes an explicit root-scoped reference:
```text
yoi.tool/<exact-tool-registration-name>/v1
{"scope":"/", "name":"yoi.tool/<exact-tool-registration-name>/v1"}
```
Its single operation is `call`. For this compatibility route, `Call.arguments` is the original ordinary tool argument object. Native projections instead receive a JSON object keyed by the descriptor's declared parameter names. Native input is decoded with the published `wip-http` descriptor-bound codec, so named and composite types are resolved, an integral JSON number remains a WIP `Number` when declared as such, and WIP `Bytes` use the codec's canonical RFC 4648 base64 JSON representation. Tool argument IDs remain JSON values; the adapter never infers a domain object or Worldspace route from them.
Its single operation is `call`. For this compatibility route, `Invoke.arguments` is `{ "input": <original ordinary tool argument object> }`, matching the descriptor's named Json parameter. Native projections instead receive a JSON object keyed by the descriptor's declared parameter names. Native input is decoded with the published `wip-http` descriptor-bound codec, so named and composite types are resolved, an integral JSON number remains a WIP `Number` when declared as such, and WIP `Bytes` use the codec's canonical RFC 4648 base64 JSON representation. Tool argument IDs remain JSON values; the adapter never infers a domain object or Worldspace route from them.
The interface descriptor uses WIP `Json` for compatibility input and includes the exact original JSON Schema in descriptor documentation. Before execution, the Host validates the JSON value with that original schema and then delegates to the original async `Tool`. Constraints are therefore neither approximated nor silently dropped. A schema that cannot be compiled prevents WIP startup instead of creating a weaker projection.
@@ -65,7 +67,7 @@ Object resolution and operations are separate contracts. One mounted `WipProject
- A native projection replaces a compatibility projection either by the original same-route semantic capability or by an explicit registry capability claim owned by a mounted native route.
- Conflicting native claims fail startup. When native is selected, the claimed compatibility tool is hidden rather than exposed through a second entry. Normal Tool mode is unaffected because it does not install the WIP registry.
`Inspect` renders the complete effective descriptor, including contributed operations, descriptor-local named declarations, and recursive record, list, enum, and union shapes with required flags and declaration documentation. The tests `host_namespace_object_resolution_and_operation_contributions_are_independent`, `operation_contributions_reject_interface_and_target_conflicts`, and `native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end` demonstrate root allocation, unique resolution, contribution merging, collision rejection, current call-time authorization, complete descriptor inspection, and descriptor-typed invocation.
`Inspect` delegates to official `wip-text-view::render_object` and `render_interface`; it renders the complete effective descriptor, including contributed operations, descriptor-local named declarations, and recursive record, list, enum, and union shapes with required flags and declaration documentation. The tests `host_namespace_object_resolution_and_operation_contributions_are_independent`, `operation_contributions_reject_interface_and_target_conflicts`, and `native_projection_preserves_descriptor_and_decodes_typed_arguments_end_to_end` demonstrate root allocation, unique resolution, contribution merging, collision rejection, current call-time authorization, complete descriptor inspection, and descriptor-typed invocation.
The built-in native route migration is:
@@ -83,7 +85,7 @@ The built-in Repository/Workdir reference provider allocates `/repositories`, `/
2. Mount a native collection `WipProjection` at that root with capability `repository:collection`, matching Object name and one declared interface. Static Objects underneath the root use the same capability owner prefix. A mounted collection retains its own identity, interfaces, and validator even when it has static children.
3. For dynamic items, register one `WipDynamicMount` on the collection with capability `repository:item`. A foreign capability owner is rejected. Its `WipDynamicItemResolver` owns reference acceptance, Object identity, and current Object validator. It returns only a bounded direct child; it must not reinterpret a reference as another namespace or grant permission from existence.
4. The reference provider supplies its read operations and handlers independently of management Feature enablement. An Object-only provider may use an empty operation descriptor. Enabled management Features register `WipOperationContribution` for static Objects or `WipDynamicOperationContribution` for a dynamic family; they do not allocate the namespace again or replace its resolver.
5. Contributions use the existing interface reference and identical format, documentation, and type declarations, supplying only their new operations. Interface references are global: extending a descriptor that is also mounted elsewhere must not create different descriptors/validators under the same reference (registration fails). Use separate references for collection and item contracts. Failed contributions leave the registry unchanged.
5. Contributions use the existing interface reference and identical format, documentation, and type declarations, supplying only their new operations. Interface references are exact (scope, name) keys: extending a descriptor that is also mounted elsewhere must not create different descriptors/validators under the same reference (registration fails). Use separate references for collection and item contracts. Failed contributions leave the registry unchanged.
6. Handlers are responsible for checking current target capability and current subject authorization before dispatch. Registration/discovery is metadata, not a grant. A resolver/handler must retain the original permission identity and domain audit/failure contracts. Revocation after discovery must fail before provider dispatch. Compatibility mounts are restricted to `/tools/<tool-name>` and cannot bypass namespace ownership or republish legacy routes.
Tests `management_enablement_is_independent_of_read_and_operation_permissions` and `contributed_interface_invalidates_observations_and_restore_requires_discovery` cover all eight enablement/read/manage combinations, direct-call rejection, effective descriptor changes, fresh runtime isolation under the same Worker identity, reset, and post-observation revocation.
@@ -103,13 +105,13 @@ The Worldspace contains only the operations registered from enabled Features and
Filesystem scope, provider Workdir capability checks, Backend Workspace authority, Feature enablement, and input-specific restrictions stay in the original tool implementation and are checked again at invocation. `ask` permissions remain denied fail-closed because the runtime has no approval protocol. The gateway does not mint authority or expose authentication material.
Pre/post Engine history still records one bounded `Call` result, while the compatibility result retains the original `ToolOutput`, including attachments and normal Engine output pruning. WIP call audit records retain the route, operation, request identity, and terminal classification.
Pre/post Engine history still records one bounded `Invoke` result, while the compatibility result retains the original `ToolOutput`, including attachments and normal Engine output pruning. WIP call audit records retain the route, operation, request identity, and terminal classification.
## Client lifetime, restoration, and compaction
The stateful `wip-client::Client` is owned by one Worker WIP runtime. Sessions are keyed by the canonical endpoint and an opaque security-context identity derived by the Host from Workspace, Worker, and session identity. Authentication credentials are not included in that identity and are never emitted to descriptors, tool output, or history.
Known Space and interface observations are therefore not shared across Workers, endpoints, or security contexts. A restored Worker creates a new runtime/client and must explore again. `Discover(reset = true)` drops all observations before reconnect/authority-change exploration; `refresh = true` explicitly supersedes one cached observation. A normal compaction keeps the live runtime but the three gateway schemas are supplied again on every LLM request, including the instruction to begin discovery at `/`; the compacted transcript is not treated as cache authority.
Known Space and interface observations are therefore not shared across Workers, endpoints, or security contexts. A restored Worker creates a new runtime/client and must explore again. `refresh = true` explicitly supersedes requested retrievals. Old string references in saved history remain historical text, not cache/wire authority; observations are runtime-only and are not persisted or restored. Resume by inspecting the target path, not by migrating an opaque display string. A normal compaction keeps the live runtime but the three gateway schemas are supplied again on every LLM request, including direct Inspect and indexable Tree semantics; the compacted transcript is not treated as cache authority.
Saved Session/history entries remain append-only evidence and are never rewritten or replayed as calls. In particular, historical `/features/<feature>/...` text stays displayable but is not a current route alias. Passing such a legacy path to discovery or call returns `NotFound` with guidance to rediscover from `/`; the Host does not translate it, resolve it under another namespace, or expose old and new routes in parallel.
@@ -138,3 +140,13 @@ The runtime tracks bounded counters for:
- successful operations.
These values are returned with discovery/inspection output and are intended for comparing prompt input volume, exploration round trips, and representative operation success with ordinary Tool mode. No fixed reduction percentage is promised.
## Scoped publication and display boundaries
References are structured everywhere; wire requests reject legacy/display strings. Root and self scopes are valid; ancestry uses canonical path segments, not string prefixes. Contextual descriptors use the resolved Object path as their explicit self scope, not an encoded suffix on the local name. Fetching a self-scoped Interface freezes descriptor/validator and optional scope Object ref from the same live projection. A scope without a ref is valid; the adapter does not invent one. Calls check target existence, Object preconditions, scope/membership/ref, then Interface preconditions and arguments, with the selected descriptor and handler pinned for execution/result validation. Existing providers repeat current authorization and their captured Workdir/Workspace metadata checks at their execution/commit boundary. The registered artifacts alone are not call authority.
Separate Interface namespaces on one static Object can be registered with `mount_interface`; the owner/identity cannot be replaced and duplicate Interface keys fail. Inspect preserves each Interface reference, even with identical operation names, and Invoke selects the exact namespace. Contribution merging retains one Interface and disjoint operation names.
Official signature quoting, escapes and summary layout are preserved verbatim. Host documentation is untrusted data in Tool output, not instructions. RenderError categories and the failed path/reference are reported as display failures. Library input limits and Yoi envelope limits reject the entire display rather than truncating it or reporting no operations. No signature parser, shorthand-input parser, independent lexer, or custom escape engine is installed.
Typed Entry results remain values; no eager result acquisition, guessed string-path conversion, or inferred indexable edges occur. A later Inspect failure never reclassifies a completed successful operation. FS indexable/list policy is a separate provider concern (T-714), not Client-side search/ranking.
+368
View File
@@ -0,0 +1,368 @@
# Workspace Drive storage
## Scope and ownership
Drive is a Workspace-owned **latest-version document tree**, not a mounted or
POSIX-compatible filesystem. `workspace-drive` owns storage, hierarchy, revision
conflicts, mutation receipts, bounded reads/search, and garbage collection.
`WorkspaceApi::drive` is the trusted Server adapter. The authorized HTTP API and
Backend-managed grants are documented below. Worker Tools/WIP, Web UI, and full
end-to-end routing belong to T-723–725.
Session attachments remain Session-owned and are not Drive authority. Worker,
Session, and Workdir cleanup do not delete Drive.
Only a single Server Host with persistent local disks is supported initially.
Multiple processes must use **the same metadata DB and blob root**. Separate
Hosts/independent DB copies may not write the same Drive. Workers on other Hosts
use the authorized API, not direct storage access. S3 deployment/migration,
POSIX/inode semantics, histories, mounts, and distributed quotas are not included.
## Configuration and persistent volumes
Server startup uses the existing resolved Yoi data directory. `ServerConfig` keeps
that resolved path in `data_root`; Drive code does not read environment variables
or introduce another default. At the default layout:
```
$YOI_DATA/drives/<internal-workspace-id>/blobs/<opaque-UUID>
$YOI_DATA/server/server.db
$YOI_DATA/server/feature-storage/<internal-workspace-id>/features/workspace-drive.sqlite
```
The FeatureStorage root is derived from the authority database path using the
existing Host convention; it is **not** placed below `drives`. Custom database
placements remain possible: configure the trusted `ServerConfig.data_root`
explicitly and persist both locations. Bind `$YOI_DATA` (and any custom database
root) to persistent volumes, not container writable layers, Worker checkouts,
or temporary directories. The storage identifier is the immutable internal
Workspace ID, never its display name or a user-supplied path. No blob key,
physical path, credentials, or SQL surface is included in node/read DTOs. Internal
storage errors contain diagnostics for Host logs; the authorized API adapter must
map them to sanitized failure responses, not expose raw error strings.
The directories and every ancestor are Host-owned and must not be writable by
untrusted actors. Existing symlink components, symlink blobs, non-directory
ancestors, nested directories, and path traversal are rejected. This is not a
sandbox against a hostile local administrator replacing paths concurrently:
`LocalFileSystem` uses path-based operations, not descriptor-relative no-follow
operations. External edits of the store are outside the Drive operation contract.
## Metadata and tree contract
Migration 1 registers `workspace-drive` through FeatureStorage. Each scoped DB
contains its Workspace binding, nodes, deletion tombstone, and mutation receipts.
SQLite `AUTOINCREMENT` node IDs are stable, scoped to a Workspace and never
reused after deletion. They are opaque application IDs, not inodes. IDs and
revisions serialize as canonical positive **decimal strings**, preserving signed
64-bit precision across JavaScript DTO boundaries. Restore preserves
`sqlite_sequence` as well as IDs and revisions.
The DB is the only tree/latest-reference authority. Each node has one parent;
root is the sole parentless directory, has empty name, and cannot be renamed,
moved, deleted, or written. All other names are 1–255 UTF-8 bytes, compared
case-sensitively with SQLite binary equality. No Unicode normalization or case
folding is performed: canonically equivalent strings are distinct. Japanese is
supported. Control characters, `.`, `..`, slash and backslash are rejected. Names
never become blob paths. Same-parent names are protected by a unique DB index.
Parents must be directories in this scoped Workspace. Self/descendant moves are
rejected. Ordinary folder deletion is **empty-only**, never implicitly recursive.
Workspace destruction is the separate fenced purge operation.
Every update/move/delete requires `expected_revision`. A stale revision or
same-name/request-identity competitor returns `Conflict`; it never falls back to
unconditional writes. Root mutations and malformed requests are `Invalid`;
missing/deleted IDs are `NotFound`. Revision exhaustion fails rather than wrapping.
Actor identity is trusted caller input (the authorization adapter supplies it),
and successful mutations record actor and UTC epoch milliseconds.
`BEGIN IMMEDIATE` is held for validation, blob operations, metadata mutation and
receipt insertion. It serializes the whole Workspace hierarchy and all blob
read/upload/GC activity across independent processes. A→B/B→A, parent deletion
versus child creation, and same-revision updates therefore have one valid winner.
FeatureStorage configures foreign keys, WAL, `synchronous=FULL`, and a 5-second
busy timeout. Busy/SQL/I/O failures remain errors; they do not mean an empty Drive.
Before opening SQLite or admitting a Feature handle, FeatureStorage creates and
syncs **every metadata directory ancestor link**, including configured path and
resolved target chains for trusted Host symlink paths. It re-syncs existing
directories on retry after any prior flush failure. Directory sync failure aborts
admission before DB initialization/migration; no usable connection is cached.
SQLite's DB/WAL and immediate-directory synchronization alone would not protect
new FeatureStorage/Workspace ancestors. Both this metadata link durability and the
separate blob link durability below are required for acknowledged Drive success.
The Server uses `Drive::open_with_workspace_authority` to attach its trusted
`server.db` to the Feature DB connection. An IMMEDIATE transaction also locks that
attached authority, and checks that the Workspace remains `active`. Consequently
a cached Drive handle cannot mutate after the deletion reservation commits, even
before the separate Drive tombstone is persisted. Drive never writes the
attached authority; only its metadata DB is changed. This conservative initial
implementation also serializes against other Server DB writes during blob I/O.
There is no cross-DB atomic write promise or distributed transaction: only the
Drive DB is written. Long uploads/searches can increase Server write contention;
all calls are blocking and async callers must use `spawn_blocking`.
## Blob publication and durability
Content creates/updates choose a fresh canonical UUID blob key unrelated to names
or parents. `object_store` **0.13.2**, with the `fs` feature and without cloud
features, supplies `local::LocalFileSystem`. `PutMode::Create` forbids overwrites.
Rename/move change metadata only; no blob move/copy/stat is needed. List/metadata
queries are SQL-only and still work if a referenced blob is missing, whereas
content reads diagnose the missing reference.
The pinned local implementation writes a `create_new` staging file, publishes
Create with `hard_link`, and best-effort removes staging. Inspection of 0.13.2's
local implementation found **no `with_fsync` option and no fsync** ([versioned
source](https://docs.rs/object_store/0.13.2/src/object_store/local.rs.html)).
The dependency is pinned to 0.13.2 because staging cleanup also depends on its
local filename protocol; reinspect publication/staging/durability before upgrading.
Atomic
publication is not power-loss durability. The small adapter compensates:
1. Create/validate Host directories; `sync_all` their entries and parents.
2. Use LocalFileSystem to atomically create the immutable blob.
3. `sync_all` the published file, then its containing directory.
4. Only after both succeed, update the DB reference/revision and insert the
receipt in the **same SQL transaction**.
5. SQLite commit is the sole success/publication point.
Delete also syncs the directory, including on a missing-file retry. Sync, disk
full, permissions, and other errors propagate; metadata is not committed if blob
persistence fails. A failed sync may leave an unreferenced blob for GC. If DB
commit fails, the old reference remains and the new blob is likewise reclaimable.
There is no external-store/DB two-phase commit or bespoke object-store engine.
Supported durability target: **Linux, local persistent filesystem with working
file and directory fsync and SQLite WAL locking**. Filesystems/drivers that do not
support these calls fail rather than silently downgrade. Windows, network
filesystems, and other OS/storage combinations have not been qualified. Disk
controllers and devices must honor flushes. Unit tests verify ordering/failure
semantics with injected errors and actual isolated files/SQLite; they are **not a
power-cut experiment** and do not prove hardware power-loss behavior.
## Request result reconciliation
Every mutation includes a bounded request identity (1–128 bytes) and trusted
actor (1–256 bytes). A SHA-256 fingerprint covers the actor and typed mutation,
including content. Its committed result is stored with metadata in the same
transaction. Replaying the exact same request returns the recorded result;
reusing the ID with different input/actor is `Conflict`. Results are retained for
the Workspace lifetime, including results referring to later deleted nodes.
There is no clock-based deduplication window or partial FS-recovery engine.
After response loss or uncertain SQL/I/O errors, query `request_status`:
`Committed` includes the original result; `Uncommitted` means no committed row in
that authority snapshot. It is **not proof that an in-flight concurrent request
can never commit**. Retry with the same identity/payload, never manufacture a new
ID to resolve an unknown outcome. Failed/uncommitted operations have no success
receipt. A committed result can still be queried/replayed after the Drive is
fenced; it does not authorize new mutation.
## Bounds, pagination, reads and search
Hard limits are exported constants, not additional environment variables:
| Operation | Limit |
|---|---:|
| File create/update | 16 MiB (empty files supported) |
| Binary/text read chunk | 1 MiB, positive requested length |
| List page / GC page | 200 nodes / 200 blob keys + up to 200 staging files |
| Search work per page | 128 nodes |
| Text inspected per candidate | 64 KiB |
| Search query | 256 UTF-8 bytes |
| Name | 255 UTF-8 bytes |
| Content type | 128 ASCII bytes, media-type separator required |
No offset writes or shared write handles exist; whole content is replaced. Size,
invalid bounds and invalid UTF-8 errors are explicit `Invalid`; storage/capacity
errors remain failures for the API to present. There is no quota reservation.
Request and node/receipt volume remain bounded by available Host disk capacity;
operators must monitor free space for both blobs and DB/WAL. Files/receipts can
consume space until GC/Workspace deletion; there is no distributed quota system.
List uses ascending stable ID keyset pagination. Metadata is not a multi-call
snapshot: concurrent nodes may appear/disappear and relocation can change page
membership. Continuation is the last examined ID, not an offset or name. Search
uses the same non-snapshot ID semantics, examining at most its work budget per
page; an empty matching page can still have continuation. Name search is exact
substring matching. Optional bounded text search inspects only UTF-8 `text/*`
files no larger than 64 KiB, never images/large files. No index/embedding exists.
Each read supplies node ID **and the selected revision**, and is locked to that
blob for the entire chunk. The SQLite lock excludes upload/GC while reading.
After an update the old revision expires: the next chunk returns `Conflict` (or
`NotFound` after deletion), not the new generation. No long-lived read lease or
full-history retention is promised. Binary ranges are byte-based and exact;
text reads require UTF-8 character-aligned offsets/ranges and reject split or
invalid characters. Missing referenced blobs and length inconsistencies are
errors, including missing zero-byte files, never successful empty content.
## Garbage collection and restart
GC takes the **same SQL IMMEDIATE lock** as readers/uploads. Thus there is no
in-flight upload while sweeping; every currently referenced key is checked in
metadata before deletion. A grace period or process-local mutex is not a safety
proof. After a process crash SQLite releases/rolls back the transaction; an
unfinished staging file or fully saved but uncommitted blob can be reclaimed on
a later sweep. Recognized 0.13.2 staging names are cleaned under that lock; foreign
files are not interpreted as blobs. Deleted/replaced blobs are reclaimed too.
`collect` is explicit Host maintenance, not a background task tied to a Worker.
Repeat its cursor pages, then start future sweeps at `None` because new UUID keys
can sort before the preceding cursor. Local object listing necessarily traverses
the flat blob directory; response/deletion batches and candidate memory are
bounded, unlike SQL list which does no blob traversal. Holding the write lock for
local listing is a conservative safety/throughput tradeoff. A corrupt or redirected
storage layout fails closed. GC never deletes a key referenced by the DB.
## Workspace deletion
Deletion reservation in Server authority changes the Workspace to `deleting`.
The attached authority fence immediately rejects new mutations from cached Drive
handles. Deletion start also persists the Drive tombstone before responding or
scheduling cleanup; API restoration reapplies it for blocked/failed deletions.
Purge commits that fence first, removes only this Workspace's blobs/staging,
clears its nodes/receipts, then Host removes the blob directory and calls
FeatureStorage's scoped delete. The feature scope's admission fence prevents
cached tasks recreating DB files during finalization. Failures retain a retryable
Server deletion operation; repeating purge/fence/delete is safe. Other Workspace
roots are never traversed or removed. Root/tombstone remain in the domain DB until
the Host removes the scope; there is no normal client recursive folder deletion.
## Consistent backup and restore (offline initial procedure)
A FeatureStorage DB snapshot **alone is not a Drive backup**. No online
DB-plus-object snapshot is implemented. Use this administrative procedure:
1. Stop request admission, **all** Server processes sharing the authority,
uploads/readers, maintenance/GC and deletion. Do not merely stop Workers.
Complete or reconcile in-flight requests before backup.
2. Checkpoint/close the metadata DB using SQLite/FeatureStorage shutdown or use
its SQLite backup API while all Drive writers and GC remain stopped. Never
copy an active DB and omit its WAL. Capture the existing FeatureStorage scoped
metadata tree (or its snapshot manifest + databases), Server authority and
other deployment-required secrets/config using their respective procedures.
3. Copy the **same stopped generation's blob tree** with the metadata. Include
all referenced immutable objects; copying unreferenced blobs/staging is safe
and later GC can reclaim them. Preserve the internal Workspace ID. Record
both roots, object_store/schema version and checksums in an operator manifest;
restrict backup permissions because it contains user documents.
4. Verify each file node's blob exists and length matches `size`; missing content
is a corrupt backup, not an empty file. Flush/sync the backup using the backup
destination's own durability facilities before treating it as complete.
5. Restore only with admission/writers/GC still stopped, into absent FeatureStorage
scope and Host blob roots. Use `ScopedFeatureStorage::restore` for its snapshot
format, or restore the entire stopped deployment consistently. Never restore
just `server.db` or create a new empty Drive DB over surviving blobs.
6. Restore blobs from the matching generation; restore internal IDs, revisions,
receipts and `sqlite_sequence` unchanged. Validate references/lengths and
sanitized metadata/text/binary reads before reopening admission. Resume GC
only after validation. A deletion tombstone must not be cleared on restore.
The hermetic backup test stops writers, snapshots actual FeatureStorage SQLite,
copies actual LocalFileSystem blobs, restores into absent roots, and checks stable
IDs/latest content/receipts and non-reused ID allocation. It does not validate a
production backup scheduler, volume snapshot, or physical power cut.
## Authorized typed HTTP API
The Rust `server-api` contract owns `/api/w/{workspace_id}/drive/` operations:
- `GET root`, `metadata`, `list`, `search`, `read-text`, `read-chunk`, `download`;
- `POST mutate` (create folder/bounded UTF-8 text, replace text, relocate, delete);
- `PUT upload` (binary body with flat query metadata);
- `GET requests/{request_id}` for the committed receipt snapshot;
- owner-controlled `POST/GET grants` and `DELETE grants/{grant_id}`.
Ordinary usage follows the existing member-facing authenticated Server-user
policy. Non-ownership alone does not deny use. Grant management follows the
existing Workspace-owner account boundary. No new general membership ACL is
introduced. Browser/API-token identity is supplied by authentication middleware;
user/account existence and active Workspace state are checked again in the
operation transaction. Cookie mutations require the configured application
Origin through the existing middleware. No ordinary DTO accepts an actor.
Runtime-forwarded requests require the existing signed source proof and live
Runtime/Worker catalog membership. Workers receive an explicit whole-Drive
`read_only` or `read_write` grant, independently of Profile, Subject, Ticket,
Workdir or commands. Incomplete/mismatched Runtime or Worker identity cannot fall
back to a browser actor. Grant creation rechecks owner, active Workspace, and
existing live Worker in the Server DB transaction; active access changes require
revoking the previous grant first (no silent downgrade). Migration 84 stores
creation/revocation actor and time. Removal fences and catalog deletion revoke
access and retain audit; grant rows cascade with Workspace deletion. No alternate
Worker registry or Drive Workdir attachment is created.
A clone-bound Drive authorizer queries the attached Server authority under every
client-operation `BEGIN IMMEDIATE`, including receipt replay/result queries.
The lock spans validation, blob persistence and DB publication. Grant revocation
and Worker removal use the same Server DB write authority: whichever transaction
wins first is ordered first. A mutation committing before revocation is valid;
a revoked cached handle cannot commit later. Upload admission also checks access
before consuming the body, but that admission is not a lease: publication always
rechecks current access. Download checks authorization separately for every
chunk, and stops on revocation, deletion, revision expiry or storage failure.
References contain **both** Workspace ID and stable decimal node ID. JSON uses
`DriveEntryRef`; flat GET/binary queries require `entry_workspace_id` matching the
route Workspace before resolving `id`/`parent_id`. IDs/revisions are canonical
positive decimal strings within signed 64-bit range; file sizes/offsets are
bounded `u32`. List/search cursors carry Workspace identity and the last node ID;
they are continuation hints, not capabilities or snapshots. Returned `latest_url`
is a relative authenticated URL bound to Workspace and ID, independent of logical
name/parent. It confers neither public sharing nor approval. File URLs download;
folder URLs return metadata. Deletion/recreation never reuses the old reference.
Web upload consumes binary HTTP frames with an observed **16 MiB** ceiling and
checks declared size and lowercase SHA-256 before publication. Oversized announced
or received data, mismatches, aborted bodies and I/O errors never return success.
The storage boundary currently requires a whole-file Vec, so uploads retain at
most one bounded binary file in memory before calling T-721; they are not
constant-memory object-store multipart uploads and are never expanded to
JSON/base64. Runtime source-proof verification separately buffers a bounded body
before granting trusted ingress, as in other signed Server operations. Web
downloads stream **64 KiB** revision-fixed chunks; they do not materialize the
whole file. Generated Rust clients retain their explicit bounded-response policy
and return exact `BinaryBody` bytes. Worker JSON-facing adapters can request
bounded text or binary chunks through this same contract, but Tools/WIP and
model-context/image presentation belong to T-723; this API does not emit large
base64 JSON blobs.
Downloads validate bare MIME types and always use `Content-Disposition:
attachment` with percent-encoded UTF-8 names, `nosniff`, a sandbox/default-none
CSP, `private, no-store`, and a Workspace/node/revision ETag. Arbitrary HTML/SVG
bytes are storable, not safe app-origin inline previews. Content length is exact;
if a later chunk loses authority or expires, the response stream fails rather
than returning another generation or a successful truncated file. Even an empty
file validates its referenced blob before HTTP success. Conditional 304/range
HTTP semantics are not added; revision-selected chunk reads are explicit.
Errors use fixed, path-free typed codes: `denied`, `not_found`, `conflict`,
`invalid`, `limit`, `storage_unavailable`, `outcome_unknown`. SQL/commit/task
uncertainty at a mutation boundary is `outcome_unknown`/`unknown`; known rejection
or blob persistence failure is `not_committed`. A successful DB commit alone
returns a mutation response. After a lost response, query the same request ID;
`uncommitted` only means no committed receipt in that snapshot and must not be
interpreted as proof that an in-flight request can never commit. Exact replay uses
the same identity/payload/transport-bound actor and current permissions. Never
blindly create a fresh request ID to resolve an unknown outcome. Physical paths,
blob keys and raw provider errors are not public.
Contract regeneration/freshness:
```sh
cargo run -q -p server-api --example export_openapi -- openapi/server-api.json
cargo run -q -p server-api --example export_openapi -- --check openapi/server-api.json
cargo run -q -p server-api --features typescript --example generate_drive_api_types > web/workspace/src/lib/generated/drive-api.ts
deno fmt web/workspace/src/lib/generated/drive-api.ts
cargo test -p server-api --features typescript generated_drive_api_contract_is_current
# Existing OpenAPI-derived projections embed the whole-contract digest:
for example in generate_repository_openapi_types generate_runtime_api_types generate_ticket_api_types generate_companion_api_types generate_worker_launch_api_types; do
cargo run -q -p server-api --example "$example"
cargo run -q -p server-api --example "$example" -- --check
done
```
The existing BinaryBody contract also supports binary **success responses**,
including declared response headers, without changing binary request bytes or
JSON/error behavior. It exports inline OpenAPI binary bodies, not JSON byte arrays.
@@ -0,0 +1,124 @@
# Scoped WIP integration validation
## Released dependency and resolution
The adopted Client, HTTP, Protocol and official Text View are **crates.io 0.2.0**,
with exact `=0.2.0` workspace declarations and registry checksums in Cargo.lock.
`cargo info <package>@0.2.0` confirmed all four published packages (Rust 1.88).
`node scripts/verify-wip-dependencies.mjs` checks actual locked/offline Cargo
resolution: all WIP packages use registry 0.2.0, each required package occurs
once, and Protocol has one source. No path/Git override or attached checkout is
used. The earlier unreleased 0.1.0 source snapshot and vendor verifier were
removed when T-713 thread sequence 26 specified 0.2.0.
Canonical contract reference: wip-reference
`6086f3c5ef10aa1464ce9c824750d7f667217bfd`. This reference documents the contract;
Cargo resolves the release, not that checkout or an unreleased Rust revision.
## Executed checks on registry 0.2.0
- `cargo test -p worker --locked --offline`: **894 unit + 110 integration tests
passed**, no ignored/failed tests. This includes ordinary Tools mode, Feature
permission/revocation, restore, session/history and controller integration.
- Narrow development checks: `wip::binding` (25), `wip::provider_tests` (12), existing
`wip::tests` and workdir projection tests (43), checkout transports/providers
(20), workspace-config production/authority tests (19). These are included
in the complete worker run.
- Published SDK tests via `cargo test -p wip-client -p wip-http -p wip-protocol
-p wip-text-view --locked --offline`: Text View **16 public signature/token/golden
tests + 4 doctests**; Client **63 adapter/runtime tests**; HTTP **38 tests + 1
doctest**; Protocol **22 tests + 1 doctest**. Tests run from the registry packages,
not substituted source or removed vendor directories.
- Root `cargo check --locked --offline` passed, including normal TUI, CLI,
Runtime and Workspace Server compile closure.
- `cargo fmt --all -- --check` and `git diff --check HEAD` passed.
- `nix build --no-link .#yoi.cargoDeps` passed with the updated registry dependency
hash. This validates credential-free dependency acquisition, not a full Nix
binary/image build or deployment update.
## Current target compatibility (preview, not integration)
Source runtime at `23cf6c4f882ce78985ba4bd673751cebe64e2d87` and current develop
`b5ea162f838fc36e364edec9ab6689292b59cffd` produce the conflict-free `git merge-tree`
preview tree `7ef3545b635cbdfc5e24658f6d1532e0f8b0947c`. An archive of this tree,
without a branch/ref change, passed root locked/offline Cargo check, all **91**
Server API TypeScript tests (including OpenAPI/checked-in generator freshness),
registry WIP resolution checks, and Nix dependency acquisition with the same hash.
T-722's API macros, Server API/Drive/Server source, OpenAPI and generated TS blobs
are unchanged from develop in the combined tree; no artifact overwrite or
regeneration is necessary for this source change. The later added same-name scope
regression changes tests only, not the validated runtime or shared contracts.
The initial archive check reused a Cargo target directory; returning to the older
main checkout exposed stale preview Server API artifacts. All local workspace
artifacts were invalidated, and a fresh main root check and complete **894 + 110**
Worker suite passed. Use an isolated target directory for future cross-tree checks.
No target push, integration branch, deployment, or live dogfood update was made.
Orchestrator readiness/integration must still resolve the then-current target.
## Contract evidence
`crates/worker/src/wip/binding.rs` tests exercise fresh/loading/stale/failed and
capacity-blocked acquisition, explicit refresh, superseded responses, retrieval
ownership cleanup, output limits, direct unseen-path Inspect, official unmodified
signature/path envelopes, structured Interface input and complete edge-only trees.
They invoke the registered three-tool surface for a search returning typed Entry
paths, inspect only a selected nonindexable path, dispatch the selected operation,
and verify that a later Inspect failure neither fails nor replays an earlier
successful operation. Interface acquisition failures are not empty operation lists.
`provider_tests.rs` exercises the actual Yoi adapter for root/self scope, optional
refs, copied scope_ref, different scopes with identical names/operations, shape vs
membership errors, validator/ref absence/mismatch and protocol failure precedence.
It replaces a dynamic scope Object identity, observes both stale sides after
InterfaceMismatch, reacquires without replay, and confirms direct NotFound ends
safe Interface reuse. An in-flight operation survives descriptor refresh using
its exact frozen Host and Client descriptors rather than the newer return type.
`ancestor_tests.rs` covers the independent review finding P1_CURRENT_ANCESTOR_SCOPE:
valid live ancestor refs that differ from registration placeholders, root `/` and
intermediate provider-owned ancestors, ref-less and ref-bearing scope deletion
without a prior scope Inspect, rejected cached dispatch and orphan observation,
replacement/republication without restoring old Interface registrations,
same-name scope replacement or loss of public ref rejecting copied old metadata
without replay (and later explicit Inspect/Invoke copying only the new optional ref),
shape/target/Object-validator/Interface-mismatch precedence, and frozen in-flight
ancestor Descriptor/handler/result validation after replacement. Valid Invoke
makes one provider publication call for the target, not a separate scope lookup.
Existing checkout tests retain commit-time captured Workdir/connection/validator
checks, alias reuse/restore fencing, inode replacement rejection, authorization,
shared prior-read tracking, exact post-operation validators, cooperative
cancellation and unknown/lost/malformed responses without retries. Existing native
Ticket/Objective/Merge Request tests retain scoped Workspace authority and Feature
permission separation; compatibility Invoke retains original schema validation,
permission identity and ToolOutput/attachments.
## Downstream provider seams
- Public projection/contribution Interface fields use
`wip_protocol::InterfaceReference { scope, name }` from registry 0.2.0.
- `WipSubtreeProvider::publication` returns a `WipPublication` that captures target,
current scope Object, Descriptor/validator and handler at one provider boundary.
Mutable ancestors must not be resolved independently after selecting a target.
`scope: None` ends usable publication, even without a public ref; it is not a
ref-less published Object. Host checks and observation never substitute the
root registration placeholder, and Interface fetch never falls back to retained
provider registrations. Old registrations must end when their scope lifetime
ends. Published ancestor scopes outside the subtree remain Host-owned.
- Contextual/self-scope publishers use `WipPublication::self_scoped` on their exact
provider snapshot; references are explicit paths, not an encoded `/@/` suffix.
Checkout and Workspace Config retain their captured provider/Backend execution
and commit fences. Ref-less publication remains legitimate.
- `WipRuntime::{tree, inspect, invoke}` is the object-centered integration seam.
Inspect uses official summary-only signatures and typed rendering failures.
- `WipMountRegistry::mount_interface` adds a separate static Interface namespace
without replacing Object ownership or flattening same-named operations. It does
not overlay stale registration metadata onto a live subtree provider.
- FS indexable boundaries and directory.list remain provider policy. This change
deliberately does not implement the separate T-714 boundary/list migration.
Relevant transport/commit tests are `checkout_wip_tests.rs`,
`checkout_http_tests.rs`, and `checkout_race_tests.rs`.
- Worldspace state is runtime-only. Historical opaque references in append-only
session/tool history are not migrated into cache or parsed for a guessed scope;
a restored Client starts with fresh observations and direct path Inspect.
+18
View File
@@ -0,0 +1,18 @@
# T-722: SubWorker cwd and logical path friction
During delegation from the assigned repository Workdir, a child started with
`cwd: crates/server-api` and readable policy paths under repository-root
`docs/development`. Its Read calls using repository-relative logical paths could
not read the required testing strategy; absolute paths were rejected as invalid
logical paths too. The child correctly stopped without edits. The parent
inspected the committed child session, stopped that idle child, and recreated it
with repository-root cwd and explicit read-only repository scope plus write scope
limited to server-api. Policy reads and implementation then succeeded.
The current observable behavior appears to root child filesystem tools at the
selected cwd, rather than making cwd only the command default as the spawn-tool
description states. This makes repository-root logical paths surprising. Consider
keeping the attachment root invariant and applying cwd only to command execution,
or documenting/returning a distinct filesystem base explicitly. Do not encourage
absolute paths, parent traversal, or broader write grants as a workaround. This
report records a tool usability issue, not additional Ticket requirements.
+2341 -7
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -43,7 +43,8 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter;
};
cargoHash = "sha256-LjswbsalQA0Yny0DIBJg3FiqwdmTADZ1CnqNJ9oo9Kw=";
# All four WIP SDK crates resolve from crates.io 0.2.0 via Cargo.lock checksums.
cargoHash = "sha256-L1RR+zdcVxMGvEonGxERgcvzjxzXro4n2j2syBeR/m0=";
depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint,
+17
View File
@@ -0,0 +1,17 @@
import "./default.dcdl" // {
slug = "ticket-worker";
description = "General Ticket work with typed progress and completion tools.";
engine = {
instruction = "role.ticket_worker";
};
feature = {
ticket = {
enabled = true;
authoring = false;
thread = true;
intake = false;
workflow = false;
};
};
}
+2
View File
@@ -15,6 +15,7 @@ commonWorkerOrchestration = import "./common/worker-orchestration.md";
commonWorkspace = import "./common/workspace.md";
commonWriting = import "./common/writing.md";
roleCoder = import "./role/coder.md";
roleTicketWorker = import "./role/ticket-worker.md";
roleIntake = import "./role/intake.md";
roleOrchestrator = import "./role/orchestrator.md";
roleReviewer = import "./role/reviewer.md";
@@ -55,6 +56,7 @@ in
};
role = {
coder = roleCoder.content;
ticket_worker = roleTicketWorker.content;
intake = roleIntake.content;
orchestrator = roleOrchestrator.content;
reviewer = roleReviewer.content;
+1 -1
View File
@@ -2,7 +2,7 @@ You are the Ticket Orchestrator role.
{% include "common.git" %}
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. For an actionable `queued` Ticket, select exactly one current Workdir for every declared Ticket target and call `SpawnTicketCoder` once with the complete alias-keyed `workdir_attachments` collection; use stable repository-derived aliases and grant Backend-derived write capability to every `read_write` target. Never omit a target, attach an undeclared repository, or request attachment capabilities: Ticket access and Backend validation are authoritative. Call `SpawnTicketCoder` without first recording `inprogress`: the guarded Worker creation operation commits the fixed Coder profile/Flow, initial input, one current assignment, every Workdir finalization, and only then the authoritative `queued -> inprogress` acceptance. If any attachment, Runtime, assignment, or finalization step fails, leave the Ticket queued and do not report accepted implementation. After spawn, reread the Ticket and verify both `inprogress` and that its current assignment names that Coder before asking it to implement; never route implementation to an unassigned Coder. Route implementation work to sibling Coder Workers. The human `ready -> queued` transition delegates ordinary implementation, publication from any required `read_write` target work branch, guarded integration of the approved linked Merge Requests, and guarded Ticket completion to the Workspace Orchestrator by default. This queue delegation does not grant broader repository authority from launch prose. Stop only when the Ticket explicitly records a separate approval gate or completion requires a new decision outside the queued scope.
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Use typed Ticket tools and current repository state as authority. Explicitly start bounded Ticket work with `SpawnTicketWorker`, selecting a registered `profile`, an `initial_request`, an optional `flow`, and alias-keyed `workdir_attachments`. Work assignment is independent from the selected Profile: ordinary investigation can use `builtin:default` without a Flow or Workdir; Git implementation can select `builtin:coder` with `builtin:coder-review`. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Do not first record `inprogress`: the guarded operation accepts initial input, assignment, and attachment finalization before recording start. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. After start, reread the Ticket and verify `inprogress` and `current_worker` before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope.
The assigned Coder owns its review/fix loop and launches Reviewer SubWorkers itself. Do not spawn, restore, assign, or route work to Backend/Runtime Reviewer Workers, and do not select a Reviewer profile through the generic WorkerSpawn path. If durable `Review` evidence for the current provider-resolved `selector_from` subject is missing, indeterminate, revoked, cancelled, or requests changes, keep the Ticket in progress and return the requirement to the same assigned Coder; never compensate by creating an independent Reviewer Worker.
+7
View File
@@ -0,0 +1,7 @@
You are the assigned Ticket Worker. Carry out the user's requested work and keep durable progress and completion evidence on the Ticket. Ticket work may be research, analysis, planning, documentation, or other non-code work; do not assume a repository, Workdir, Flow, git operation, Merge Request, or review cycle is required.
Read the current Ticket with `ShowTicket` before acting. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it.
Use `TicketComment` for useful natural-language progress, results, blockers, and decisions that need attention. Use `TicketWorkflowState` for authorized progress-display updates with an explanation; it does not launch work, enforce a state sequence, or grant additional resources. When the requested work meets its acceptance criteria, use `CompleteTicket` with a natural-language reason describing the result and supporting evidence, and optional typed references when relevant. Use the current item revision and expected state from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing.
Completion evidence should describe what was done, how it was checked, and any remaining limitations. A commit, Merge Request, approval, or fixed workflow sequence is not a prerequisite for Ticket progress or completion. Do not claim work or validation that did not occur. Ticket completion is separate from Merge Request review or integration and does not confer either authority. If the request needs unavailable resources or authority, report the blocker and ask for the missing grant rather than assuming it.
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env node
// Verify actual Cargo resolution, not just version strings in workspace manifests.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const cwd = fileURLToPath(new URL('../', import.meta.url));
const host = execFileSync('rustc', ['-vV'], { cwd, encoding: 'utf8' })
.match(/^host: (.+)$/m)?.[1];
assert.ok(host, 'rustc must report the validation host');
const metadata = JSON.parse(execFileSync('cargo', [
'metadata', '--locked', '--offline', '--format-version=1', '--filter-platform', host,
], { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }));
const expected = ['wip-client', 'wip-http', 'wip-protocol', 'wip-text-view'];
const source = 'registry+https://github.com/rust-lang/crates.io-index';
const sdk = metadata.packages.filter(pkg => pkg.name.startsWith('wip-'));
for (const pkg of sdk) {
assert.equal(pkg.version, '0.2.0', `${pkg.name}: no old WIP version is permitted`);
assert.equal(pkg.source, source, `${pkg.name}: must resolve from crates.io, not a path/Git substitute`);
}
for (const name of expected) {
assert.equal(sdk.filter(pkg => pkg.name === name).length, 1, `${name}: expected one package source`);
}
console.log(`Verified ${expected.join(', ')} at crates.io 0.2.0; one Protocol source (${host})`);
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -0,0 +1,203 @@
// Generated from server-api. Do not edit by hand.
// Regenerate: cargo run -q -p server-api --features typescript --example generate_drive_api_types > web/workspace/src/lib/generated/drive-api.ts
export type DriveEntryRef = { workspace_id: string; node_id: string };
export type DriveEntryKind = "folder" | "file";
export type DriveEntry = {
entry: DriveEntryRef;
parent: DriveEntryRef | null;
name: string;
kind: DriveEntryKind;
revision: string;
/**
* File length in bytes; absent for folders.
*/
size: number | null;
content_type: string | null;
updated_by: string;
/**
* Timestamp projected from storage's updated_at_ms; not a creation time.
*/
updated_at: string;
/**
* Stable relative authenticated URL in this same Workspace. No bearer token
* or cross-Workspace link is embedded in this metadata.
*/
latest_url: string;
};
export type DriveEntryQuery = { entry_workspace_id: string; id: string };
export type DriveListQuery = {
entry_workspace_id: string;
/**
* Folder to list.
*/
id: string;
limit: number | null;
/**
* Opaque service-issued page cursor, not a bare node ID.
*/
after: string | null;
};
export type DriveSearchQuery = {
query: string;
/**
* Search file text as well as names only when explicitly requested.
*/
include_text: boolean;
limit: number | null;
after: string | null;
};
export type DriveListResponse = {
entries: Array<DriveEntry>;
next_after: string | null;
};
export type DriveReadTextQuery = {
entry_workspace_id: string;
id: string;
max_bytes: number;
};
export type DriveReadTextResponse = {
entry: DriveEntry;
text: string;
truncated: boolean;
};
export type DriveReadChunkQuery = {
entry_workspace_id: string;
id: string;
/**
* Bind a multi-chunk read to the same file revision.
*/
expected_revision: string;
offset: number;
length: number;
};
export type DriveDownloadQuery = {
entry_workspace_id: string;
id: string;
expected_revision: string | null;
};
export type DriveMutation = {
"operation": "create_folder";
parent: DriveEntryRef;
name: string;
} | {
"operation": "create_text";
parent: DriveEntryRef;
name: string;
text: string;
content_type: string;
} | {
"operation": "update_text";
id: DriveEntryRef;
expected_revision: string;
text: string;
content_type: string;
} | {
"operation": "relocate";
id: DriveEntryRef;
expected_revision: string;
parent: DriveEntryRef;
name: string;
} | { "operation": "delete"; id: DriveEntryRef; expected_revision: string };
export type DriveMutationRequest = {
request_id: string;
mutation: DriveMutation;
};
export type DriveMutationResponse = {
request_id: string;
/**
* A deleted entry has no live metadata.
*/
entry: DriveEntry | null;
};
export type DriveUploadOperation = "create" | "update";
export type DriveUploadQuery = {
operation: DriveUploadOperation;
request_id: string;
entry_workspace_id: string;
parent_id: string | null;
name: string | null;
id: string | null;
expected_revision: string | null;
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.
*/
size: number;
sha256: string;
};
export type DriveRequestState = "uncommitted" | "committed";
export type DriveRequestStatusResponse = {
request_id: string;
state: DriveRequestState;
response: DriveMutationResponse | null;
};
export type DriveAccess = "read_only" | "read_write";
export type DriveGrantCreateRequest = {
runtime_id: string;
worker_id: string;
access: DriveAccess;
};
export type DriveGrantResponse = {
grant_id: string;
workspace_id: string;
runtime_id: string;
worker_id: string;
access: DriveAccess;
revoked: boolean;
created_by: string;
created_at: string;
revoked_by: string | null;
revoked_at: string | null;
};
export type DriveGrantListQuery = {
limit: number | null;
/**
* Canonical grant ID cursor within this Workspace.
*/
after: string | null;
};
export type DriveGrantListResponse = {
grants: Array<DriveGrantResponse>;
next_after: string | null;
};
export type DriveApiErrorCode =
| "denied"
| "not_found"
| "conflict"
| "invalid"
| "limit"
| "storage_unavailable"
| "outcome_unknown";
export type DriveFailureClassification = "not_committed" | "unknown";
export type DriveApiError = {
code: DriveApiErrorCode;
classification: DriveFailureClassification;
message: string;
};
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -109,7 +109,7 @@ export type RuntimeSourceSummary = { identity_authority: RuntimeIdentityAuthorit
export type RuntimeSummary = { arch: string; diagnostics?: Array<Diagnostic>; host_ids?: Array<string>; kind: string; label: string; os: string; runtime_id: string; source: RuntimeSourceSummary; status: string; worker_creation_available: boolean; };
export type RuntimeTicketWorkerRole = "intake" | "orchestrator" | "coder" | "reviewer";
export type RuntimeTicketWorkerRole = "intake" | "orchestrator" | "worker" | "reviewer";
export type RuntimeTrustAuditAction = "created" | "replaced" | "reactivated" | "revoked";
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -81,17 +81,17 @@ export type ReviewDecision = "approve" | "request_changes";
export type ReviewFinding = { body: string; code?: string | null; line?: number | null; path?: string | null; severity: FindingSeverity; };
export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; principal: TicketAssignmentPrincipal; };
export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; principal: TicketAssignmentPrincipal; workdir_bindings?: Array<TicketWorkerAttachmentBinding>; };
export type TicketActionEligibility = { blockers: Array<string>; can_assign_orchestrator: boolean; can_queue: boolean; can_start_manual_coder: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array<string>; };
export type TicketActionEligibility = { blockers: Array<string>; can_assign_orchestrator: boolean; can_queue: boolean; can_start_manual_worker: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array<string>; };
export type TicketAssignmentPrincipal = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; };
export type TicketAssignmentRole = "orchestrator" | "coder" | "owner" | "contributor";
export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contributor";
export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; };
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; created_at?: string | null; current_coder?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; item_revision: string; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; item_revision: string; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; };
@@ -139,4 +139,6 @@ export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string |
export type TicketTargetAccess = "read_only" | "read_write";
export type TicketWorkerAttachmentBinding = { alias: string; connection_id: string; working_directory_id: string; };
export type TicketWorkflowState = "planning" | "ready" | "queued" | "in_progress" | "done" | "closed";
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0)
// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check