Author SHA1 Message Date
Hare 62ada5eaa4 fix: bound safe-boundary pause escalation 2026-08-27 23:57:20 +09:00
Hare f5ff0b7c13 fix: confirm bash cancellation cleanup 2026-08-27 23:01:06 +09:00
Hare 3337cafcdf feat: own cancellable tool execution lifecycle 2026-08-27 23:01:00 +09:00
Hare e87784118b fix: preserve resumable paused tool calls 2026-08-27 21:48:59 +09:00
Hare 40fada28ea fix: preserve confirmed output on interceptor abort 2026-08-27 21:30:04 +09:00
Hare 58cc94d4b7 fix: terminalize interrupted tool executions 2026-08-27 20:54:13 +09:00
Hare ccabea59c9 fix: terminalize parallel tool outputs on completion 2026-08-27 19:16:49 +09:00
Hare 183c37446e fix: fence running snapshots on input commit 2026-08-27 15:42:20 +09:00
Hare 7aa06afc45 chore: preserve reviewed source lineage
# Conflicts:
#	crates/agen/README.md
#	crates/agen/examples/engine_cancel_demo.rs
#	crates/agen/examples/engine_cli.rs
#	crates/agen/src/engine.rs
#	crates/agen/tests/annotated_history_test.rs
#	crates/agen/tests/callback_test.rs
#	crates/agen/tests/engine_fixtures.rs
#	crates/agen/tests/engine_state_test.rs
#	crates/agen/tests/parallel_execution_test.rs
#	crates/agen/tests/reasoning_round_trip_test.rs
#	crates/session-store/tests/session_test.rs
#	crates/worker/src/worker.rs
2026-08-27 15:13:27 +09:00
Hare 1515a2fb86 fix: reconcile typed history with run exits 2026-08-27 15:12:05 +09:00
Hare ec798c58d7 fix: preserve annotated history through rewind 2026-08-27 14:54:24 +09:00
Hare e365189276 feat: add provenance-aware worker history 2026-08-27 14:54:24 +09:00
Hare 116d610ad0 fix: project Ticket mutation outputs to human keys 2026-08-27 14:18:48 +09:00
Hare 75c570962d Merge commit '7edc588202dfbfd4c834f677f510ddda7f3d6451' into work/00001M10HW6BV-model-facing-resource-projection 2026-08-27 13:46:19 +09:00
Hare cae8ac1799 fix: allow missing Objective query snippets 2026-08-27 13:46:10 +09:00
Hare 917cc222a3 fix: resolve relation summaries to Ticket keys 2026-08-27 13:22:08 +09:00
Hare 7edc588202 chore: refresh T-528 after T-541 2026-08-27 13:09:44 +09:00
Hare c83461508b Merge commit '21b3dd1da1b1bbf18799a0623bf67dbe6266067c' into work/00001M10HW6BV-model-facing-resource-projection 2026-08-27 13:09:15 +09:00
Hare 374449e663 feat: add provenance-aware worker history 2026-08-27 12:51:01 +09:00
Hare 4c876a201b fix: validate projected resource keys canonically 2026-08-27 12:49:59 +09:00
Hare 21b3dd1da1 Merge commit '0496cd907bc7bb96e9aa1c6d385bedb616bf3233' into work/00001M10FJVA2-orchestrator-queue-notice 2026-08-27 12:46:27 +09:00
Hare 5ca0ea9228 fix: sanitize orchestrator queue attention 2026-08-27 12:46:10 +09:00
Hare d5c3a68a37 fix: use Ticket resource keys in handoffs 2026-08-27 12:45:08 +09:00
Hare 2b33b9158d chore: refresh T-528 against develop 2026-08-27 12:45:01 +09:00
Hare b31642e284 feat: project Ticket and Objective human references 2026-08-27 12:39:28 +09:00
Hare 3a7a3307ef fix: map internal worker terminal lifecycles 2026-08-27 12:27:55 +09:00
Hare 0496cd907b fix: use Ticket resource keys in handoffs 2026-08-27 12:24:19 +09:00
Hare 975b4fa700 feat: add typed engine run exits 2026-08-27 11:42:49 +09:00
Hare 060f280fdf fix: allow parent commands during write delegation 2026-08-27 11:27:27 +09:00
Hare 7aaf189247 fix: scope delegated commands by cwd 2026-08-27 11:11:06 +09:00
Hare 63306cf017 feat: integrate runtime Git cache materialization 2026-08-27 08:30:38 +09:00
Hare 08be5e85e4 fix: preserve command sessions across access refresh 2026-08-27 08:09:22 +09:00
Hare 0ab15aa227 chore: merge hare/develop into develop 2026-08-26 19:44:31 +09:00
Hare 29c2fb8e06 fix: refresh repository access for worker workdirs 2026-08-26 19:27:10 +09:00
Hare 4ebc465e8d fix: constrain repository SSH commands to authorized source 2026-08-26 19:02:20 +09:00
Hare 4b132a21e9 fix: bind repository SSH secrets to one-shot resources 2026-08-26 18:28:22 +09:00
Hare b644971d45 fix: preauthorize repository access without persisting secrets 2026-08-26 17:23:43 +09:00
Hare df34533765 fix: broker read-only repository SSH operations 2026-08-26 16:48:37 +09:00
Hare 108d5b14d7 fix: withhold write-capable credentials from read-only sessions 2026-08-26 16:23:48 +09:00
Hare 1873e18f8e fix: enforce attachment access transitions 2026-08-26 16:10:39 +09:00
Hare 3cdcbb47bf fix: preserve selector retries and workdir observations 2026-08-26 16:05:14 +09:00
Hare 471db64bcc fix: preserve repository access across retries 2026-08-26 15:21:52 +09:00
Hare ffb2a34ae5 fix: enforce repository access and cache boundaries 2026-08-26 14:39:37 +09:00
Hare 3a3c89e0b4 feat: materialize repositories through runtime Git cache 2026-08-26 13:54:25 +09:00
Hare 4e935c6203 test: refresh clean-build fixtures and assertions 2026-08-26 10:16:18 +09:00
Hare eac07cf5a8 fix: provide subworker control service 2026-08-26 10:16:10 +09:00
Hare 260259d461 fix: return ticket queue outcomes to web clients 2026-08-26 10:16:03 +09:00
Hare 9d572d18bc fix: accept reserved workers during initial flow resolution 2026-08-26 07:52:04 +09:00
Hare f7852e8034 fix: remove misleading runtime capability projections 2026-08-26 06:19:33 +09:00
Hare 8c075de147 fix: allow remote runtime workdir creation 2026-08-26 05:13:21 +09:00
Hare 864367f4f5 fix: remove repository-local server configuration paths 2026-08-26 03:56:35 +09:00
Hare 33db2ea7f4 fix: unify browser origin configuration 2026-08-26 02:37:14 +09:00
125 changed files with 13682 additions and 3171 deletions
Generated
+2
View File
@@ -6652,6 +6652,7 @@ dependencies = [
"workdir", "workdir",
"worker", "worker",
"workspace-api", "workspace-api",
"zeroize",
] ]
[[package]] [[package]]
@@ -6798,6 +6799,7 @@ dependencies = [
"worker", "worker",
"worker-runtime", "worker-runtime",
"workspace-api", "workspace-api",
"zeroize",
] ]
[[package]] [[package]]
+1
View File
@@ -125,4 +125,5 @@ toml = "1.1"
tracing = "0.1" tracing = "0.1"
url = "2.5" url = "2.5"
uuid = "1.23" uuid = "1.23"
zeroize = "1"
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
+1 -1
View File
@@ -21,7 +21,7 @@ services:
- "8787" - "8787"
volumes: volumes:
- server-data:/server-data - server-data:/server-data
- ./docker/workspace:/workspace:ro - /etc/yoi/server.toml:/server-config/server.toml:ro
webui: webui:
image: yoi-webui:latest image: yoi-webui:latest
+6 -5
View File
@@ -21,20 +21,21 @@ agen = { version = "0.2.1", features = ["codex"] }
## Quick start ## Quick start
Supply an implementation of [`LlmClient`](https://docs.rs/agen/latest/agen/llm_client/trait.LlmClient.html), then run a turn. The first call consumes the mutable engine and returns a cache-locked engine for later turns. Supply an implementation of [`LlmClient`](https://docs.rs/agen/latest/agen/llm_client/trait.LlmClient.html), keep conversation history in your application, then run a turn. The first call consumes the mutable engine and returns a cache-locked engine for later turns.
```no_run ```no_run
use agen::{Engine, EngineError}; use agen::{Engine, EngineError, History};
use agen::llm_client::LlmClient; use agen::llm_client::LlmClient;
async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> { async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> {
let mut history = History::new();
let output = Engine::new(client) let output = Engine::new(client)
.system_prompt("You are a concise assistant.") .system_prompt("You are a concise assistant.")
.run("Explain typed state in one sentence.") .run(&mut history, "Explain typed state in one sentence.")
.await?; .await;
let mut engine = output.engine; let mut engine = output.engine;
let _result = engine.run("Give a Rust example.").await?; let _result = engine.run(&mut history, "Give a Rust example.").await;
Ok(()) Ok(())
} }
``` ```
+10 -10
View File
@@ -4,7 +4,7 @@
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme}; use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
use agen::llm_client::transport::{HttpTransport, ResolvedAuth}; use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
use agen::{Engine, EngineResult}; use agen::{Engine, EngineRunExit, StopReason};
use std::time::Duration; use std::time::Duration;
#[tokio::main] #[tokio::main]
@@ -29,6 +29,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let base_url = scheme.default_base_url().to_string(); let base_url = scheme.default_base_url().to_string();
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap); let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
let engine = Engine::new(client); let engine = Engine::new(client);
let mut history = agen::History::new();
println!("🚀 Starting Engine..."); println!("🚀 Starting Engine...");
println!("💡 Will cancel after 2 seconds\n"); println!("💡 Will cancel after 2 seconds\n");
@@ -45,16 +46,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("📡 Sending request to LLM..."); println!("📡 Sending request to LLM...");
match engine.run("Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await { let output = engine.run(&mut history, "Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await;
Ok(out) => match out.result { match output.result {
EngineResult::Finished => println!("✅ Task completed normally"), EngineRunExit::Finished => println!("✅ Task completed normally"),
EngineResult::Paused => println!("⏸️ Task paused"), EngineRunExit::Paused => println!("⏸️ Task paused"),
EngineResult::LimitReached => println!("🔒 Turn limit reached"), EngineRunExit::Yielded => println!("↩️ Task yielded"),
EngineResult::Yielded => println!("↩️ Task yielded"), EngineRunExit::Interrupted(StopReason::LimitReached) => {
}, println!("🔒 Turn limit reached")
Err(e) => {
println!("❌ Task error: {}", e);
} }
EngineRunExit::Interrupted(reason) => println!("❌ Task interrupted: {reason:?}"),
} }
println!("\n✨ Demo complete!"); println!("\n✨ Demo complete!");
+11 -19
View File
@@ -39,7 +39,7 @@ use tracing::info;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use agen::{ use agen::{
Engine, Engine, EngineRunExit, StopReason,
interceptor::{Interceptor, PostToolAction, ToolResultInfo}, interceptor::{Interceptor, PostToolAction, ToolResultInfo},
llm_client::{ llm_client::{
LlmClient, LlmClient,
@@ -451,6 +451,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create Engine // Create Engine
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history = agen::History::new();
let tool_call_names = Arc::new(Mutex::new(HashMap::new())); let tool_call_names = Arc::new(Mutex::new(HashMap::new()));
@@ -476,12 +477,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// One-shot mode // One-shot mode
if let Some(prompt) = args.prompt { if let Some(prompt) = args.prompt {
match engine.run(&prompt).await { let output = engine.run(&mut history, &prompt).await;
Ok(_) => {} if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) = output.result {
Err(e) => { eprintln!("\n❌ Error: {error}");
eprintln!("\n❌ Error: {}", e);
std::process::exit(1);
}
} }
return Ok(()); return Ok(());
@@ -500,13 +498,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(()); return Ok(());
} }
let mut locked = match engine.run(first_input).await { let output = engine.run(&mut history, first_input).await;
Ok(out) => out.engine, let mut locked = output.engine;
Err(e) => {
eprintln!("\n❌ Error: {}", e);
return Ok(());
}
};
loop { loop {
print!("\n👤 You: "); print!("\n👤 You: ");
@@ -525,11 +518,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
break; break;
} }
match locked.run(input).await { if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) =
Ok(_) => {} locked.run(&mut history, input).await
Err(e) => { {
eprintln!("\n❌ Error: {}", e); eprintln!("\n❌ Error: {error}");
}
} }
} }
+795 -245
View File
File diff suppressed because it is too large Load Diff
+199
View File
@@ -0,0 +1,199 @@
//! Typed conversation history containers.
//!
//! Agen keeps provider-visible [`Item`](crate::Item) values separate from any
//! host-domain provenance. The host chooses the annotation type `A`, while Agen
//! preserves each item and annotation as one entry for clone/truncate/restore
//! style history operations.
use serde::{Deserialize, Serialize};
use crate::Item;
/// One conversation-history entry with host-owned annotation.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HistoryEntry<A = ()> {
/// Provider/model-visible conversation item.
pub item: Item,
/// Host-domain metadata kept with the item and never projected to providers.
pub annotation: A,
}
impl<A> HistoryEntry<A> {
/// Build an entry from an item and its annotation.
pub fn new(item: Item, annotation: A) -> Self {
Self { item, annotation }
}
/// Split the entry into its item and annotation.
pub fn into_parts(self) -> (Item, A) {
(self.item, self.annotation)
}
}
impl HistoryEntry<()> {
/// Build a unit-annotated entry.
pub fn from_item(item: Item) -> Self {
Self {
item,
annotation: (),
}
}
}
/// Conversation history with one annotation per item.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct History<A = ()> {
entries: Vec<HistoryEntry<A>>,
}
impl<A> History<A> {
/// Create an empty history.
pub fn new() -> Self {
Self {
entries: Vec::new(),
}
}
/// Build history from already annotated entries, preserving order.
pub fn from_entries(entries: Vec<HistoryEntry<A>>) -> Self {
Self { entries }
}
/// Replace all entries as one restore/rebuild operation and return the old entries.
pub fn replace_entries(&mut self, entries: Vec<HistoryEntry<A>>) -> Vec<HistoryEntry<A>> {
std::mem::replace(&mut self.entries, entries)
}
/// Borrow annotated entries.
pub fn entries(&self) -> &[HistoryEntry<A>] {
&self.entries
}
/// Mutably borrow annotated entries for host-owned rebuild operations.
pub fn entries_mut(&mut self) -> &mut [HistoryEntry<A>] {
&mut self.entries
}
/// Consume the history into annotated entries.
pub fn into_entries(self) -> Vec<HistoryEntry<A>> {
self.entries
}
/// Number of entries.
pub fn len(&self) -> usize {
self.entries.len()
}
/// Whether the history is empty.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Iterate over annotated entries.
pub fn iter(&self) -> impl ExactSizeIterator<Item = &HistoryEntry<A>> {
self.entries.iter()
}
/// Iterate over provider-visible items only.
pub fn items(&self) -> impl ExactSizeIterator<Item = &Item> {
self.entries.iter().map(|entry| &entry.item)
}
/// Clone provider-visible items into a request-local projection.
pub fn items_cloned(&self) -> Vec<Item> {
self.items().cloned().collect()
}
/// Append an already annotated entry.
pub fn push_entry(&mut self, entry: HistoryEntry<A>) {
self.entries.push(entry);
}
/// Append many already annotated entries.
pub fn extend_entries(&mut self, entries: impl IntoIterator<Item = HistoryEntry<A>>) {
self.entries.extend(entries);
}
/// Commit one item through a trusted annotation callback before it becomes live.
///
/// The callback may durably persist the item and returns the annotation that
/// must be stored with it. If the callback fails, the history is left unchanged.
pub fn append_with(
&mut self,
item: Item,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<(), String> {
let annotation = annotate(&item)?;
self.entries.push(HistoryEntry { item, annotation });
Ok(())
}
/// Commit items through a trusted annotation callback before they become live.
///
/// Items before a failure remain appended; the failing item and later items do
/// not enter history. This mirrors append-only durable logs where each accepted
/// item is already committed before the next item is attempted.
pub fn extend_with(
&mut self,
items: impl IntoIterator<Item = Item>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<(), String> {
for item in items {
self.append_with(item, annotate)?;
}
Ok(())
}
/// Truncate entries, preserving item+annotation pairing for retained entries.
pub fn truncate(&mut self, len: usize) {
self.entries.truncate(len);
}
/// Clear all entries.
pub fn clear(&mut self) {
self.entries.clear();
}
}
impl History<()> {
/// Build unit-annotated history from provider-visible items.
pub fn from_items(items: Vec<Item>) -> Self {
Self {
entries: items.into_iter().map(HistoryEntry::from_item).collect(),
}
}
/// Replace history from provider-visible items using unit annotations.
pub fn replace_items(&mut self, items: Vec<Item>) -> Vec<HistoryEntry<()>> {
self.replace_entries(items.into_iter().map(HistoryEntry::from_item).collect())
}
/// Append one item with unit annotation.
pub fn push(&mut self, item: Item) {
self.entries.push(HistoryEntry::from_item(item));
}
/// Append items with unit annotations.
pub fn extend_items(&mut self, items: impl IntoIterator<Item = Item>) {
self.entries
.extend(items.into_iter().map(HistoryEntry::from_item));
}
}
impl<A> IntoIterator for History<A> {
type Item = HistoryEntry<A>;
type IntoIter = std::vec::IntoIter<HistoryEntry<A>>;
fn into_iter(self) -> Self::IntoIter {
self.entries.into_iter()
}
}
impl<'a, A> IntoIterator for &'a History<A> {
type Item = &'a HistoryEntry<A>;
type IntoIter = std::slice::Iter<'a, HistoryEntry<A>>;
fn into_iter(self) -> Self::IntoIter {
self.entries.iter()
}
}
+9 -3
View File
@@ -2,6 +2,7 @@
mod engine; mod engine;
mod handler; mod handler;
mod history;
mod message; mod message;
pub(crate) mod callback; pub(crate) mod callback;
@@ -20,13 +21,18 @@ pub mod usage_record;
pub use agen_macros::{description, tool, tool_registry}; pub use agen_macros::{description, tool, tool_registry};
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope}; pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
pub use engine::{ pub use engine::{
Engine, EngineConfig, EngineError, EngineResult, EngineRunOutput, LlmRetryNotice, Engine, EngineConfig, EngineError, EngineResult, EngineRunExit, EngineRunOutput,
ToolRegistryError, LlmRetryNotice, StopReason, ToolRegistryError,
}; };
pub use handler::ToolUseBlockStart; pub use handler::ToolUseBlockStart;
pub use history::{History, HistoryEntry};
pub use interceptor::Interceptor; pub use interceptor::Interceptor;
pub use message::{ContentPart, Item, Message, Role}; pub use message::{ContentPart, Item, Message, Role};
pub use tool::{ToolCall, ToolExecutionContext, ToolOutputLimits, ToolResult}; pub use tool::{
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
ToolExecutionTerminal, ToolExecutionTerminalFuture, ToolOutputLimits, ToolResult,
ToolResultDisposition,
};
pub use usage_record::UsageRecord; pub use usage_record::UsageRecord;
/// Implementation dependencies used by code generated from `agen` macros. /// Implementation dependencies used by code generated from `agen` macros.
+8 -1
View File
@@ -18,6 +18,9 @@ pub enum ClientError {
message: String, message: String,
retry_after: Option<Duration>, retry_after: Option<Duration>,
}, },
/// The provider rejected the request because it exceeded the model context window.
/// Classified only from a structured provider error code, never message text.
ContextWindowExceeded,
/// A request lifecycle phase exceeded its hard timeout. /// A request lifecycle phase exceeded its hard timeout.
Timeout { Timeout {
phase: &'static str, phase: &'static str,
@@ -48,6 +51,7 @@ impl fmt::Display for ClientError {
} }
write!(f, ": {}", message) write!(f, ": {}", message)
} }
ClientError::ContextWindowExceeded => write!(f, "Model context window reached"),
ClientError::Timeout { phase, timeout } => { ClientError::Timeout { phase, timeout } => {
write!(f, "{phase} timed out after {}s", timeout.as_secs()) write!(f, "{phase} timed out after {}s", timeout.as_secs())
} }
@@ -112,7 +116,10 @@ pub fn is_retryable(error: &ClientError) -> bool {
ClientError::Api { status: None, .. } => false, ClientError::Api { status: None, .. } => false,
ClientError::Timeout { .. } => true, ClientError::Timeout { .. } => true,
ClientError::Http(e) => e.is_connect() || e.is_timeout(), ClientError::Http(e) => e.is_connect() || e.is_timeout(),
ClientError::Json(_) | ClientError::Sse(_) | ClientError::Config(_) => false, ClientError::ContextWindowExceeded
| ClientError::Json(_)
| ClientError::Sse(_)
| ClientError::Config(_) => false,
} }
} }
+4 -7
View File
@@ -431,13 +431,7 @@ fn api_error_code(error: &ClientError) -> Option<&str> {
} }
fn is_context_length_exceeded(error: &ClientError) -> bool { fn is_context_length_exceeded(error: &ClientError) -> bool {
match error { matches!(error, ClientError::ContextWindowExceeded)
ClientError::Api { code, message, .. } => {
code.as_deref() == Some("context_length_exceeded")
|| message.contains("context_length_exceeded")
}
_ => false,
}
} }
async fn response_with_timeout( async fn response_with_timeout(
@@ -487,6 +481,9 @@ async fn classify_error_response(resp: reqwest::Response) -> ClientError {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(&text) .unwrap_or(&text)
.to_string(); .to_string();
if code.as_deref() == Some("context_length_exceeded") {
return ClientError::ContextWindowExceeded;
}
ClientError::Api { ClientError::Api {
status: Some(status), status: Some(status),
code, code,
+37 -2
View File
@@ -9,7 +9,7 @@
use std::{fmt, sync::Arc}; use std::{fmt, sync::Arc};
use crate::tool::Attachment; use crate::tool::{Attachment, ToolResultDisposition};
use base64::Engine as _; use base64::Engine as _;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -121,6 +121,9 @@ pub enum Item {
/// Detailed output (removed by pruning when old enough) /// Detailed output (removed by pruning when old enough)
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
content: Option<String>, content: Option<String>,
/// Typed terminal state used for replay and recovery.
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
disposition: ToolResultDisposition,
/// Whether the tool result represents an execution error. /// Whether the tool result represents an execution error.
#[serde(default, skip_serializing_if = "is_false")] #[serde(default, skip_serializing_if = "is_false")]
is_error: bool, is_error: bool,
@@ -261,7 +264,17 @@ impl Item {
content: Option<String>, content: Option<String>,
is_error: bool, is_error: bool,
) -> Self { ) -> Self {
Self::tool_result_item_with_attachments(call_id, summary, content, is_error, Vec::new()) Self::tool_result_item_with_disposition_and_attachments(
call_id,
summary,
content,
if is_error {
ToolResultDisposition::Error
} else {
ToolResultDisposition::Success
},
Vec::new(),
)
} }
/// Create a tool result item with durable, prunable structured attachments. /// Create a tool result item with durable, prunable structured attachments.
@@ -272,11 +285,33 @@ impl Item {
is_error: bool, is_error: bool,
attachments: Vec<Attachment>, attachments: Vec<Attachment>,
) -> Self { ) -> Self {
Self::tool_result_item_with_disposition_and_attachments(
call_id,
summary,
content,
if is_error {
ToolResultDisposition::Error
} else {
ToolResultDisposition::Success
},
attachments,
)
}
pub fn tool_result_item_with_disposition_and_attachments(
call_id: impl Into<String>,
summary: impl Into<String>,
content: Option<String>,
disposition: ToolResultDisposition,
attachments: Vec<Attachment>,
) -> Self {
let is_error = !disposition.is_success();
Self::ToolResult { Self::ToolResult {
id: None, id: None,
call_id: call_id.into(), call_id: call_id.into(),
summary: summary.into(), summary: summary.into(),
content, content,
disposition,
is_error, is_error,
attachments, attachments,
} }
+1 -1
View File
@@ -19,7 +19,7 @@ mod private {
/// - Editing message history (add, delete, clear) /// - Editing message history (add, delete, clear)
/// - Registering tools and hooks /// - Registering tools and hooks
/// ///
/// Can transition to [`Locked`] state via `Engine::lock()`. /// Can transition to [`Locked`] state via `Engine::lock(&history)`.
/// ///
/// # Examples /// # Examples
/// ///
+227 -2
View File
@@ -3,7 +3,14 @@
//! Traits for defining tools callable by LLM. //! Traits for defining tools callable by LLM.
//! Usually auto-implemented using the `#[tool]` macro. //! Usually auto-implemented using the `#[tool]` macro.
use std::{collections::HashMap, fmt, sync::Arc}; use std::{
collections::HashMap,
fmt,
future::Future,
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
use async_trait::async_trait; use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::STANDARD}; use base64::{Engine as _, engine::general_purpose::STANDARD};
@@ -23,6 +30,12 @@ pub enum ToolError {
/// Internal error /// Internal error
#[error("Internal error: {0}")] #[error("Internal error: {0}")]
Internal(String), Internal(String),
/// Cooperative cancellation completed with bounded terminal output.
#[error("Tool execution cancelled")]
Cancelled(ToolOutput),
/// Execution was interrupted with a confirmed bounded terminal output.
#[error("Tool execution interrupted")]
Interrupted(ToolOutput),
} }
// ============================================================================= // =============================================================================
@@ -158,6 +171,28 @@ pub enum Attachment {
Image(ImageAttachment), Image(ImageAttachment),
} }
/// Terminal disposition of one started tool call.
///
/// `Cancelled` means the tool confirmed cancellation. `OutcomeUnknown` means
/// execution stopped without confirmation, so neither completion nor side
/// effects may be inferred.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ToolResultDisposition {
#[default]
Success,
Error,
Interrupted,
Cancelled,
OutcomeUnknown,
}
impl ToolResultDisposition {
pub const fn is_success(&self) -> bool {
matches!(self, Self::Success)
}
}
/// Tool execution result. /// Tool execution result.
/// ///
/// Every output has a mandatory `summary` (1-2 lines) that persists in /// Every output has a mandatory `summary` (1-2 lines) that persists in
@@ -322,6 +357,12 @@ impl ToolExecutionContext {
} }
} }
/// Identifies one live execution attempt without making the batch id a durable
/// replay or idempotency authority.
pub fn execution_id(&self) -> String {
format!("{}:{}", self.batch_id, self.call_id)
}
/// Context for direct, non-engine calls in unit tests and low-level callers. /// Context for direct, non-engine calls in unit tests and low-level callers.
pub fn direct() -> Self { pub fn direct() -> Self {
Self::new("direct", "direct", 0) Self::new("direct", "direct", 0)
@@ -334,6 +375,142 @@ impl Default for ToolExecutionContext {
} }
} }
/// The provider-confirmed terminal result of one started tool execution.
///
/// `OutcomeUnknown` is reserved for an execution task that had to be force-closed
/// or failed before the provider could confirm its terminal result.
#[derive(Debug)]
pub enum ToolExecutionTerminal {
Confirmed(Result<ToolOutput, ToolError>),
OutcomeUnknown,
}
/// The completion future paired with a [`ToolExecutionHandle`]. Dropping this
/// future does not drop the provider execution: the spawned execution remains
/// owned by its handle until it completes or is explicitly force-closed.
pub struct ToolExecutionTerminalFuture {
task: tokio::task::JoinHandle<Result<ToolOutput, ToolError>>,
}
impl Future for ToolExecutionTerminalFuture {
type Output = ToolExecutionTerminal;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
match Pin::new(&mut self.task).poll(cx) {
Poll::Ready(Ok(result)) => Poll::Ready(ToolExecutionTerminal::Confirmed(result)),
Poll::Ready(Err(_)) => Poll::Ready(ToolExecutionTerminal::OutcomeUnknown),
Poll::Pending => Poll::Pending,
}
}
}
/// Live ownership and control for one started tool execution.
///
/// Execution, cancellation, and terminal confirmation remain provider-owned:
/// this handle starts `Tool::execute`, delegates cooperative cancellation to
/// `Tool::cancel_execution`, and treats execution-future completion as the
/// provider's terminal confirmation. Agen may force-close only after its caller's
/// deadline expires, at which point the outcome is necessarily unknown.
#[derive(Clone)]
pub struct ToolExecutionHandle {
inner: Arc<ToolExecutionHandleInner>,
}
struct ToolExecutionHandleInner {
tool: Arc<dyn Tool>,
context: ToolExecutionContext,
abort: tokio::task::AbortHandle,
}
impl Drop for ToolExecutionHandleInner {
fn drop(&mut self) {
// Losing the final live owner is an explicit forced close, never a
// best-effort detached provider future.
self.abort.abort();
}
}
impl fmt::Debug for ToolExecutionHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ToolExecutionHandle")
.field("call_id", &self.inner.context.call_id)
.field("batch_id", &self.inner.context.batch_id)
.finish_non_exhaustive()
}
}
impl ToolExecutionHandle {
pub fn start(
tool: Arc<dyn Tool>,
input_json: String,
context: ToolExecutionContext,
) -> (Self, ToolExecutionTerminalFuture) {
let execution_tool = Arc::clone(&tool);
let execution_context = context.clone();
let task =
tokio::spawn(
async move { execution_tool.execute(&input_json, execution_context).await },
);
let abort = task.abort_handle();
(
Self {
inner: Arc::new(ToolExecutionHandleInner {
tool,
context,
abort,
}),
},
ToolExecutionTerminalFuture { task },
)
}
pub fn context(&self) -> &ToolExecutionContext {
&self.inner.context
}
pub async fn cancel_before(&self, deadline: tokio::time::Instant) -> Result<(), ToolError> {
match tokio::time::timeout_at(
deadline,
self.inner.tool.cancel_execution(&self.inner.context),
)
.await
{
Ok(result) => result,
Err(_) => Err(ToolError::Internal(format!(
"tool cancellation request exceeded its deadline for call {}",
self.inner.context.call_id
))),
}
}
pub fn force_close(&self) {
self.inner.abort.abort();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ToolExecutionPolicy {
/// Time a pause waits for already-started providers to reach a natural safe
/// boundary before escalating to explicit cooperative cancellation.
pub pause_safe_boundary_timeout: std::time::Duration,
/// Maximum time allowed for a provider to accept one cooperative
/// cancellation request.
pub cancellation_request_timeout: std::time::Duration,
/// Maximum time allowed for all providers to confirm terminal results after
/// cancellation has been requested.
pub terminal_confirmation_timeout: std::time::Duration,
}
impl Default for ToolExecutionPolicy {
fn default() -> Self {
Self {
pause_safe_boundary_timeout: std::time::Duration::from_millis(100),
cancellation_request_timeout: std::time::Duration::from_millis(100),
terminal_confirmation_timeout: std::time::Duration::from_millis(500),
}
}
}
// ============================================================================= // =============================================================================
// Tool trait // Tool trait
// ============================================================================= // =============================================================================
@@ -402,6 +579,26 @@ pub trait Tool: Send + Sync {
input_json: &str, input_json: &str,
ctx: ToolExecutionContext, ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError>; ) -> Result<ToolOutput, ToolError>;
/// Request cooperative cancellation for one started call.
///
/// Implementations that own cancellable provider operations should signal
/// every live execution identified by `call_id`, then let `execute` return
/// the confirmed bounded terminal output. Direct callers may use this
/// compatibility surface; Agen uses [`Tool::cancel_execution`] so providers
/// can bind cancellation to one exact live attempt.
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
Ok(())
}
/// Request cooperative cancellation for one exact started execution.
///
/// The default preserves existing tools by delegating to `cancel(call_id)`.
/// Providers with their own execution registry should override this method
/// and key cancellation by [`ToolExecutionContext::execution_id`].
async fn cancel_execution(&self, ctx: &ToolExecutionContext) -> Result<(), ToolError> {
self.cancel(&ctx.call_id).await
}
} }
// ============================================================================= // =============================================================================
@@ -429,6 +626,9 @@ pub struct ToolCall {
pub struct ToolResult { pub struct ToolResult {
/// Corresponding tool call ID /// Corresponding tool call ID
pub tool_use_id: String, pub tool_use_id: String,
/// Typed terminal state.
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
pub disposition: ToolResultDisposition,
/// Short summary (always kept in history) /// Short summary (always kept in history)
pub summary: String, pub summary: String,
/// Detailed output (prunable) /// Detailed output (prunable)
@@ -445,11 +645,20 @@ pub struct ToolResult {
impl ToolResult { impl ToolResult {
/// Create a success result from a [`ToolOutput`]. /// Create a success result from a [`ToolOutput`].
pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self { pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
Self::from_output_with_disposition(tool_use_id, output, ToolResultDisposition::Success)
}
pub fn from_output_with_disposition(
tool_use_id: impl Into<String>,
output: ToolOutput,
disposition: ToolResultDisposition,
) -> Self {
Self { Self {
tool_use_id: tool_use_id.into(), tool_use_id: tool_use_id.into(),
disposition,
summary: output.summary, summary: output.summary,
content: output.content, content: output.content,
is_error: false, is_error: !disposition.is_success(),
attachments: output.attachments, attachments: output.attachments,
} }
} }
@@ -458,12 +667,28 @@ impl ToolResult {
pub fn error(tool_use_id: impl Into<String>, message: impl Into<String>) -> Self { pub fn error(tool_use_id: impl Into<String>, message: impl Into<String>) -> Self {
Self { Self {
tool_use_id: tool_use_id.into(), tool_use_id: tool_use_id.into(),
disposition: ToolResultDisposition::Error,
summary: message.into(), summary: message.into(),
content: None, content: None,
is_error: true, is_error: true,
attachments: Vec::new(), attachments: Vec::new(),
} }
} }
/// Close an execution whose completion and side effects cannot be confirmed.
pub fn outcome_unknown(tool_use_id: impl Into<String>) -> Self {
Self {
tool_use_id: tool_use_id.into(),
disposition: ToolResultDisposition::OutcomeUnknown,
summary: "Tool execution outcome unknown".to_string(),
content: Some(
"Execution was interrupted before completion could be confirmed. Completion and side effects are unknown."
.to_string(),
),
is_error: true,
attachments: Vec::new(),
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -0,0 +1,84 @@
mod common;
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
use common::MockLlmClient;
fn completed_text_events(text: &str) -> Vec<Event> {
vec![
Event::text_block_start(0),
Event::text_delta(0, text),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
}
#[tokio::test]
async fn run_preserves_item_annotations_without_projecting_them() {
let client = MockLlmClient::new(completed_text_events("assistant reply"));
let engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
let mut history = History::<String>::new();
let mut next = 0usize;
let mut annotate = |item: &Item| {
next += 1;
let kind = match item {
Item::Message { role, .. } => match role {
Role::User => "user",
Role::Assistant => "assistant",
Role::System => "system",
},
Item::ToolCall { .. } => "tool_call",
Item::ToolResult { .. } => "tool_result",
Item::Reasoning { .. } => "reasoning",
};
Ok(format!("{next}:{kind}"))
};
let output = engine
.run_with_annotation(&mut history, "hello", &mut annotate)
.await;
assert!(matches!(output.result, agen::EngineRunExit::Finished));
assert_eq!(history.len(), 2);
assert_eq!(history.entries()[0].annotation, "1:user");
assert_eq!(history.entries()[1].annotation, "2:assistant");
assert_eq!(history.items_cloned().len(), 2);
}
#[test]
fn append_failure_does_not_make_item_live() {
let client = MockLlmClient::new(vec![]);
let mut engine = Engine::<_, agen::state::Mutable, usize>::new_annotated(client);
let mut history = History::<usize>::new();
let mut fail = |_item: &Item| Err("commit failed".to_string());
let err = engine
.append_history_with(&mut history, [Item::user_message("uncommitted")], &mut fail)
.unwrap_err();
assert!(matches!(err, EngineError::HistoryAppend(message) if message == "commit failed"));
assert!(history.is_empty());
}
#[test]
fn replacement_keeps_items_and_annotations_together() {
let mut history = History::from_entries(vec![
HistoryEntry::new(Item::user_message("old"), "old-ann".to_string()),
HistoryEntry::new(Item::user_message("second"), "second-ann".to_string()),
]);
history.truncate(1);
assert_eq!(history.entries()[0].item.as_text(), Some("old"));
assert_eq!(history.entries()[0].annotation, "old-ann");
let previous = history.replace_entries(vec![HistoryEntry::new(
Item::user_message("restored"),
"restored-ann".to_string(),
)]);
assert_eq!(previous.len(), 1);
assert_eq!(history.entries()[0].item.as_text(), Some("restored"));
assert_eq!(history.entries()[0].annotation, "restored-ann");
}
+27 -14
View File
@@ -58,6 +58,7 @@ async fn test_callback_llm_retry_event() {
max_attempts: 2, max_attempts: 2,
total_timeout: Duration::from_secs(1), total_timeout: Duration::from_secs(1),
}); });
let mut history = agen::History::new();
let notices = Arc::new(Mutex::new(Vec::new())); let notices = Arc::new(Mutex::new(Vec::new()));
let sink = notices.clone(); let sink = notices.clone();
@@ -65,8 +66,11 @@ async fn test_callback_llm_retry_event() {
sink.lock().unwrap().push((llm_call, notice.clone())); sink.lock().unwrap().push((llm_call, notice.clone()));
}); });
let result = engine.run("retry once").await; let result = engine.run(&mut history, "retry once").await;
assert!(result.is_ok(), "engine should succeed after one retry"); assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"engine should succeed after one retry"
);
let notices = notices.lock().unwrap(); let notices = notices.lock().unwrap();
assert_eq!(notices.len(), 1); assert_eq!(notices.len(), 1);
@@ -91,6 +95,7 @@ async fn test_callback_text_block_events() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history = agen::History::new();
let text_deltas = Arc::new(Mutex::new(Vec::new())); let text_deltas = Arc::new(Mutex::new(Vec::new()));
let text_completes = Arc::new(Mutex::new(Vec::new())); let text_completes = Arc::new(Mutex::new(Vec::new()));
@@ -108,9 +113,12 @@ async fn test_callback_text_block_events() {
}); });
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineRunExit)
let result = engine.run("Greet me").await; let result = engine.run(&mut history, "Greet me").await;
assert!(result.is_ok(), "Engine should complete"); assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete"
);
let deltas = text_deltas.lock().unwrap(); let deltas = text_deltas.lock().unwrap();
assert_eq!(deltas.len(), 2); assert_eq!(deltas.len(), 2);
@@ -137,6 +145,7 @@ async fn test_callback_tool_call_complete() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history = agen::History::new();
let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new())); let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
let tool_completes = Arc::new(Mutex::new(Vec::new())); let tool_completes = Arc::new(Mutex::new(Vec::new()));
@@ -154,8 +163,8 @@ async fn test_callback_tool_call_complete() {
}); });
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineRunExit)
let _ = engine.run("Weather please").await; let _ = engine.run(&mut history, "Weather please").await;
let starts = tool_starts.lock().unwrap(); let starts = tool_starts.lock().unwrap();
assert_eq!(starts.len(), 1); assert_eq!(starts.len(), 1);
@@ -183,6 +192,7 @@ async fn test_callback_turn_events() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history = agen::History::new();
let turn_starts = Arc::new(Mutex::new(Vec::new())); let turn_starts = Arc::new(Mutex::new(Vec::new()));
let turn_ends = Arc::new(Mutex::new(Vec::new())); let turn_ends = Arc::new(Mutex::new(Vec::new()));
@@ -197,9 +207,9 @@ async fn test_callback_turn_events() {
ends.lock().unwrap().push(turn); ends.lock().unwrap().push(turn);
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineRunExit)
let result = engine.run("Do something").await; let result = engine.run(&mut history, "Do something").await;
assert!(result.is_ok()); assert!(matches!(result.result, agen::EngineRunExit::Finished));
let starts = turn_starts.lock().unwrap(); let starts = turn_starts.lock().unwrap();
let ends = turn_ends.lock().unwrap(); let ends = turn_ends.lock().unwrap();
@@ -254,6 +264,7 @@ async fn test_callback_tool_result_events() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history = agen::History::new();
engine.register_tool(fixed_tool( engine.register_tool(fixed_tool(
"fixed", "fixed",
@@ -276,7 +287,7 @@ async fn test_callback_tool_result_events() {
)); ));
}); });
let _ = engine.run("call it").await; let _ = engine.run(&mut history, "call it").await;
let observed = captured.lock().unwrap(); let observed = captured.lock().unwrap();
assert_eq!(observed.len(), 1); assert_eq!(observed.len(), 1);
@@ -330,6 +341,7 @@ async fn test_callback_tool_result_error_path() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history = agen::History::new();
engine.register_tool(erroring_tool("erroring", "boom")); engine.register_tool(erroring_tool("erroring", "boom"));
@@ -345,7 +357,7 @@ async fn test_callback_tool_result_error_path() {
)); ));
}); });
let _ = engine.run("fail it").await; let _ = engine.run(&mut history, "fail it").await;
let observed = captured.lock().unwrap(); let observed = captured.lock().unwrap();
assert_eq!(observed.len(), 1); assert_eq!(observed.len(), 1);
@@ -374,6 +386,7 @@ async fn test_callback_usage_events() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history = agen::History::new();
let usage_events = Arc::new(Mutex::new(Vec::new())); let usage_events = Arc::new(Mutex::new(Vec::new()));
@@ -382,8 +395,8 @@ async fn test_callback_usage_events() {
usages.lock().unwrap().push(event.clone()); usages.lock().unwrap().push(event.clone());
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineRunExit)
let _ = engine.run("Hello").await; let _ = engine.run(&mut history, "Hello").await;
let usages = usage_events.lock().unwrap(); let usages = usage_events.lock().unwrap();
assert_eq!(usages.len(), 1); assert_eq!(usages.len(), 1);
+8 -1
View File
@@ -19,6 +19,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
pub struct MockLlmClient { pub struct MockLlmClient {
responses: Arc<Vec<Vec<Event>>>, responses: Arc<Vec<Vec<Event>>>,
call_count: Arc<AtomicUsize>, call_count: Arc<AtomicUsize>,
requests: Arc<Mutex<Vec<Request>>>,
} }
impl MockLlmClient { impl MockLlmClient {
@@ -30,6 +31,7 @@ impl MockLlmClient {
Self { Self {
responses: Arc::new(responses), responses: Arc::new(responses),
call_count: Arc::new(AtomicUsize::new(0)), call_count: Arc::new(AtomicUsize::new(0)),
requests: Arc::new(Mutex::new(Vec::new())),
} }
} }
@@ -41,6 +43,10 @@ impl MockLlmClient {
pub fn event_count(&self) -> usize { pub fn event_count(&self) -> usize {
self.responses.iter().map(|v| v.len()).sum() self.responses.iter().map(|v| v.len()).sum()
} }
pub fn requests(&self) -> Vec<Request> {
self.requests.lock().unwrap().clone()
}
} }
#[async_trait] #[async_trait]
@@ -51,8 +57,9 @@ impl LlmClient for MockLlmClient {
async fn stream( async fn stream(
&self, &self,
_request: Request, request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> { ) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
self.requests.lock().unwrap().push(request);
let count = self.call_count.fetch_add(1, Ordering::SeqCst); let count = self.call_count.fetch_add(1, Ordering::SeqCst);
if count >= self.responses.len() { if count >= self.responses.len() {
return Err(ClientError::Api { return Err(ClientError::Api {
+16 -5
View File
@@ -134,11 +134,15 @@ async fn test_engine_simple_text_response() {
let client = MockLlmClient::from_fixture(&fixture_path).unwrap(); let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
let engine = Engine::new(client); let engine = Engine::new(client);
let mut history = agen::History::new();
// Send a simple message (Mutable::run consumes self, returns tuple) // Send a simple message (Mutable::run consumes self, returns tuple)
let result = engine.run("Hello").await; let result = engine.run(&mut history, "Hello").await;
assert!(result.is_ok(), "Engine should complete successfully"); assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete successfully"
);
} }
/// Verify that Engine can correctly process responses containing tool calls /// Verify that Engine can correctly process responses containing tool calls
@@ -156,6 +160,7 @@ async fn test_engine_tool_call() {
let client = MockLlmClient::from_fixture(&fixture_path).unwrap(); let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history = agen::History::new();
// Register tool // Register tool
let weather_tool = MockWeatherTool::new(); let weather_tool = MockWeatherTool::new();
@@ -163,7 +168,9 @@ async fn test_engine_tool_call() {
engine.register_tool(weather_tool.definition()); engine.register_tool(weather_tool.definition());
// Send message (Mutable::run consumes self, returns tuple) // Send message (Mutable::run consumes self, returns tuple)
let _result = engine.run("What's the weather in Tokyo?").await; let _result = engine
.run(&mut history, "What's the weather in Tokyo?")
.await;
// Verify tool was called // Verify tool was called
// Note: max_turns=1 so no request is sent after tool result // Note: max_turns=1 so no request is sent after tool result
@@ -195,11 +202,15 @@ async fn test_engine_with_programmatic_events() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let engine = Engine::new(client); let engine = Engine::new(client);
let mut history = agen::History::new();
// Mutable::run consumes self, returns tuple // Mutable::run consumes self, returns tuple
let result = engine.run("Greet me").await; let result = engine.run(&mut history, "Greet me").await;
assert!(result.is_ok(), "Engine should complete successfully"); assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete successfully"
);
} }
/// Verify that ToolCallCollector correctly collects ToolCall from ToolUse block events /// Verify that ToolCallCollector correctly collects ToolCall from ToolUse block events
+154 -91
View File
@@ -14,7 +14,7 @@ use agen::interceptor::{
}; };
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent}; use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use agen::{Engine, EngineError, EngineResult}; use agen::{Engine, EngineError, EngineRunExit, History, StopReason};
use async_trait::async_trait; use async_trait::async_trait;
use common::MockLlmClient; use common::MockLlmClient;
@@ -42,36 +42,37 @@ fn test_mutable_set_system_prompt() {
fn test_mutable_history_manipulation() { fn test_mutable_history_manipulation() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
// Initial state is empty // Initial state is empty
assert!(engine.history().is_empty()); assert!(history.is_empty());
// Add to history // Add to history
engine engine
.append_history(vec![Item::user_message("Hello")]) .append_history(&mut history, vec![Item::user_message("Hello")])
.unwrap(); .unwrap();
engine engine
.append_history(vec![Item::assistant_message("Hi there!")]) .append_history(&mut history, vec![Item::assistant_message("Hi there!")])
.unwrap(); .unwrap();
assert_eq!(engine.history().len(), 2); assert_eq!(history.len(), 2);
// Append to history via the callback-aware API. // Append to history via the callback-aware API.
engine engine
.append_history(vec![Item::user_message("How are you?")]) .append_history(&mut history, vec![Item::user_message("How are you?")])
.unwrap(); .unwrap();
assert_eq!(engine.history().len(), 3); assert_eq!(history.len(), 3);
// Clear history // Clear history
engine.clear_history(); engine.clear_history(&mut history);
assert!(engine.history().is_empty()); assert!(history.is_empty());
// Set history // Set history
let items = vec![ let items = vec![
Item::user_message("Test"), Item::user_message("Test"),
Item::assistant_message("Response"), Item::assistant_message("Response"),
]; ];
engine.set_history(items); engine.set_history(&mut history, items);
assert_eq!(engine.history().len(), 2); assert_eq!(history.len(), 2);
} }
/// Verify that Engine can be constructed using builder pattern /// Verify that Engine can be constructed using builder pattern
@@ -79,9 +80,10 @@ fn test_mutable_history_manipulation() {
fn test_mutable_builder_pattern() { fn test_mutable_builder_pattern() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let engine = Engine::new(client).system_prompt("System prompt"); let engine = Engine::new(client).system_prompt("System prompt");
let history: History = History::new();
assert_eq!(engine.get_system_prompt(), Some("System prompt")); assert_eq!(engine.get_system_prompt(), Some("System prompt"));
assert!(engine.history().is_empty()); assert!(history.is_empty());
} }
/// Verify that multiple items can be added with append_history and callbacks fire. /// Verify that multiple items can be added with append_history and callbacks fire.
@@ -91,6 +93,7 @@ fn test_mutable_append_history() {
let observed = Arc::new(Mutex::new(Vec::new())); let observed = Arc::new(Mutex::new(Vec::new()));
let observed_for_callback = Arc::clone(&observed); let observed_for_callback = Arc::clone(&observed);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
engine.on_history_append(move |item| { engine.on_history_append(move |item| {
if let Some(text) = item.as_text() { if let Some(text) = item.as_text() {
observed_for_callback.lock().unwrap().push(text.to_string()); observed_for_callback.lock().unwrap().push(text.to_string());
@@ -99,18 +102,21 @@ fn test_mutable_append_history() {
}); });
engine engine
.append_history(vec![Item::user_message("First")]) .append_history(&mut history, vec![Item::user_message("First")])
.unwrap(); .unwrap();
engine engine
.append_history(vec![ .append_history(
&mut history,
vec![
Item::assistant_message("Response 1"), Item::assistant_message("Response 1"),
Item::user_message("Second"), Item::user_message("Second"),
Item::assistant_message("Response 2"), Item::assistant_message("Response 2"),
]) ],
)
.unwrap(); .unwrap();
assert_eq!(engine.history().len(), 4); assert_eq!(history.len(), 4);
assert_eq!( assert_eq!(
observed.lock().unwrap().as_slice(), observed.lock().unwrap().as_slice(),
["First", "Response 1", "Second", "Response 2"] ["First", "Response 1", "Second", "Response 2"]
@@ -185,6 +191,7 @@ async fn history_append_failure_stops_before_tool_execution() {
]); ]);
let tool = CountingTool::new("count_tool"); let tool = CountingTool::new("count_tool");
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
engine.register_tool(tool.definition()); engine.register_tool(tool.definition());
engine.on_history_append(|item| { engine.on_history_append(|item| {
if item.is_tool_call() { if item.is_tool_call() {
@@ -194,15 +201,15 @@ async fn history_append_failure_stops_before_tool_execution() {
} }
}); });
let mut engine = engine.lock(); let mut engine = engine.lock(&history);
let error = engine.run("use the tool").await.unwrap_err(); let exit = engine.run(&mut history, "use the tool").await;
assert!( assert!(
matches!(error, EngineError::HistoryAppend(ref message) if message == "simulated ENOSPC") matches!(exit, EngineRunExit::Interrupted(StopReason::Unexpected(EngineError::HistoryAppend(ref message))) if message == "simulated ENOSPC")
); );
assert_eq!(tool.call_count(), 0); assert_eq!(tool.call_count(), 0);
assert_eq!(engine.history().len(), 1); assert_eq!(history.len(), 1);
assert_eq!(engine.history()[0].as_text(), Some("use the tool")); assert_eq!(history.entries()[0].item.as_text(), Some("use the tool"));
} }
// ============================================================================= // =============================================================================
@@ -214,21 +221,22 @@ async fn history_append_failure_stops_before_tool_execution() {
fn test_lock_transition() { fn test_lock_transition() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
engine.set_system_prompt("System"); engine.set_system_prompt("System");
engine engine
.append_history(vec![Item::user_message("Hello")]) .append_history(&mut history, vec![Item::user_message("Hello")])
.unwrap(); .unwrap();
engine engine
.append_history(vec![Item::assistant_message("Hi")]) .append_history(&mut history, vec![Item::assistant_message("Hi")])
.unwrap(); .unwrap();
// Lock // Lock
let locked_engine = engine.lock(); let locked_engine = engine.lock(&history);
// History and system prompt are still accessible in Locked state // History and system prompt are still accessible in Locked state
assert_eq!(locked_engine.get_system_prompt(), Some("System")); assert_eq!(locked_engine.get_system_prompt(), Some("System"));
assert_eq!(locked_engine.history().len(), 2); assert_eq!(history.len(), 2);
assert_eq!(locked_engine.locked_prefix_len(), 2); assert_eq!(locked_engine.locked_prefix_len(), 2);
} }
@@ -237,21 +245,22 @@ fn test_lock_transition() {
fn test_unlock_transition() { fn test_unlock_transition() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
engine engine
.append_history(vec![Item::user_message("Hello")]) .append_history(&mut history, vec![Item::user_message("Hello")])
.unwrap(); .unwrap();
let locked_engine = engine.lock(); let locked_engine = engine.lock(&history);
// Unlock // Unlock
let mut engine = locked_engine.unlock(); let mut engine = locked_engine.unlock();
// History operations are available again in Mutable state // History operations are available again in Mutable state
engine engine
.append_history(vec![Item::assistant_message("Hi")]) .append_history(&mut history, vec![Item::assistant_message("Hi")])
.unwrap(); .unwrap();
engine.clear_history(); engine.clear_history(&mut history);
assert!(engine.history().is_empty()); assert!(history.is_empty());
} }
// ============================================================================= // =============================================================================
@@ -272,20 +281,20 @@ async fn test_mutable_run_updates_history() -> Result<(), EngineError> {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let engine = Engine::new(client); let engine = Engine::new(client);
let mut history: History = History::new();
// Execute (Mutable::run consumes self, returns EngineRunOutput) // Execute (Mutable::run consumes self, returns EngineRunOutput)
let out = engine.run("Hi there").await?; let _out = engine.run(&mut history, "Hi there").await;
let engine = out.engine;
// History is updated // History is updated
let history = engine.history(); let entries = history.entries();
assert_eq!(history.len(), 2); // user + assistant assert_eq!(history.len(), 2); // user + assistant
// User message // User message
assert_eq!(history[0].as_text(), Some("Hi there")); assert_eq!(entries[0].item.as_text(), Some("Hi there"));
// Assistant message // Assistant message
assert_eq!(history[1].as_text(), Some("Hello, I'm an assistant!")); assert_eq!(entries[1].item.as_text(), Some("Hello, I'm an assistant!"));
Ok(()) Ok(())
} }
@@ -316,35 +325,36 @@ async fn test_locked_multi_turn_history_accumulation() {
]); ]);
let engine = Engine::new(client).system_prompt("You are helpful."); let engine = Engine::new(client).system_prompt("You are helpful.");
let mut history: History = History::new();
// Lock (after setting system prompt) // Lock (after setting system prompt)
let mut locked_engine = engine.lock(); let mut locked_engine = engine.lock(&history);
assert_eq!(locked_engine.locked_prefix_len(), 0); // No items yet assert_eq!(locked_engine.locked_prefix_len(), 0); // No items yet
// Turn 1 // Turn 1
let result1 = locked_engine.run("Hello!").await; let result1 = locked_engine.run(&mut history, "Hello!").await;
assert!(result1.is_ok()); assert!(matches!(result1, EngineRunExit::Finished));
assert_eq!(locked_engine.history().len(), 2); // user + assistant assert_eq!(history.len(), 2); // user + assistant
// Turn 2 // Turn 2
let result2 = locked_engine.run("Can you help me?").await; let result2 = locked_engine.run(&mut history, "Can you help me?").await;
assert!(result2.is_ok()); assert!(matches!(result2, EngineRunExit::Finished));
assert_eq!(locked_engine.history().len(), 4); // 2 * (user + assistant) assert_eq!(history.len(), 4); // 2 * (user + assistant)
// Verify history contents // Verify history contents
let history = locked_engine.history(); let entries = history.entries();
// Turn 1 user message // Turn 1 user message
assert_eq!(history[0].as_text(), Some("Hello!")); assert_eq!(entries[0].item.as_text(), Some("Hello!"));
// Turn 1 assistant message // Turn 1 assistant message
assert_eq!(history[1].as_text(), Some("Nice to meet you!")); assert_eq!(entries[1].item.as_text(), Some("Nice to meet you!"));
// Turn 2 user message // Turn 2 user message
assert_eq!(history[2].as_text(), Some("Can you help me?")); assert_eq!(entries[2].item.as_text(), Some("Can you help me?"));
// Turn 2 assistant message // Turn 2 assistant message
assert_eq!(history[3].as_text(), Some("I can help with that.")); assert_eq!(entries[3].item.as_text(), Some("I can help with that."));
} }
/// Verify that locked_prefix_len correctly records history length at lock time /// Verify that locked_prefix_len correctly records history length at lock time
@@ -370,26 +380,33 @@ async fn test_locked_prefix_len_tracking() {
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
// Add items beforehand // Add items beforehand
engine engine
.append_history(vec![Item::user_message("Pre-existing message 1")]) .append_history(
&mut history,
vec![Item::user_message("Pre-existing message 1")],
)
.unwrap(); .unwrap();
engine engine
.append_history(vec![Item::assistant_message("Pre-existing response 1")]) .append_history(
&mut history,
vec![Item::assistant_message("Pre-existing response 1")],
)
.unwrap(); .unwrap();
assert_eq!(engine.history().len(), 2); assert_eq!(history.len(), 2);
// Lock // Lock
let mut locked_engine = engine.lock(); let mut locked_engine = engine.lock(&history);
assert_eq!(locked_engine.locked_prefix_len(), 2); // 2 items at lock time assert_eq!(locked_engine.locked_prefix_len(), 2); // 2 items at lock time
// Execute turn // Execute turn
locked_engine.run("New message").await.unwrap(); locked_engine.run(&mut history, "New message").await;
// History grows but locked_prefix_len remains unchanged // History grows but locked_prefix_len remains unchanged
assert_eq!(locked_engine.history().len(), 4); // 2 + 2 assert_eq!(history.len(), 4); // 2 + 2
assert_eq!(locked_engine.locked_prefix_len(), 2); // Unchanged assert_eq!(locked_engine.locked_prefix_len(), 2); // Unchanged
} }
@@ -416,18 +433,22 @@ async fn test_turn_count_increment() -> Result<(), EngineError> {
]); ]);
let engine = Engine::new(client); let engine = Engine::new(client);
let mut history: History = History::new();
assert_eq!(engine.turn_count(), 0); assert_eq!(engine.turn_count(), 0);
assert_eq!(engine.llm_call_count(), 0); assert_eq!(engine.llm_call_count(), 0);
// First run consumes Mutable, returns EngineRunOutput // First run consumes Mutable, returns EngineRunOutput
let mut engine = engine.run("First").await?.engine; let mut engine = engine.run(&mut history, "First").await.engine;
assert_eq!(engine.turn_count(), 1); assert_eq!(engine.turn_count(), 1);
// Retry not yet implemented → AgentTurn:LlmCall is 1:1. // Retry not yet implemented → AgentTurn:LlmCall is 1:1.
assert_eq!(engine.llm_call_count(), 1); assert_eq!(engine.llm_call_count(), 1);
// Subsequent runs on Locked take &mut self // Subsequent runs on Locked take &mut self
engine.run("Second").await?; assert!(matches!(
engine.run(&mut history, "Second").await,
EngineRunExit::Finished
));
assert_eq!(engine.turn_count(), 2); assert_eq!(engine.turn_count(), 2);
assert_eq!(engine.llm_call_count(), 2); assert_eq!(engine.llm_call_count(), 2);
@@ -447,28 +468,29 @@ async fn test_unlock_edit_relock() {
]]); ]]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
engine engine
.append_history(vec![ .append_history(
Item::user_message("Hello"), &mut history,
Item::assistant_message("Hi"), vec![Item::user_message("Hello"), Item::assistant_message("Hi")],
]) )
.unwrap(); .unwrap();
// Lock -> Unlock // Lock -> Unlock
let locked = engine.lock(); let locked = engine.lock(&history);
assert_eq!(locked.locked_prefix_len(), 2); assert_eq!(locked.locked_prefix_len(), 2);
let mut unlocked = locked.unlock(); let mut unlocked = locked.unlock();
// Edit history // Edit history
unlocked.clear_history(); unlocked.clear_history(&mut history);
unlocked unlocked
.append_history(vec![Item::user_message("Fresh start")]) .append_history(&mut history, vec![Item::user_message("Fresh start")])
.unwrap(); .unwrap();
// Re-lock // Re-lock
let relocked = unlocked.lock(); let relocked = unlocked.lock(&history);
assert_eq!(relocked.history().len(), 1); assert_eq!(history.len(), 1);
assert_eq!(relocked.locked_prefix_len(), 1); assert_eq!(relocked.locked_prefix_len(), 1);
} }
@@ -511,19 +533,26 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let tool_a = CountingTool::new("tool_a"); let tool_a = CountingTool::new("tool_a");
engine.register_tool(tool_a.definition()); engine.register_tool(tool_a.definition());
let mut locked = engine.lock(); let mut locked = engine.lock(&history);
locked.run("first").await.expect("first run"); assert!(matches!(
locked.run(&mut history, "first").await,
EngineRunExit::Finished
));
assert_eq!(tool_a.call_count(), 1, "tool_a should be called once"); assert_eq!(tool_a.call_count(), 1, "tool_a should be called once");
let mut unlocked = locked.unlock(); let mut unlocked = locked.unlock();
let tool_b = CountingTool::new("tool_b"); let tool_b = CountingTool::new("tool_b");
unlocked.register_tool(tool_b.definition()); unlocked.register_tool(tool_b.definition());
let mut relocked = unlocked.lock(); let mut relocked = unlocked.lock(&history);
relocked.run("second").await.expect("second run"); assert!(matches!(
relocked.run(&mut history, "second").await,
EngineRunExit::Finished
));
assert_eq!(tool_a.call_count(), 1, "tool_a should not be called again"); assert_eq!(tool_a.call_count(), 1, "tool_a should not be called again");
assert_eq!(tool_b.call_count(), 1, "tool_b should be called once"); assert_eq!(tool_b.call_count(), 1, "tool_b should be called once");
@@ -538,8 +567,9 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
fn test_system_prompt_preserved_in_locked_state() { fn test_system_prompt_preserved_in_locked_state() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let engine = Engine::new(client).system_prompt("Important system prompt"); let engine = Engine::new(client).system_prompt("Important system prompt");
let history: History = History::new();
let locked = engine.lock(); let locked = engine.lock(&history);
assert_eq!(locked.get_system_prompt(), Some("Important system prompt")); assert_eq!(locked.get_system_prompt(), Some("Important system prompt"));
let unlocked = locked.unlock(); let unlocked = locked.unlock();
@@ -554,14 +584,15 @@ fn test_system_prompt_preserved_in_locked_state() {
fn test_system_prompt_change_after_unlock() { fn test_system_prompt_change_after_unlock() {
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let engine = Engine::new(client).system_prompt("Original prompt"); let engine = Engine::new(client).system_prompt("Original prompt");
let history: History = History::new();
let locked = engine.lock(); let locked = engine.lock(&history);
let mut unlocked = locked.unlock(); let mut unlocked = locked.unlock();
unlocked.set_system_prompt("New prompt"); unlocked.set_system_prompt("New prompt");
assert_eq!(unlocked.get_system_prompt(), Some("New prompt")); assert_eq!(unlocked.get_system_prompt(), Some("New prompt"));
let relocked = unlocked.lock(); let relocked = unlocked.lock(&history);
assert_eq!(relocked.get_system_prompt(), Some("New prompt")); assert_eq!(relocked.get_system_prompt(), Some("New prompt"));
} }
@@ -623,40 +654,55 @@ impl Interceptor for ContinueTurnOnce {
#[tokio::test] #[tokio::test]
async fn max_turns_is_scoped_to_each_fresh_run() { async fn max_turns_is_scoped_to_each_fresh_run() {
let mut history: History = History::new();
let responses = vec![completed_text_events(), completed_text_events()]; let responses = vec![completed_text_events(), completed_text_events()];
let mut engine = Engine::new(MockLlmClient::with_responses(responses)); let mut engine = Engine::new(MockLlmClient::with_responses(responses));
engine.set_max_turns(Some(1)); engine.set_max_turns(Some(1));
let mut engine = engine.lock(); let mut engine = engine.lock(&history);
assert_eq!(engine.run("first").await.unwrap(), EngineResult::Finished); assert!(matches!(
engine.run(&mut history, "first").await,
EngineRunExit::Finished
));
assert_eq!(engine.turn_count(), 1); assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), None); assert_eq!(engine.active_run_turn_count(), None);
assert_eq!(engine.run("second").await.unwrap(), EngineResult::Finished); assert!(matches!(
engine.run(&mut history, "second").await,
EngineRunExit::Finished
));
assert_eq!(engine.turn_count(), 2); assert_eq!(engine.turn_count(), 2);
assert_eq!(engine.active_run_turn_count(), None); assert_eq!(engine.active_run_turn_count(), None);
} }
#[tokio::test] #[tokio::test]
async fn yielded_resume_keeps_the_same_unspent_turn_budget() { async fn yielded_resume_keeps_the_same_unspent_turn_budget() {
let mut history: History = History::new();
let mut engine = Engine::new(MockLlmClient::new(completed_text_events())); let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_max_turns(Some(1)); engine.set_max_turns(Some(1));
engine.set_interceptor(YieldOnce { engine.set_interceptor(YieldOnce {
calls: AtomicUsize::new(0), calls: AtomicUsize::new(0),
}); });
let mut engine = engine.lock(); let mut engine = engine.lock(&history);
assert_eq!(engine.run("start").await.unwrap(), EngineResult::Yielded); assert!(matches!(
engine.run(&mut history, "start").await,
EngineRunExit::Yielded
));
assert_eq!(engine.turn_count(), 0); assert_eq!(engine.turn_count(), 0);
assert_eq!(engine.active_run_turn_count(), Some(0)); assert_eq!(engine.active_run_turn_count(), Some(0));
assert_eq!(engine.resume().await.unwrap(), EngineResult::Finished); assert!(matches!(
engine.resume(&mut history).await,
EngineRunExit::Finished
));
assert_eq!(engine.turn_count(), 1); assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), None); assert_eq!(engine.active_run_turn_count(), None);
} }
#[tokio::test] #[tokio::test]
async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() { async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
let mut history: History = History::new();
let events = vec![ let events = vec![
Event::tool_use_start(0, "call_1", "count_tool"), Event::tool_use_start(0, "call_1", "count_tool"),
Event::tool_input_delta(0, "{}"), Event::tool_input_delta(0, "{}"),
@@ -672,14 +718,20 @@ async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
engine.set_interceptor(PauseToolOnce { engine.set_interceptor(PauseToolOnce {
calls: AtomicUsize::new(0), calls: AtomicUsize::new(0),
}); });
let mut engine = engine.lock(); let mut engine = engine.lock(&history);
assert_eq!(engine.run("call it").await.unwrap(), EngineResult::Paused); assert!(matches!(
engine.run(&mut history, "call it").await,
EngineRunExit::Paused
));
assert_eq!(engine.turn_count(), 1); assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), Some(1)); assert_eq!(engine.active_run_turn_count(), Some(1));
assert_eq!(tool.call_count(), 0); assert_eq!(tool.call_count(), 0);
assert_eq!(engine.resume().await.unwrap(), EngineResult::LimitReached); assert!(matches!(
engine.resume(&mut history).await,
EngineRunExit::Interrupted(StopReason::LimitReached)
));
assert_eq!(engine.turn_count(), 1); assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), None); assert_eq!(engine.active_run_turn_count(), None);
assert_eq!(tool.call_count(), 1, "the consumed turn's tool still runs"); assert_eq!(tool.call_count(), 1, "the consumed turn's tool still runs");
@@ -687,6 +739,7 @@ async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
#[tokio::test] #[tokio::test]
async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() { async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
let mut history: History = History::new();
let tool_events = vec![ let tool_events = vec![
Event::tool_use_start(0, "call_1", "count_tool"), Event::tool_use_start(0, "call_1", "count_tool"),
Event::tool_input_delta(0, "{}"), Event::tool_input_delta(0, "{}"),
@@ -703,12 +756,18 @@ async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
engine.set_interceptor(PauseToolOnce { engine.set_interceptor(PauseToolOnce {
calls: AtomicUsize::new(0), calls: AtomicUsize::new(0),
}); });
let mut engine = engine.lock(); let mut engine = engine.lock(&history);
assert_eq!(engine.run("pause").await.unwrap(), EngineResult::Paused); assert!(matches!(
engine.run(&mut history, "pause").await,
EngineRunExit::Paused
));
assert_eq!(engine.active_run_turn_count(), Some(1)); assert_eq!(engine.active_run_turn_count(), Some(1));
assert_eq!(engine.run("replace").await.unwrap(), EngineResult::Finished); assert!(matches!(
engine.run(&mut history, "replace").await,
EngineRunExit::Finished
));
assert_eq!(engine.turn_count(), 2); assert_eq!(engine.turn_count(), 2);
assert_eq!(engine.active_run_turn_count(), None); assert_eq!(engine.active_run_turn_count(), None);
assert_eq!(tool.call_count(), 1, "pending-tool semantics are unchanged"); assert_eq!(tool.call_count(), 1, "pending-tool semantics are unchanged");
@@ -716,17 +775,18 @@ async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
#[tokio::test] #[tokio::test]
async fn interceptor_continuation_consumes_the_logical_run_budget() { async fn interceptor_continuation_consumes_the_logical_run_budget() {
let mut history: History = History::new();
let mut engine = Engine::new(MockLlmClient::new(completed_text_events())); let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_max_turns(Some(1)); engine.set_max_turns(Some(1));
engine.set_interceptor(ContinueTurnOnce { engine.set_interceptor(ContinueTurnOnce {
calls: AtomicUsize::new(0), calls: AtomicUsize::new(0),
}); });
let mut engine = engine.lock(); let mut engine = engine.lock(&history);
assert_eq!( assert!(matches!(
engine.run("start").await.unwrap(), engine.run(&mut history, "start").await,
EngineResult::LimitReached EngineRunExit::Interrupted(StopReason::LimitReached)
); ));
assert_eq!(engine.turn_count(), 1); assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.llm_call_count(), 1); assert_eq!(engine.llm_call_count(), 1);
assert_eq!(engine.active_run_turn_count(), None); assert_eq!(engine.active_run_turn_count(), None);
@@ -734,14 +794,17 @@ async fn interceptor_continuation_consumes_the_logical_run_budget() {
#[tokio::test] #[tokio::test]
async fn restored_active_run_budget_is_enforced_before_another_llm_call() { async fn restored_active_run_budget_is_enforced_before_another_llm_call() {
let mut history: History = History::new();
let mut engine = Engine::new(MockLlmClient::new(completed_text_events())); let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_max_turns(Some(1)); engine.set_max_turns(Some(1));
engine.set_turn_count(7); engine.set_turn_count(7);
engine.set_last_run_interrupted(true);
engine.set_active_run_turn_count(Some(1)); engine.set_active_run_turn_count(Some(1));
let mut engine = engine.lock(); let mut engine = engine.lock(&history);
assert_eq!(engine.resume().await.unwrap(), EngineResult::LimitReached); assert!(matches!(
engine.resume(&mut history).await,
EngineRunExit::Interrupted(StopReason::LimitReached)
));
assert_eq!(engine.turn_count(), 7); assert_eq!(engine.turn_count(), 7);
assert_eq!(engine.llm_call_count(), 0); assert_eq!(engine.llm_call_count(), 0);
assert_eq!(engine.active_run_turn_count(), None); assert_eq!(engine.active_run_turn_count(), None);
+678 -10
View File
@@ -6,12 +6,13 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use agen::Engine;
use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo}; use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent}; use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::tool::{ use agen::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult, Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
ToolResultDisposition,
}; };
use agen::{Engine, History, Item, ToolExecutionPolicy};
use async_trait::async_trait; use async_trait::async_trait;
mod common; mod common;
@@ -70,6 +71,144 @@ impl Tool for SlowTool {
} }
} }
#[derive(Clone)]
struct FirstAttemptHangsTool {
calls: Arc<AtomicUsize>,
}
impl FirstAttemptHangsTool {
fn new() -> Self {
Self {
calls: Arc::new(AtomicUsize::new(0)),
}
}
fn definition(&self) -> ToolDefinition {
let tool = self.clone();
Arc::new(move || {
let meta = ToolMeta::new("hang_once")
.description("Hangs on the first execution attempt")
.input_schema(serde_json::json!({"type": "object"}));
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
})
}
fn call_count(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait]
impl Tool for FirstAttemptHangsTool {
async fn execute(
&self,
_input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let attempt = self.calls.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
std::future::pending::<()>().await;
}
Ok("completed on retry".to_string().into())
}
}
#[derive(Clone)]
struct CooperativeCancelTool {
calls: Arc<AtomicUsize>,
cancelled: Arc<tokio::sync::Notify>,
}
impl CooperativeCancelTool {
fn new() -> Self {
Self {
calls: Arc::new(AtomicUsize::new(0)),
cancelled: Arc::new(tokio::sync::Notify::new()),
}
}
fn definition(&self) -> ToolDefinition {
let tool = self.clone();
Arc::new(move || {
let meta = ToolMeta::new("cooperative")
.description("Returns bounded progress after cancellation")
.input_schema(serde_json::json!({"type": "object"}));
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
})
}
}
#[async_trait]
impl Tool for CooperativeCancelTool {
async fn execute(
&self,
_input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.cancelled.notified().await;
Err(ToolError::Cancelled(ToolOutput {
summary: "cooperative command cancelled".to_string(),
content: Some("stdout before cancellation\nstderr before cancellation".to_string()),
attachments: Vec::new(),
}))
}
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
self.cancelled.notify_one();
Ok(())
}
}
#[derive(Clone)]
struct SafePauseTool {
calls: Arc<AtomicUsize>,
cancellations: Arc<AtomicUsize>,
release: Arc<tokio::sync::Notify>,
}
impl SafePauseTool {
fn new() -> Self {
Self {
calls: Arc::new(AtomicUsize::new(0)),
cancellations: Arc::new(AtomicUsize::new(0)),
release: Arc::new(tokio::sync::Notify::new()),
}
}
fn definition(&self) -> ToolDefinition {
let tool = self.clone();
Arc::new(move || {
let meta = ToolMeta::new("safe_pause")
.description("Waits for a safe-boundary release")
.input_schema(serde_json::json!({"type": "object"}));
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
})
}
}
#[async_trait]
impl Tool for SafePauseTool {
async fn execute(
&self,
_input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.release.notified().await;
Ok(ToolOutput {
summary: "safe-boundary complete".to_string(),
content: Some("safe-boundary complete".to_string()),
attachments: Vec::new(),
})
}
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
self.cancellations.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
#[derive(Clone)] #[derive(Clone)]
struct ContextRecordingTool { struct ContextRecordingTool {
name: String, name: String,
@@ -145,6 +284,7 @@ async fn test_parallel_tool_execution() {
], ],
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let tool1 = SlowTool::new("slow_tool_1", 100); let tool1 = SlowTool::new("slow_tool_1", 100);
let tool2 = SlowTool::new("slow_tool_2", 100); let tool2 = SlowTool::new("slow_tool_2", 100);
let tool3 = SlowTool::new("slow_tool_3", 100); let tool3 = SlowTool::new("slow_tool_3", 100);
@@ -159,7 +299,7 @@ async fn test_parallel_tool_execution() {
let start = Instant::now(); let start = Instant::now();
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineResult)
let _result = engine.run("Run all tools").await; let _result = engine.run(&mut history, "Run all tools").await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
// Verify all tools were called // Verify all tools were called
@@ -178,6 +318,450 @@ async fn test_parallel_tool_execution() {
println!("Parallel execution completed in {:?}", elapsed); println!("Parallel execution completed in {:?}", elapsed);
} }
#[tokio::test]
async fn completed_results_commit_before_publish_without_waiting_for_siblings() {
let client = MockLlmClient::with_responses(vec![
vec![
Event::tool_use_start(0, "call_slow", "slow_first"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::tool_use_start(1, "call_fast", "fast_second"),
Event::tool_input_delta(1, r#"{}"#),
Event::tool_use_stop(1),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
vec![
Event::text_block_start(0),
Event::text_delta(0, "Done"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
]);
let client_probe = client.clone();
let mut engine = Engine::new(client);
engine.register_tool(SlowTool::new("slow_first", 100).definition());
engine.register_tool(SlowTool::new("fast_second", 5).definition());
let observed = Arc::new(Mutex::new(Vec::<String>::new()));
let published = observed.clone();
engine.on_tool_result(move |result| {
published
.lock()
.unwrap()
.push(format!("publish:{}", result.tool_use_id));
});
let committed = observed.clone();
let mut annotate = move |item: &Item| {
if let Item::ToolResult { call_id, .. } = item {
committed.lock().unwrap().push(format!("commit:{call_id}"));
}
Ok(())
};
let mut history = History::new();
let _ = engine
.run_with_annotation(&mut history, "run both", &mut annotate)
.await;
observed.lock().unwrap().push("run-returned".to_string());
assert_eq!(
observed.lock().unwrap().as_slice(),
[
"commit:call_fast",
"publish:call_fast",
"commit:call_slow",
"publish:call_slow",
"run-returned",
]
);
let committed_order: Vec<_> = history
.iter()
.filter_map(|entry| match &entry.item {
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
assert_eq!(committed_order, ["call_fast", "call_slow"]);
let requests = client_probe.requests();
let projected_order: Vec<_> = requests[1]
.items
.iter()
.filter_map(|item| match item {
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
assert_eq!(projected_order, ["call_slow", "call_fast"]);
}
#[tokio::test]
async fn cancellation_preserves_completed_results_and_resume_skips_them() {
let client = MockLlmClient::with_responses(vec![
vec![
Event::tool_use_start(0, "call_hang", "hang_once"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::tool_use_start(1, "call_fast_a", "fast_a"),
Event::tool_input_delta(1, r#"{}"#),
Event::tool_use_stop(1),
Event::tool_use_start(2, "call_fast_b", "fast_b"),
Event::tool_input_delta(2, r#"{}"#),
Event::tool_use_stop(2),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
vec![
Event::text_block_start(0),
Event::text_delta(0, "Recovered"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
]);
let mut engine = Engine::new(client);
let hanging = FirstAttemptHangsTool::new();
let fast_a = SlowTool::new("fast_a", 1);
let fast_b = SlowTool::new("fast_b", 2);
engine.register_tool(hanging.definition());
engine.register_tool(fast_a.definition());
engine.register_tool(fast_b.definition());
let cancel = engine.cancel_sender();
let cancel_task = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(30)).await;
cancel.send(()).await.unwrap();
});
let mut history = History::new();
let output = engine.run(&mut history, "start").await;
let mut engine = output.engine;
cancel_task.await.unwrap();
let completed_before_resume = history
.iter()
.filter(|entry| {
matches!(
&entry.item,
Item::ToolResult { call_id, .. }
if call_id == "call_fast_a" || call_id == "call_fast_b"
)
})
.count();
let unknown_before_resume = history
.iter()
.filter(|entry| {
matches!(
&entry.item,
Item::ToolResult {
call_id,
disposition: ToolResultDisposition::OutcomeUnknown,
..
} if call_id == "call_hang"
)
})
.count();
assert_eq!(completed_before_resume, 2);
assert_eq!(unknown_before_resume, 1);
assert_eq!(fast_a.call_count(), 1);
assert_eq!(fast_b.call_count(), 1);
assert_eq!(hanging.call_count(), 1);
let _ = engine.resume(&mut history).await;
assert_eq!(
fast_a.call_count(),
1,
"completed call must not be re-executed"
);
assert_eq!(
fast_b.call_count(),
1,
"completed call must not be re-executed"
);
assert_eq!(
hanging.call_count(),
1,
"OutcomeUnknown is terminal and must not be re-executed"
);
let completed_after_resume = history
.iter()
.filter(|entry| {
matches!(
&entry.item,
Item::ToolResult { call_id, .. }
if call_id == "call_fast_a" || call_id == "call_fast_b"
)
})
.count();
assert_eq!(completed_after_resume, 2);
assert_eq!(
history
.iter()
.filter(|entry| {
matches!(
&entry.item,
Item::ToolResult {
call_id,
disposition: ToolResultDisposition::OutcomeUnknown,
..
} if call_id == "call_hang"
)
})
.count(),
1
);
}
#[tokio::test]
async fn cooperative_cancellation_commits_bounded_terminal_output() {
let client = MockLlmClient::with_responses(vec![vec![
Event::tool_use_start(0, "call_cooperative", "cooperative"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]]);
let mut engine = Engine::new(client);
let tool = CooperativeCancelTool::new();
engine.register_tool(tool.definition());
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
let published = observed.clone();
engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
let committed = observed.clone();
let mut annotate = move |item: &Item| {
if matches!(item, Item::ToolResult { .. }) {
committed.lock().unwrap().push("committed");
}
Ok(())
};
let cancel = engine.cancel_sender();
let cancel_task = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(30)).await;
cancel.send(()).await.unwrap();
});
let mut history = History::new();
let output = engine
.run_with_annotation(&mut history, "start", &mut annotate)
.await;
observed.lock().unwrap().push("run-returned");
cancel_task.await.unwrap();
assert_eq!(
observed.lock().unwrap().as_slice(),
["committed", "published", "run-returned"]
);
assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
let terminal: Vec<_> = history
.iter()
.filter_map(|entry| match &entry.item {
Item::ToolResult {
call_id,
disposition,
content,
..
} if call_id == "call_cooperative" => Some((*disposition, content.as_deref())),
_ => None,
})
.collect();
assert_eq!(terminal.len(), 1);
assert_eq!(terminal[0].0, ToolResultDisposition::Cancelled);
assert_eq!(
terminal[0].1,
Some("stdout before cancellation\nstderr before cancellation")
);
assert!(matches!(
output.result,
agen::EngineRunExit::Interrupted(agen::StopReason::Cancelled)
));
}
#[tokio::test]
async fn pause_waits_for_started_tool_terminal_without_cancelling_provider() {
let client = MockLlmClient::with_responses(vec![vec![
Event::tool_use_start(0, "call_safe_pause", "safe_pause"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]]);
let mut engine = Engine::new(client);
let tool = SafePauseTool::new();
engine.register_tool(tool.definition());
let pause = engine.pause_sender();
let calls = Arc::clone(&tool.calls);
let release = Arc::clone(&tool.release);
let control = tokio::spawn(async move {
tokio::time::timeout(Duration::from_secs(1), async {
while calls.load(Ordering::SeqCst) == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("tool execution starts");
pause.send(()).await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
release.notify_one();
});
let started_at = std::time::Instant::now();
let mut history = History::new();
let output = engine.run(&mut history, "pause safely").await;
control.await.unwrap();
assert!(started_at.elapsed() >= Duration::from_millis(50));
assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
assert_eq!(tool.cancellations.load(Ordering::SeqCst), 0);
assert!(matches!(output.result, agen::EngineRunExit::Paused));
assert!(history.iter().any(|entry| matches!(
&entry.item,
Item::ToolResult {
call_id,
disposition: ToolResultDisposition::Success,
..
} if call_id == "call_safe_pause"
)));
}
#[tokio::test]
async fn pause_escalates_to_explicit_cancel_and_confirm_after_safe_boundary_deadline() {
let client = MockLlmClient::with_responses(vec![vec![
Event::tool_use_start(0, "call_pause_cancel", "cooperative"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]]);
let mut engine = Engine::new(client);
engine.set_tool_execution_policy(ToolExecutionPolicy {
pause_safe_boundary_timeout: Duration::from_millis(20),
cancellation_request_timeout: Duration::from_millis(50),
terminal_confirmation_timeout: Duration::from_millis(100),
});
let tool = CooperativeCancelTool::new();
engine.register_tool(tool.definition());
let pause = engine.pause_sender();
let calls = Arc::clone(&tool.calls);
let control = tokio::spawn(async move {
tokio::time::timeout(Duration::from_secs(1), async {
while calls.load(Ordering::SeqCst) == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("tool execution starts");
pause.send(()).await.unwrap();
});
let mut history = History::new();
let output = engine.run(&mut history, "pause with escalation").await;
control.await.unwrap();
assert!(matches!(output.result, agen::EngineRunExit::Paused));
assert!(history.iter().any(|entry| matches!(
&entry.item,
Item::ToolResult {
call_id,
disposition: ToolResultDisposition::Cancelled,
..
} if call_id == "call_pause_cancel"
)));
}
#[tokio::test]
async fn cancellation_completion_race_commits_one_terminal_output() {
for iteration in 0..24u64 {
let client = MockLlmClient::with_responses(vec![
vec![
Event::tool_use_start(0, "call_racy", "racy"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
vec![Event::Status(StatusEvent {
status: ResponseStatus::Completed,
})],
]);
let mut engine = Engine::new(client);
let delay = 2 + iteration % 3;
let tool = SlowTool::new("racy", delay);
engine.register_tool(tool.definition());
let cancel = engine.cancel_sender();
let cancel_task = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(delay)).await;
let _ = cancel.send(()).await;
});
let mut history = History::new();
let _ = engine.run(&mut history, "race").await;
cancel_task.await.unwrap();
let terminal_count = history
.iter()
.filter(|entry| {
matches!(
&entry.item,
Item::ToolResult { call_id, .. } if call_id == "call_racy"
)
})
.count();
assert_eq!(terminal_count, 1, "iteration {iteration}");
assert_eq!(tool.call_count(), 1, "iteration {iteration}");
}
}
#[tokio::test]
async fn tool_result_commit_failure_prevents_publication() {
let client = MockLlmClient::with_responses(vec![vec![
Event::tool_use_start(0, "call_fast", "fast"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]]);
let mut engine = Engine::new(client);
engine.register_tool(SlowTool::new("fast", 1).definition());
let published = Arc::new(AtomicUsize::new(0));
let published_probe = published.clone();
engine.on_tool_result(move |_| {
published_probe.fetch_add(1, Ordering::SeqCst);
});
let mut history = History::new();
let mut reject_tool_result = |item: &Item| {
if matches!(item, Item::ToolResult { .. }) {
Err("session log unavailable".to_string())
} else {
Ok(())
}
};
let _ = engine
.run_with_annotation(&mut history, "start", &mut reject_tool_result)
.await;
assert_eq!(published.load(Ordering::SeqCst), 0);
assert!(
history
.iter()
.all(|entry| !matches!(entry.item, Item::ToolResult { .. }))
);
}
#[tokio::test] #[tokio::test]
async fn test_tool_execution_context_order_and_batch_id() { async fn test_tool_execution_context_order_and_batch_id() {
let client = MockLlmClient::with_responses(vec![ let client = MockLlmClient::with_responses(vec![
@@ -205,13 +789,14 @@ async fn test_tool_execution_context_order_and_batch_id() {
], ],
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let contexts = Arc::new(Mutex::new(Vec::new())); let contexts = Arc::new(Mutex::new(Vec::new()));
engine.register_tool(ContextRecordingTool::new("record_a", contexts.clone()).definition()); engine.register_tool(ContextRecordingTool::new("record_a", contexts.clone()).definition());
engine.register_tool(ContextRecordingTool::new("record_b", contexts.clone()).definition()); engine.register_tool(ContextRecordingTool::new("record_b", contexts.clone()).definition());
engine.register_tool(ContextRecordingTool::new("record_c", contexts.clone()).definition()); engine.register_tool(ContextRecordingTool::new("record_c", contexts.clone()).definition());
let _ = engine.run("record contexts").await; let _ = engine.run(&mut history, "record contexts").await;
let mut contexts = contexts.lock().unwrap().clone(); let mut contexts = contexts.lock().unwrap().clone();
contexts.sort_by_key(|ctx| ctx.call_index); contexts.sort_by_key(|ctx| ctx.call_index);
@@ -256,11 +841,12 @@ async fn test_tool_execution_context_batch_id_changes_between_batches() {
], ],
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let contexts = Arc::new(Mutex::new(Vec::new())); let contexts = Arc::new(Mutex::new(Vec::new()));
engine.register_tool(ContextRecordingTool::new("record", contexts.clone()).definition()); engine.register_tool(ContextRecordingTool::new("record", contexts.clone()).definition());
let _ = engine.run("record batches").await; let _ = engine.run(&mut history, "record batches").await;
let contexts = contexts.lock().unwrap().clone(); let contexts = contexts.lock().unwrap().clone();
assert_eq!(contexts.len(), 2); assert_eq!(contexts.len(), 2);
@@ -298,6 +884,7 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
], ],
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let executed_contexts = Arc::new(Mutex::new(Vec::new())); let executed_contexts = Arc::new(Mutex::new(Vec::new()));
let pre_contexts = Arc::new(Mutex::new(Vec::new())); let pre_contexts = Arc::new(Mutex::new(Vec::new()));
let post_contexts = Arc::new(Mutex::new(Vec::new())); let post_contexts = Arc::new(Mutex::new(Vec::new()));
@@ -344,7 +931,9 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
post_contexts: post_contexts.clone(), post_contexts: post_contexts.clone(),
}); });
let _ = engine.run("record skipped and synthetic contexts").await; let _ = engine
.run(&mut history, "record skipped and synthetic contexts")
.await;
let mut pre_contexts = pre_contexts.lock().unwrap().clone(); let mut pre_contexts = pre_contexts.lock().unwrap().clone();
pre_contexts.sort_by_key(|ctx| ctx.call_index); pre_contexts.sort_by_key(|ctx| ctx.call_index);
@@ -389,6 +978,7 @@ async fn test_before_tool_call_skip() {
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let allowed_tool = SlowTool::new("allowed_tool", 10); let allowed_tool = SlowTool::new("allowed_tool", 10);
let blocked_tool = SlowTool::new("blocked_tool", 10); let blocked_tool = SlowTool::new("blocked_tool", 10);
@@ -416,7 +1006,7 @@ async fn test_before_tool_call_skip() {
engine.set_interceptor(BlockingPolicy); engine.set_interceptor(BlockingPolicy);
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineResult)
let _result = engine.run("Test hook").await; let _result = engine.run(&mut history, "Test hook").await;
// allowed_tool is called, but blocked_tool is not // allowed_tool is called, but blocked_tool is not
assert_eq!( assert_eq!(
@@ -457,6 +1047,7 @@ async fn test_post_tool_call_modification() {
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
#[derive(Clone)] #[derive(Clone)]
struct SimpleTool; struct SimpleTool;
@@ -503,9 +1094,12 @@ async fn test_post_tool_call_modification() {
}); });
// Mutable::run consumes self, returns (Locked, EngineResult) // Mutable::run consumes self, returns (Locked, EngineResult)
let result = engine.run("Test modification").await; let result = engine.run(&mut history, "Test modification").await;
assert!(result.is_ok(), "Engine should complete"); assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete"
);
// Verify hook was called and content was modified // Verify hook was called and content was modified
let content = modified_content.lock().unwrap().clone(); let content = modified_content.lock().unwrap().clone();
@@ -540,6 +1134,7 @@ async fn test_before_tool_call_synthetic_result_committed() {
], ],
]); ]);
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
let blocked_tool = SlowTool::new("blocked_tool", 10); let blocked_tool = SlowTool::new("blocked_tool", 10);
let blocked_clone = blocked_tool.clone(); let blocked_clone = blocked_tool.clone();
engine.register_tool(blocked_tool.definition()); engine.register_tool(blocked_tool.definition());
@@ -558,10 +1153,10 @@ async fn test_before_tool_call_synthetic_result_committed() {
engine.set_interceptor(SyntheticPolicy); engine.set_interceptor(SyntheticPolicy);
let result = engine.run("Test synthetic result").await.unwrap(); let _result = engine.run(&mut history, "Test synthetic result").await;
assert_eq!(blocked_clone.call_count(), 0, "Blocked tool should not run"); assert_eq!(blocked_clone.call_count(), 0, "Blocked tool should not run");
assert!(result.engine.history().iter().any(|item| matches!( assert!(history.items().any(|item| matches!(
item, item,
agen::Item::ToolResult { agen::Item::ToolResult {
call_id, call_id,
@@ -571,3 +1166,76 @@ async fn test_before_tool_call_synthetic_result_committed() {
} if call_id == "call_1" && summary == "permission denied" } if call_id == "call_1" && summary == "permission denied"
))); )));
} }
#[tokio::test]
async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
let client = MockLlmClient::new(vec![
Event::tool_use_start(0, "call_confirmed", "confirmed"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]);
let mut engine = Engine::new(client);
let tool = SlowTool::new("confirmed", 1);
engine.register_tool(tool.definition());
struct AbortAfterResult;
#[async_trait]
impl Interceptor for AbortAfterResult {
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
PostToolAction::Abort("policy stopped the run".to_string())
}
}
engine.set_interceptor(AbortAfterResult);
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
let published = observed.clone();
engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
let committed = observed.clone();
let mut annotate = move |item: &Item| {
if matches!(item, Item::ToolResult { .. }) {
committed.lock().unwrap().push("committed");
}
Ok(())
};
let mut history = History::new();
let output = engine
.run_with_annotation(&mut history, "run confirmed tool", &mut annotate)
.await;
observed.lock().unwrap().push("run-returned");
assert_eq!(tool.call_count(), 1);
assert_eq!(
observed.lock().unwrap().as_slice(),
["committed", "published", "run-returned"]
);
assert!(matches!(
output.result,
agen::EngineRunExit::Interrupted(agen::StopReason::Unexpected(
agen::EngineError::Aborted(ref reason)
)) if reason == "policy stopped the run"
));
let terminal: Vec<_> = history
.iter()
.filter_map(|entry| match &entry.item {
Item::ToolResult {
call_id,
disposition,
..
} if call_id == "call_confirmed" => Some(*disposition),
_ => None,
})
.collect();
assert_eq!(terminal, [ToolResultDisposition::Success]);
assert!(!history.iter().any(|entry| matches!(
&entry.item,
Item::ToolResult {
call_id,
disposition: ToolResultDisposition::OutcomeUnknown,
..
} if call_id == "call_confirmed"
)));
}
+23 -19
View File
@@ -13,12 +13,12 @@
mod common; mod common;
use agen::Engine;
use agen::Item; use agen::Item;
use agen::llm_client::event::{ use agen::llm_client::event::{
BlockMetadata, BlockStart, BlockStop, BlockType, Event, ReasoningBlockData, ResponseStatus, BlockMetadata, BlockStart, BlockStop, BlockType, Event, ReasoningBlockData, ResponseStatus,
StatusEvent, StatusEvent,
}; };
use agen::{Engine, History};
use common::MockLlmClient; use common::MockLlmClient;
fn reasoning_block(text: impl Into<String>, data: ReasoningBlockData) -> Vec<Event> { fn reasoning_block(text: impl Into<String>, data: ReasoningBlockData) -> Vec<Event> {
@@ -65,15 +65,15 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
]); ]);
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let engine = Engine::new(client); let engine = Engine::new(client);
let out = engine.run("question?").await.expect("run ok"); let mut history: History = History::new();
let engine = out.engine; let _out = engine.run(&mut history, "question?").await;
let history = engine.history(); let entries = history.entries();
// user / reasoning / assistant_message // user / reasoning / assistant_message
assert_eq!(history.len(), 3, "history: {history:?}"); assert_eq!(history.len(), 3, "history: {history:?}");
assert!(matches!(history[0], Item::Message { .. })); assert!(matches!(entries[0].item, Item::Message { .. }));
match &history[1] { match &entries[1].item {
Item::Reasoning { Item::Reasoning {
text, signature, .. text, signature, ..
} => { } => {
@@ -82,7 +82,7 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
} }
other => panic!("expected Reasoning, got {other:?}"), other => panic!("expected Reasoning, got {other:?}"),
} }
assert_eq!(history[2].as_text(), Some("Here's the answer")); assert_eq!(entries[2].item.as_text(), Some("Here's the answer"));
} }
/// OpenAI Responses 風: encrypted_content + summary を持った reasoning が /// OpenAI Responses 風: encrypted_content + summary を持った reasoning が
@@ -109,11 +109,11 @@ async fn openai_reasoning_round_trips_encrypted_and_summary() {
]); ]);
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let engine = Engine::new(client); let engine = Engine::new(client);
let out = engine.run("q").await.expect("run ok"); let mut history: History = History::new();
let engine = out.engine; let _out = engine.run(&mut history, "q").await;
let history = engine.history(); let entries = history.entries();
match &history[1] { match &entries[1].item {
Item::Reasoning { Item::Reasoning {
text, text,
summary, summary,
@@ -155,13 +155,13 @@ async fn reasoning_precedes_text_in_assistant_burst() {
})); }));
let client = MockLlmClient::new(events); let client = MockLlmClient::new(events);
let engine = Engine::new(client); let engine = Engine::new(client);
let out = engine.run("q").await.expect("run ok"); let mut history: History = History::new();
let engine = out.engine; let _out = engine.run(&mut history, "q").await;
let history = engine.history(); let entries = history.entries();
// user / reasoning(先頭) / assistant_message // user / reasoning(先頭) / assistant_message
assert!(matches!(history[1], Item::Reasoning { .. })); assert!(matches!(entries[1].item, Item::Reasoning { .. }));
assert_eq!(history[2].as_text(), Some("intermediate")); assert_eq!(entries[2].item.as_text(), Some("intermediate"));
} }
/// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Engine に /// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Engine に
@@ -207,14 +207,18 @@ async fn injected_reasoning_survives_into_outgoing_request() {
}; };
let mut engine = Engine::new(client); let mut engine = Engine::new(client);
let mut history: History = History::new();
// resume: 既存 history を流し込む // resume: 既存 history を流し込む
engine.set_history(vec![ engine.set_history(
&mut history,
vec![
Item::user_message("prior question"), Item::user_message("prior question"),
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"), Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
Item::assistant_message("prior answer"), Item::assistant_message("prior answer"),
]); ],
);
let _ = engine.run("follow up").await.expect("run ok"); let _ = engine.run(&mut history, "follow up").await;
let req = captured let req = captured
.lock() .lock()
+3 -2
View File
@@ -1,4 +1,4 @@
use agen::Engine; use agen::{Engine, History};
use agen::llm_client::capability::{ use agen::llm_client::capability::{
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport, CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
}; };
@@ -22,7 +22,8 @@ fn main() {
cap, cap,
); );
let engine = Engine::new(client); let engine = Engine::new(client);
let mut locked = engine.lock(); let history = History::new();
let mut locked = engine.lock(&history);
let def: agen::tool::ToolDefinition = Arc::new(|| panic!("unused")); let def: agen::tool::ToolDefinition = Arc::new(|| panic!("unused"));
let _ = locked.register_tool(def); let _ = locked.register_tool(def);
} }
@@ -1,8 +1,8 @@
error[E0599]: no method named `register_tool` found for struct `Engine<HttpTransport<AnthropicScheme>, Locked>` in the current scope error[E0599]: no method named `register_tool` found for struct `Engine<HttpTransport<AnthropicScheme>, Locked>` in the current scope
--> tests/ui/locked_register_tool.rs:27:20 --> tests/ui/locked_register_tool.rs:28:20
| |
27 | let _ = locked.register_tool(def); 28 | let _ = locked.register_tool(def);
| ^^^^^^^^^^^^^ method not found in `Engine<HttpTransport<AnthropicScheme>, Locked>` | ^^^^^^^^^^^^^ method not found in `Engine<HttpTransport<AnthropicScheme>, Locked>`
| |
= note: the method was found for = note: the method was found for
- `Engine<C>` - `Engine<C, Mutable, A>`
+3 -3
View File
@@ -26,7 +26,7 @@ struct BackendWorkerLaunchOptions {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct BackendWorkerLaunchRuntime { struct BackendWorkerLaunchRuntime {
runtime_id: String, runtime_id: String,
can_spawn_worker: bool, worker_creation_available: bool,
working_directory_required: bool, working_directory_required: bool,
} }
@@ -261,7 +261,7 @@ impl BackendWorkspaceProductClient {
let runtime = options let runtime = options
.runtimes .runtimes
.iter() .iter()
.find(|runtime| runtime.can_spawn_worker && !runtime.working_directory_required) .find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required)
.ok_or_else(|| { .ok_or_else(|| {
BackendWorkspaceClientError::InvalidTarget( BackendWorkspaceClientError::InvalidTarget(
"Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker" "Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker"
@@ -777,7 +777,7 @@ mod tests {
let (base_url, requests, handle) = response_sequence_server(vec![ let (base_url, requests, handle) = response_sequence_server(vec![
( (
"200 OK", "200 OK",
r#"{"runtimes":[{"runtime_id":"embedded","can_spawn_worker":true,"working_directory_required":false}]}"#, r#"{"runtimes":[{"runtime_id":"embedded","worker_creation_available":true,"working_directory_required":false}]}"#,
), ),
( (
"200 OK", "200 OK",
+4 -1
View File
@@ -9,7 +9,7 @@
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::schema::{EvidenceKind, SourceEvidenceRef, SourceRef}; use crate::schema::{EvidenceKind, EvidenceOrigin, SourceEvidenceRef, SourceRef};
/// Current flat staging schema version. /// Current flat staging schema version.
pub const STAGING_SCHEMA_VERSION: u32 = 2; pub const STAGING_SCHEMA_VERSION: u32 = 2;
@@ -80,6 +80,8 @@ pub struct StagingEvidence {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_range: Option<[u64; 2]>, pub entry_range: Option<[u64; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<EvidenceOrigin>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub excerpt: Option<String>, pub excerpt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>, pub summary: Option<String>,
@@ -159,6 +161,7 @@ mod tests {
id: "E001".into(), id: "E001".into(),
kind: EvidenceKind::new(EvidenceKind::MESSAGE), kind: EvidenceKind::new(EvidenceKind::MESSAGE),
entry_range: Some([10, 12]), entry_range: Some([10, 12]),
origin: None,
excerpt: Some("extract candidate taxonomy".into()), excerpt: Some("extract candidate taxonomy".into()),
summary: Some("User and assistant discussed staging kinds".into()), summary: Some("User and assistant discussed staging kinds".into()),
}; };
+37
View File
@@ -67,6 +67,40 @@ impl EvidenceKind {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceOriginKind {
HumanInput,
WorkerInput,
FlowInstruction,
BackendInstruction,
ModelOutput,
ToolOutput,
DerivedSummary,
LegacyUnknown,
}
/// Bounded origin snapshot attached to extraction evidence. This is audit
/// metadata only and cannot authorize Workspace operations.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct EvidenceOrigin {
pub kind: EvidenceOriginKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flow_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flow_definition_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flow_definition_revision: Option<u64>,
}
/// Host-resolved source/evidence metadata for an individual staging claim. /// Host-resolved source/evidence metadata for an individual staging claim.
/// ///
/// This deliberately stores only bounded anchor metadata: stable ids, entry /// This deliberately stores only bounded anchor metadata: stable ids, entry
@@ -86,6 +120,9 @@ pub struct SourceEvidenceRef {
/// Host-assigned evidence id within the referenced evidence set. /// Host-assigned evidence id within the referenced evidence set.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence_id: Option<String>, pub evidence_id: Option<String>,
/// Trusted typed origin snapshot for this logical evidence entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<EvidenceOrigin>,
/// Extensible evidence kind tag. /// Extensible evidence kind tag.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence_kind: Option<EvidenceKind>, pub evidence_kind: Option<EvidenceKind>,
+4 -1
View File
@@ -10,7 +10,10 @@ mod decision;
mod request; mod request;
mod summary; mod summary;
pub use common::{EvidenceKind, Frontmatter, SourceEvidenceRef, SourceRef, split_frontmatter}; pub use common::{
EvidenceKind, EvidenceOrigin, EvidenceOriginKind, Frontmatter, SourceEvidenceRef, SourceRef,
split_frontmatter,
};
pub use decision::{DecisionFrontmatter, DecisionStatus}; pub use decision::{DecisionFrontmatter, DecisionStatus};
pub use request::RequestFrontmatter; pub use request::RequestFrontmatter;
pub use summary::SummaryFrontmatter; pub use summary::SummaryFrontmatter;
+21 -1
View File
@@ -352,6 +352,18 @@ pub struct InternalWorkerSnapshot {
pub internal_workers: Vec<InternalWorkerSnapshot>, pub internal_workers: Vec<InternalWorkerSnapshot>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum ToolResultDisposition {
#[default]
Success,
Error,
Interrupted,
Cancelled,
OutcomeUnknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "event", content = "data", rename_all = "snake_case")] #[serde(tag = "event", content = "data", rename_all = "snake_case")]
@@ -501,6 +513,8 @@ pub enum Event {
/// summary-only, or when the result was pruned. /// summary-only, or when the result was pruned.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
output: Option<String>, output: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
disposition: Option<ToolResultDisposition>,
#[serde(default)] #[serde(default)]
is_error: bool, is_error: bool,
}, },
@@ -923,6 +937,7 @@ pub enum WorkerStatus {
Idle, Idle,
Running, Running,
Paused, Paused,
Stopped,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -1405,7 +1420,7 @@ mod tests {
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "completions"); assert_eq!(parsed["event"], "completions");
assert_eq!(parsed["data"]["kind"], "file"); assert_eq!(parsed["data"]["kind"], "file");
assert_eq!(parsed["data"]["entries"][0]["value"], "clear"); assert_eq!(parsed["data"]["entries"][0]["value"], "src/main.rs");
// is_dir defaults to false on inbound payloads that omit it. // is_dir defaults to false on inbound payloads that omit it.
let inbound = let inbound =
@@ -1838,6 +1853,7 @@ mod tests {
id: "call_1".into(), id: "call_1".into(),
summary: "Read 128 bytes".into(), summary: "Read 128 bytes".into(),
output: Some("hello world".into()), output: Some("hello world".into()),
disposition: Some(ToolResultDisposition::Success),
is_error: false, is_error: false,
}; };
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
@@ -1854,11 +1870,13 @@ mod tests {
id, id,
summary, summary,
output, output,
disposition,
is_error, is_error,
} => { } => {
assert_eq!(id, "call_1"); assert_eq!(id, "call_1");
assert_eq!(summary, "Read 128 bytes"); assert_eq!(summary, "Read 128 bytes");
assert_eq!(output.as_deref(), Some("hello world")); assert_eq!(output.as_deref(), Some("hello world"));
assert_eq!(disposition, Some(ToolResultDisposition::Success));
assert!(!is_error); assert!(!is_error);
} }
other => panic!("expected ToolResult, got {other:?}"), other => panic!("expected ToolResult, got {other:?}"),
@@ -1871,6 +1889,7 @@ mod tests {
id: "call_2".into(), id: "call_2".into(),
summary: "ok".into(), summary: "ok".into(),
output: None, output: None,
disposition: Some(ToolResultDisposition::Success),
is_error: false, is_error: false,
}; };
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
@@ -1886,6 +1905,7 @@ mod tests {
id: "call_3".into(), id: "call_3".into(),
summary: "invalid argument".into(), summary: "invalid argument".into(),
output: None, output: None,
disposition: Some(ToolResultDisposition::Error),
is_error: true, is_error: true,
}; };
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
+2 -1
View File
@@ -8,7 +8,7 @@ use crate::{
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId, InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId,
RunResult, ScopeRule, Segment, TurnResult, WorkerEvent, WorkerStatus, RunResult, ScopeRule, Segment, ToolResultDisposition, TurnResult, WorkerEvent, WorkerStatus,
subscription::{ subscription::{
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame, EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest, SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
@@ -45,6 +45,7 @@ pub fn generated_protocol_types() -> String {
push_decl::<TurnResult>(&cfg, &mut output); push_decl::<TurnResult>(&cfg, &mut output);
push_decl::<InvokeKind>(&cfg, &mut output); push_decl::<InvokeKind>(&cfg, &mut output);
push_decl::<RunResult>(&cfg, &mut output); push_decl::<RunResult>(&cfg, &mut output);
push_decl::<ToolResultDisposition>(&cfg, &mut output);
push_decl::<ErrorCode>(&cfg, &mut output); push_decl::<ErrorCode>(&cfg, &mut output);
push_decl::<Permission>(&cfg, &mut output); push_decl::<Permission>(&cfg, &mut output);
push_decl::<InFlightToolCallState>(&cfg, &mut output); push_decl::<InFlightToolCallState>(&cfg, &mut output);
+180
View File
@@ -0,0 +1,180 @@
//! Serializable history entries with restore-authoritative logical identity and origin.
use serde::{Deserialize, Serialize};
use crate::{LoggedItem, SessionId};
/// Stable logical identity of one model-visible history entry.
///
/// This value is generated at the trusted Worker session boundary and copied
/// unchanged across fork, rewind, compaction retention, restore, and reboot.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct LoggedSessionHistoryEntryId(pub String);
impl LoggedSessionHistoryEntryId {
pub fn new() -> Self {
Self(uuid::Uuid::now_v7().to_string())
}
}
impl Default for LoggedSessionHistoryEntryId {
fn default() -> Self {
Self::new()
}
}
/// Bounded subject snapshot. It is evidence, not a live authorization handle.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoggedWorkerSubject {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_id: Option<String>,
pub worker_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LoggedSessionHistoryOrigin {
HumanInput {
account_id: String,
},
WorkerInput {
actor: LoggedWorkerSubject,
},
FlowInstruction {
selector: String,
definition_id: String,
definition_revision: u64,
instance_id: String,
state_id: String,
},
BackendInstruction {
#[serde(default, skip_serializing_if = "Option::is_none")]
operation_id: Option<String>,
},
ModelOutput {
worker: LoggedWorkerSubject,
},
ToolOutput {
worker: LoggedWorkerSubject,
},
DerivedSummary,
LegacyUnknown,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoggedHistoryDerivation {
pub sources: Vec<LoggedSessionHistoryEntryId>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoggedSessionHistoryMetadata {
pub entry_id: LoggedSessionHistoryEntryId,
pub origin: LoggedSessionHistoryOrigin,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub derivation: Option<LoggedHistoryDerivation>,
}
impl LoggedSessionHistoryMetadata {
pub fn legacy_unknown() -> Self {
Self {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
}
}
}
/// Persisted item and metadata are one value so transforms cannot reorder or
/// truncate one without the other.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LoggedHistoryEntry {
pub item: LoggedItem,
pub metadata: LoggedSessionHistoryMetadata,
}
/// Typed system-item history record. The typed system event remains available
/// to client replay while its model-visible projection carries the same stable
/// metadata used by live history.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LoggedSystemHistoryEntry {
pub item: crate::SystemItem,
pub metadata: LoggedSessionHistoryMetadata,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::LoggedRole;
use agen::llm_client::RequestConfig;
#[test]
fn logged_history_entry_round_trip_preserves_id_origin_and_derivation() {
let source_id = LoggedSessionHistoryEntryId::new();
let entry = LoggedHistoryEntry {
item: LoggedItem::Message {
role: LoggedRole::User,
content: vec![crate::LoggedContentPart::Text {
text: "preference".into(),
}],
},
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::HumanInput {
account_id: "account-1".into(),
},
derivation: Some(LoggedHistoryDerivation {
sources: vec![source_id.clone()],
}),
},
};
let encoded = serde_json::to_vec(&entry).unwrap();
let decoded: LoggedHistoryEntry = serde_json::from_slice(&encoded).unwrap();
assert_eq!(decoded, entry);
assert_eq!(
decoded.metadata.derivation.unwrap().sources,
vec![source_id]
);
}
#[test]
fn annotated_segment_start_is_restore_visible_without_projecting_metadata() {
let session_id = uuid::Uuid::now_v7();
let history_entry = legacy_logged_history(LoggedItem::Message {
role: LoggedRole::Assistant,
content: vec![crate::LoggedContentPart::Text {
text: "answer".into(),
}],
});
let state = crate::collect_state(&[crate::LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: vec![history_entry],
forked_from: None,
compacted_from: None,
}]);
assert_eq!(state.history[0].as_text(), Some("answer"));
}
}
/// Legacy Session Logs did not persist annotations. Decode helpers explicitly
/// create `LegacyUnknown`; they never infer Human/System authority from role or
/// plaintext.
pub fn legacy_logged_history(item: LoggedItem) -> LoggedHistoryEntry {
LoggedHistoryEntry {
item,
metadata: LoggedSessionHistoryMetadata::legacy_unknown(),
}
}
pub fn legacy_segment_history(
session_id: SessionId,
items: impl IntoIterator<Item = LoggedItem>,
) -> Vec<LoggedHistoryEntry> {
let _ = session_id;
items.into_iter().map(legacy_logged_history).collect()
}
+6
View File
@@ -32,6 +32,7 @@
pub mod event_trace; pub mod event_trace;
pub mod fs_store; pub mod fs_store;
pub mod history;
pub mod logged_item; pub mod logged_item;
pub mod segment; pub mod segment;
pub mod segment_log; pub mod segment_log;
@@ -44,6 +45,11 @@ pub use agen::UsageRecord;
pub use agen::llm_client::types::{ContentPart, Item, Role}; pub use agen::llm_client::types::{ContentPart, Item, Role};
pub use event_trace::{TraceEntry, TracePayload}; pub use event_trace::{TraceEntry, TracePayload};
pub use fs_store::FsStore; pub use fs_store::FsStore;
pub use history::{
LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry,
LoggedWorkerSubject, legacy_logged_history, legacy_segment_history,
};
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged}; pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
pub use segment::{ pub use segment::{
SegmentStartState, append_entry, append_system_item, classify_history_item, SegmentStartState, append_entry, append_system_item, classify_history_item,
+52 -3
View File
@@ -14,7 +14,7 @@
use agen::{ use agen::{
llm_client::types::{ContentPart, Item, Role}, llm_client::types::{ContentPart, Item, Role},
tool::{Attachment, ImageAttachment}, tool::{Attachment, ImageAttachment, ToolResultDisposition},
}; };
use base64::{Engine as _, engine::general_purpose::STANDARD}; use base64::{Engine as _, engine::general_purpose::STANDARD};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
@@ -61,6 +61,8 @@ pub enum LoggedItem {
content: Option<String>, content: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<LoggedAttachment>, attachments: Vec<LoggedAttachment>,
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
disposition: ToolResultDisposition,
#[serde(default, skip_serializing_if = "is_false")] #[serde(default, skip_serializing_if = "is_false")]
is_error: bool, is_error: bool,
}, },
@@ -128,6 +130,7 @@ impl From<&Item> for LoggedItem {
summary, summary,
content, content,
attachments, attachments,
disposition,
is_error, is_error,
.. ..
} => Self::ToolResult { } => Self::ToolResult {
@@ -135,6 +138,7 @@ impl From<&Item> for LoggedItem {
summary: summary.clone(), summary: summary.clone(),
content: content.clone(), content: content.clone(),
attachments: attachments.iter().map(LoggedAttachment::from).collect(), attachments: attachments.iter().map(LoggedAttachment::from).collect(),
disposition: *disposition,
is_error: *is_error, is_error: *is_error,
}, },
Item::Reasoning { Item::Reasoning {
@@ -184,15 +188,24 @@ impl From<LoggedItem> for Item {
summary, summary,
content, content,
attachments, attachments,
disposition,
is_error, is_error,
} => Item::ToolResult { } => {
let disposition = if is_error && disposition.is_success() {
ToolResultDisposition::Error
} else {
disposition
};
Item::ToolResult {
id: None, id: None,
call_id, call_id,
summary, summary,
content, content,
disposition,
is_error, is_error,
attachments: attachments.into_iter().map(Attachment::from).collect(), attachments: attachments.into_iter().map(Attachment::from).collect(),
}, }
}
LoggedItem::Reasoning { LoggedItem::Reasoning {
text, text,
summary, summary,
@@ -430,6 +443,42 @@ mod tests {
} }
} }
#[test]
fn outcome_unknown_tool_result_round_trips_as_terminal() {
let original = Item::tool_result_item_with_disposition_and_attachments(
"call_unknown",
"outcome unknown",
Some("bounded progress".to_string()),
ToolResultDisposition::OutcomeUnknown,
Vec::new(),
);
let logged: LoggedItem = (&original).into();
let json = serde_json::to_string(&logged).unwrap();
assert!(json.contains(r#""disposition":"outcome_unknown""#));
match Item::from(serde_json::from_str::<LoggedItem>(&json).unwrap()) {
Item::ToolResult {
disposition,
is_error,
..
} => {
assert_eq!(disposition, ToolResultDisposition::OutcomeUnknown);
assert!(is_error);
}
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn legacy_error_tool_result_infers_error_disposition() {
let legacy = r#"{"kind":"tool_result","call_id":"call_old","summary":"failed","content":null,"is_error":true}"#;
match Item::from(serde_json::from_str::<LoggedItem>(legacy).unwrap()) {
Item::ToolResult { disposition, .. } => {
assert_eq!(disposition, ToolResultDisposition::Error)
}
other => panic!("unexpected variant: {other:?}"),
}
}
#[test] #[test]
fn tool_result_persistence_round_trips_binary_attachments() { fn tool_result_persistence_round_trips_binary_attachments() {
let original = Item::tool_result_item_with_attachments( let original = Item::tool_result_item_with_attachments(
+77
View File
@@ -14,6 +14,7 @@ use agen::{EngineResult, UsageRecord};
use protocol::{InvokeKind, Segment}; use protocol::{InvokeKind, Segment};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::history::{LoggedHistoryEntry, LoggedSystemHistoryEntry};
use crate::logged_item::LoggedItem; use crate::logged_item::LoggedItem;
use crate::system_item::SystemItem; use crate::system_item::SystemItem;
@@ -70,6 +71,20 @@ pub enum LogEntry {
compacted_from: Option<SegmentOrigin>, compacted_from: Option<SegmentOrigin>,
}, },
/// Schema-v2 segment seed. Retained entries keep their stable logical
/// identity and origin across fork/compaction/restore.
AnnotatedSegmentStart {
ts: u64,
session_id: crate::SessionId,
system_prompt: Option<String>,
config: RequestConfig,
history: Vec<LoggedHistoryEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_from: Option<SegmentOrigin>,
#[serde(default, skip_serializing_if = "Option::is_none")]
compacted_from: Option<SegmentOrigin>,
},
/// IDLE → active marker. Records the start of a new self-driving /// IDLE → active marker. Records the start of a new self-driving
/// cycle (Invoke range). The range extends implicitly until the /// cycle (Invoke range). The range extends implicitly until the
/// next `Invoke` entry; this entry carries the trigger only — the /// next `Invoke` entry; this entry carries the trigger only — the
@@ -105,14 +120,37 @@ pub enum LogEntry {
extensions: Vec<SessionExtension>, extensions: Vec<SessionExtension>,
}, },
/// Schema-v2 user submission with its exact model-visible entries. Typed
/// Flow instructions and caller-attributed input remain separate entries.
AnnotatedUserInput {
ts: u64,
segments: Vec<Segment>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extensions: Vec<SessionExtension>,
history: Vec<LoggedHistoryEntry>,
},
/// Schema-v2 model output and metadata committed as one journal record.
AnnotatedAssistantItem { ts: u64, entry: LoggedHistoryEntry },
/// One assistant-side item appended to history — assistant message, /// One assistant-side item appended to history — assistant message,
/// reasoning, or tool call. Singular: one entry per history item so /// reasoning, or tool call. Singular: one entry per history item so
/// the wire-side `Event::*` lane and on-disk LogEntry stay 1:1. /// the wire-side `Event::*` lane and on-disk LogEntry stay 1:1.
AssistantItem { ts: u64, item: LoggedItem }, AssistantItem { ts: u64, item: LoggedItem },
/// Schema-v2 tool output and metadata committed as one journal record.
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
/// One tool-execution result appended to history. /// One tool-execution result appended to history.
ToolResult { ts: u64, item: LoggedItem }, ToolResult { ts: u64, item: LoggedItem },
/// Schema-v2 typed system event and model-visible metadata committed
/// together.
AnnotatedSystemItem {
ts: u64,
entry: LoggedSystemHistoryEntry,
},
/// One typed agent-injected system item: notification, child-Worker /// One typed agent-injected system item: notification, child-Worker
/// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each /// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each
/// `SystemItem` carries kind metadata that the LLM /// `SystemItem` carries kind metadata that the LLM
@@ -278,6 +316,22 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.config = config.clone(); state.config = config.clone();
state.history = history.iter().cloned().map(Item::from).collect(); state.history = history.iter().cloned().map(Item::from).collect();
} }
LogEntry::AnnotatedSegmentStart {
session_id,
system_prompt,
config,
history,
..
} => {
state.session_id = Some(*session_id);
state.system_prompt = system_prompt.clone();
state.config = config.clone();
state.history = history
.iter()
.cloned()
.map(|entry| Item::from(entry.item))
.collect();
}
LogEntry::Invoke { .. } => { LogEntry::Invoke { .. } => {
// A terminal run record below clears or refines this. If the // A terminal run record below clears or refines this. If the
// log ends first, restore must treat the turn as interrupted. // log ends first, restore must treat the turn as interrupted.
@@ -298,6 +352,29 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
.map(|extension| (extension.domain.clone(), extension.payload.clone())), .map(|extension| (extension.domain.clone(), extension.payload.clone())),
); );
} }
LogEntry::AnnotatedUserInput {
segments,
extensions,
history,
..
} => {
state
.history
.extend(history.iter().cloned().map(|entry| Item::from(entry.item)));
state.user_segments.push(segments.clone());
state.extensions.extend(
extensions
.iter()
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
);
}
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => {
state.history.push(Item::from(entry.item.clone()));
}
LogEntry::AnnotatedSystemItem { entry, .. } => {
state.history.push(entry.item.to_history_item());
}
LogEntry::AssistantItem { item, .. } => { LogEntry::AssistantItem { item, .. } => {
state.history.push(Item::from(item.clone())); state.history.push(Item::from(item.clone()));
} }
@@ -20,7 +20,8 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::SystemTime; use std::time::SystemTime;
const SESSION_SCHEMA_VERSION: u32 = 1; const SESSION_SCHEMA_VERSION: u32 = 2;
const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1;
const SESSION_FILE: &str = "session.json"; const SESSION_FILE: &str = "session.json";
const SEGMENTS_DIR: &str = "segments"; const SEGMENTS_DIR: &str = "segments";
@@ -44,16 +45,23 @@ impl WorkerSessionStore {
fs::create_dir_all(root.join(SEGMENTS_DIR))?; fs::create_dir_all(root.join(SEGMENTS_DIR))?;
let session_id = match fs::read(root.join(SESSION_FILE)) { let session_id = match fs::read(root.join(SESSION_FILE)) {
Ok(bytes) => { Ok(bytes) => {
let manifest: SessionManifest = serde_json::from_slice(&bytes)?; let mut manifest: SessionManifest = serde_json::from_slice(&bytes)?;
if manifest.schema_version != SESSION_SCHEMA_VERSION { match manifest.schema_version {
SESSION_SCHEMA_VERSION => {}
LEGACY_SESSION_SCHEMA_VERSION => {
validate_legacy_segment_logs(&root)?;
manifest.schema_version = SESSION_SCHEMA_VERSION;
atomic_write_json(&root.join(SESSION_FILE), &manifest)?;
}
version => {
return Err(StoreError::Corrupt { return Err(StoreError::Corrupt {
line: 0, line: 0,
message: format!( message: format!(
"unsupported Worker Session schema version {}, expected {}", "unsupported Worker Session schema version {version}, expected {SESSION_SCHEMA_VERSION}"
manifest.schema_version, SESSION_SCHEMA_VERSION
), ),
}); });
} }
}
Some(manifest.session_id) Some(manifest.session_id)
} }
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
@@ -278,6 +286,37 @@ impl Store for WorkerSessionStore {
} }
} }
fn validate_legacy_segment_logs(root: &Path) -> Result<(), StoreError> {
let segments = root.join(SEGMENTS_DIR);
if !segments.exists() {
return Ok(());
}
for entry in fs::read_dir(&segments)? {
let entry = entry?;
let path = entry.path();
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if !name.ends_with(".jsonl") || name.ends_with(".trace.jsonl") {
continue;
}
let contents = fs::read_to_string(&path)?;
for (line_index, line) in contents.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
serde_json::from_str::<LogEntry>(line).map_err(|error| StoreError::Corrupt {
line: line_index + 1,
message: format!(
"cannot migrate legacy Worker Session log {}: {error}",
path.display()
),
})?;
}
}
Ok(())
}
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> { fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
let mut bytes = serde_json::to_vec_pretty(value)?; let mut bytes = serde_json::to_vec_pretty(value)?;
bytes.push(b'\n'); bytes.push(b'\n');
@@ -405,6 +444,54 @@ mod tests {
assert_eq!(store.list_sessions().unwrap(), vec![session_id]); assert_eq!(store.list_sessions().unwrap(), vec![session_id]);
} }
#[test]
fn schema_v1_logs_are_validated_and_promoted_to_v2() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
WorkerSessionStore::new(root.path())
.unwrap()
.create_segment(session_id, segment_id, &[])
.unwrap();
let manifest_path = root.path().join(SESSION_FILE);
let mut manifest: SessionManifest =
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
manifest.schema_version = LEGACY_SESSION_SCHEMA_VERSION;
atomic_write_json(&manifest_path, &manifest).unwrap();
let reopened = WorkerSessionStore::new(root.path()).unwrap();
assert_eq!(reopened.session_id().unwrap(), Some(session_id));
let migrated: SessionManifest =
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
assert_eq!(migrated.schema_version, SESSION_SCHEMA_VERSION);
}
#[test]
fn schema_v1_migration_rejects_corrupt_log_before_manifest_update() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let manifest = SessionManifest {
schema_version: LEGACY_SESSION_SCHEMA_VERSION,
session_id,
};
atomic_write_json(&root.path().join(SESSION_FILE), &manifest).unwrap();
fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap();
fs::write(
root.path().join(SEGMENTS_DIR).join("broken.jsonl"),
"{not-json}\n",
)
.unwrap();
let error = match WorkerSessionStore::new(root.path()) {
Ok(_) => panic!("corrupt legacy Session log must reject migration"),
Err(error) => error,
};
assert!(matches!(error, StoreError::Corrupt { .. }));
let persisted: SessionManifest =
serde_json::from_slice(&fs::read(root.path().join(SESSION_FILE)).unwrap()).unwrap();
assert_eq!(persisted.schema_version, LEGACY_SESSION_SCHEMA_VERSION);
}
#[test] #[test]
fn reopen_preserves_session_and_segment_ids() { fn reopen_preserves_session_and_segment_ids() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
+89 -37
View File
@@ -1,12 +1,13 @@
mod common; mod common;
use std::ops::{Deref, DerefMut};
use std::sync::Arc; use std::sync::Arc;
use agen::Engine;
use agen::interceptor::{Interceptor, TurnEndAction}; use agen::interceptor::{Interceptor, TurnEndAction};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent}; use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::llm_client::types::{Item, RequestConfig}; use agen::llm_client::types::{Item, RequestConfig};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use agen::{Engine, History};
use async_trait::async_trait; use async_trait::async_trait;
use common::MockLlmClient; use common::MockLlmClient;
use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state}; use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state};
@@ -94,15 +95,47 @@ fn make_store() -> (tempfile::TempDir, FsStore) {
(dir, store) (dir, store)
} }
struct TestWorker {
engine: Engine<MockLlmClient>,
history: History,
}
impl TestWorker {
fn new(engine: Engine<MockLlmClient>) -> Self {
Self {
engine,
history: History::new(),
}
}
fn history(&self) -> Vec<Item> {
self.history.items_cloned()
}
}
impl Deref for TestWorker {
type Target = Engine<MockLlmClient>;
fn deref(&self) -> &Self::Target {
&self.engine
}
}
impl DerefMut for TestWorker {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.engine
}
}
/// Run a worker turn and persist via session-store functions. /// Run a worker turn and persist via session-store functions.
/// Takes ownership of the worker (needed for lock/unlock) and returns it. /// Takes ownership of the worker (needed for lock/unlock) and returns it.
async fn run_and_persist( async fn run_and_persist(
worker: Engine<MockLlmClient>, mut worker: TestWorker,
store: &FsStore, store: &FsStore,
session_id: session_store::SessionId, session_id: session_store::SessionId,
segment_id: session_store::SegmentId, segment_id: session_store::SegmentId,
input: &str, input: &str,
) -> (Engine<MockLlmClient>, agen::EngineResult) { ) -> (TestWorker, agen::EngineRunExit) {
// Mirror Worker's run-entry contract: log the user input as segments // Mirror Worker's run-entry contract: log the user input as segments
// before the worker pushes its flattened user_message; save_delta // before the worker pushes its flattened user_message; save_delta
// skips the resulting user_message item to avoid double-write. // skips the resulting user_message item to avoid double-write.
@@ -114,42 +147,61 @@ async fn run_and_persist(
) )
.unwrap(); .unwrap();
let history_before = worker.history().len(); let history_before = worker.history.len();
let mut locked = worker.lock(); let mut locked = worker.engine.lock(&worker.history);
let result = locked.run(input).await; let result = locked.run(&mut worker.history, input).await;
let worker = locked.unlock(); worker.engine = locked.unlock();
let new_items = &worker.history()[history_before..]; let projected = worker.history();
let new_items = &projected[history_before..];
session_store::save_delta(store, session_id, segment_id, new_items).unwrap(); session_store::save_delta(store, session_id, segment_id, new_items).unwrap();
session_store::save_turn_end(store, session_id, segment_id, worker.turn_count()).unwrap(); session_store::save_turn_end(store, session_id, segment_id, worker.turn_count()).unwrap();
match &result { match &result {
Ok(r) => { agen::EngineRunExit::Finished
| agen::EngineRunExit::Paused
| agen::EngineRunExit::Yielded => {
let (legacy_result, interrupted) = match &result {
agen::EngineRunExit::Finished => (agen::EngineResult::Finished, false),
agen::EngineRunExit::Paused => (agen::EngineResult::Paused, true),
agen::EngineRunExit::Yielded => (agen::EngineResult::Yielded, true),
agen::EngineRunExit::Interrupted(_) => unreachable!(),
};
session_store::save_run_completed( session_store::save_run_completed(
store, store,
session_id, session_id,
segment_id, segment_id,
r.clone(), legacy_result,
worker.last_run_interrupted(), interrupted,
worker.active_run_turn_count(), worker.active_run_turn_count(),
) )
.unwrap(); .unwrap();
} }
Err(e) => { agen::EngineRunExit::Interrupted(agen::StopReason::LimitReached) => {
session_store::save_run_completed(
store,
session_id,
segment_id,
agen::EngineResult::LimitReached,
false,
worker.active_run_turn_count(),
)
.unwrap();
}
agen::EngineRunExit::Interrupted(reason) => {
session_store::save_run_errored( session_store::save_run_errored(
store, store,
session_id, session_id,
segment_id, segment_id,
e.to_string(), format!("{reason:?}"),
worker.last_run_interrupted(), true,
) )
.unwrap(); .unwrap();
} }
} }
let r = result.unwrap(); (worker, result)
(worker, r)
} }
// ============================================================================= // =============================================================================
@@ -160,14 +212,14 @@ async fn run_and_persist(
async fn session_run_logs_entries() { async fn session_run_logs_entries() {
let (_dir, store) = make_store(); let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events()); let client = MockLlmClient::new(simple_text_events());
let worker = Engine::new(client); let worker = TestWorker::new(Engine::new(client));
let (sid, segid) = session_store::create_segment( let (sid, segid) = session_store::create_segment(
&store, &store,
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -204,7 +256,7 @@ async fn session_run_logs_entries() {
async fn session_restore_round_trip() { async fn session_restore_round_trip() {
let (_dir, store) = make_store(); let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events()); let client = MockLlmClient::new(simple_text_events());
let mut worker = Engine::new(client); let mut worker = TestWorker::new(Engine::new(client));
worker.set_system_prompt("You are helpful."); worker.set_system_prompt("You are helpful.");
let (sid, segid) = session_store::create_segment( let (sid, segid) = session_store::create_segment(
@@ -212,7 +264,7 @@ async fn session_restore_round_trip() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -243,7 +295,7 @@ async fn session_restore_round_trip() {
async fn session_run_with_tool_call() { async fn session_run_with_tool_call() {
let (_dir, store) = make_store(); let (_dir, store) = make_store();
let client = MockLlmClient::with_responses(tool_call_events()); let client = MockLlmClient::with_responses(tool_call_events());
let mut worker = Engine::new(client); let mut worker = TestWorker::new(Engine::new(client));
worker.register_tool(weather_tool_definition()); worker.register_tool(weather_tool_definition());
let (sid, segid) = session_store::create_segment( let (sid, segid) = session_store::create_segment(
@@ -251,7 +303,7 @@ async fn session_run_with_tool_call() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -277,7 +329,7 @@ async fn session_resume_after_pause() {
// First run: tool call with pause policy → Paused // First run: tool call with pause policy → Paused
let client = MockLlmClient::with_responses(tool_call_events()); let client = MockLlmClient::with_responses(tool_call_events());
let mut worker = Engine::new(client); let mut worker = TestWorker::new(Engine::new(client));
worker.register_tool(weather_tool_definition()); worker.register_tool(weather_tool_definition());
worker.set_interceptor(PausePolicy); worker.set_interceptor(PausePolicy);
@@ -286,13 +338,13 @@ async fn session_resume_after_pause() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
let (_worker, result) = run_and_persist(worker, &store, sid, segid, "Weather?").await; let (_worker, result) = run_and_persist(worker, &store, sid, segid, "Weather?").await;
assert!(matches!(result, agen::EngineResult::Paused)); assert!(matches!(result, agen::EngineRunExit::Paused));
// Check RunCompleted is Paused // Check RunCompleted is Paused
let entries = store.read_all(sid, segid).unwrap(); let entries = store.read_all(sid, segid).unwrap();
@@ -317,7 +369,7 @@ async fn session_resume_after_pause() {
async fn session_fork_creates_new_session() { async fn session_fork_creates_new_session() {
let (_dir, store) = make_store(); let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events()); let client = MockLlmClient::new(simple_text_events());
let mut worker = Engine::new(client); let mut worker = TestWorker::new(Engine::new(client));
worker.set_system_prompt("System prompt"); worker.set_system_prompt("System prompt");
let (sid, segid) = session_store::create_segment( let (sid, segid) = session_store::create_segment(
@@ -325,7 +377,7 @@ async fn session_fork_creates_new_session() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -338,7 +390,7 @@ async fn session_fork_creates_new_session() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -359,14 +411,14 @@ async fn session_fork_creates_new_session() {
async fn session_fork_at_truncates_within_session() { async fn session_fork_at_truncates_within_session() {
let (_dir, store) = make_store(); let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events()); let client = MockLlmClient::new(simple_text_events());
let worker = Engine::new(client); let worker = TestWorker::new(Engine::new(client));
let (sid, segid) = session_store::create_segment( let (sid, segid) = session_store::create_segment(
&store, &store,
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -404,14 +456,14 @@ async fn session_fork_at_truncates_within_session() {
async fn session_config_changed_logged() { async fn session_config_changed_logged() {
let (_dir, store) = make_store(); let (_dir, store) = make_store();
let client = MockLlmClient::new(vec![]); let client = MockLlmClient::new(vec![]);
let mut worker = Engine::new(client); let mut worker = TestWorker::new(Engine::new(client));
let (sid, segid) = session_store::create_segment( let (sid, segid) = session_store::create_segment(
&store, &store,
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -437,14 +489,14 @@ async fn session_auto_forks_on_conflict() {
// Create a segment // Create a segment
let client_a = MockLlmClient::new(simple_text_events()); let client_a = MockLlmClient::new(simple_text_events());
let worker_a = Engine::new(client_a); let worker_a = TestWorker::new(Engine::new(client_a));
let (sid, original_segid) = session_store::create_segment( let (sid, original_segid) = session_store::create_segment(
&store, &store,
SegmentStartState { SegmentStartState {
system_prompt: worker_a.get_system_prompt(), system_prompt: worker_a.get_system_prompt(),
config: worker_a.request_config(), config: worker_a.request_config(),
history: worker_a.history(), history: &worker_a.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -470,7 +522,7 @@ async fn session_auto_forks_on_conflict() {
SegmentStartState { SegmentStartState {
system_prompt: worker_a.get_system_prompt(), system_prompt: worker_a.get_system_prompt(),
config: worker_a.request_config(), config: worker_a.request_config(),
history: worker_a.history(), history: &worker_a.history(),
}, },
) )
.unwrap(); .unwrap();
@@ -522,14 +574,14 @@ async fn session_auto_forks_on_conflict() {
async fn nested_past_fork_leaves_ancestors_immutable() { async fn nested_past_fork_leaves_ancestors_immutable() {
let (_dir, store) = make_store(); let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events()); let client = MockLlmClient::new(simple_text_events());
let worker = Engine::new(client); let worker = TestWorker::new(Engine::new(client));
let (sid, root_segid) = session_store::create_segment( let (sid, root_segid) = session_store::create_segment(
&store, &store,
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: worker.history(), history: &worker.history(),
}, },
) )
.unwrap(); .unwrap();
+150 -16
View File
@@ -410,7 +410,7 @@ struct TicketCreateParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketEditItemParams { struct TicketEditItemParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
/// Optional replacement title. /// Optional replacement title.
#[serde(default)] #[serde(default)]
@@ -539,7 +539,7 @@ impl QueryTicketParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ShowTicketParams { struct ShowTicketParams {
/// Ticket id. Exactly one of `id` or `query` must be provided. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. Exactly one of `id` or `query` must be provided.
#[serde(default)] #[serde(default)]
id: Option<String>, id: Option<String>,
/// Exact ticket id query. Exactly one of `id` or `query` must be provided. /// Exact ticket id query. Exactly one of `id` or `query` must be provided.
@@ -558,7 +558,7 @@ struct ShowTicketParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketThreadEventParams { struct TicketThreadEventParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
/// Markdown event body. /// Markdown event body.
body: String, body: String,
@@ -566,7 +566,7 @@ struct TicketThreadEventParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketMarkReadyParams { struct TicketMarkReadyParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
/// Optional reason attached to the state_changed event. /// Optional reason attached to the state_changed event.
#[serde(default)] #[serde(default)]
@@ -575,7 +575,7 @@ struct TicketMarkReadyParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketIntakeReadyParams { struct TicketIntakeReadyParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
/// Concise bounded intake summary appended before the ready transition. /// Concise bounded intake summary appended before the ready transition.
intake_summary: String, intake_summary: String,
@@ -586,13 +586,13 @@ struct TicketIntakeReadyParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketQueueParams { struct TicketQueueParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
} }
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketWorkflowStateParams { struct TicketWorkflowStateParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
/// Expected current state. The backend rejects stale transitions. /// Expected current state. The backend rejects stale transitions.
from: TicketWorkflowStateParam, from: TicketWorkflowStateParam,
@@ -606,7 +606,7 @@ struct TicketWorkflowStateParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketCloseParams { struct TicketCloseParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
/// Markdown resolution written to resolution.md and thread.md. /// Markdown resolution written to resolution.md and thread.md.
resolution: String, resolution: String,
@@ -614,7 +614,7 @@ struct TicketCloseParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketDependencyCheckParams { struct TicketDependencyCheckParams {
/// Ticket id. /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String, ticket: String,
} }
@@ -646,7 +646,7 @@ struct TicketRelationRecordParams {
ticket: String, ticket: String,
/// Forward relation kind: depends_on, blocks, related, supersedes, or duplicate_of. /// Forward relation kind: depends_on, blocks, related, supersedes, or duplicate_of.
kind: TicketRelationKindParam, kind: TicketRelationKindParam,
/// Target canonical Ticket id. Title/slug words are not accepted as relation authority. /// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
target: String, target: String,
/// Optional bounded rationale/note. /// Optional bounded rationale/note.
#[serde(default)] #[serde(default)]
@@ -659,7 +659,7 @@ struct TicketRelationRemoveParams {
ticket: String, ticket: String,
/// Forward relation kind to remove. /// Forward relation kind to remove.
kind: TicketRelationKindParam, kind: TicketRelationKindParam,
/// Target canonical Ticket id. /// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
target: String, target: String,
} }
@@ -1223,10 +1223,17 @@ impl Tool for TicketQueueTool {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?; let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
let queued_by = default_author(); let queued_by = default_author();
let outcome = self let mut outcome = self
.backend .backend
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by) .queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
.map_err(|error| backend_error("TicketQueue", error))?; .map_err(|error| backend_error("TicketQueue", error))?;
outcome.requested_ticket =
model_ticket_reference(&self.backend, &outcome.requested_ticket, "TicketQueue")?;
outcome.queued_tickets = outcome
.queued_tickets
.into_iter()
.map(|ticket| model_ticket_reference(&self.backend, &ticket, "TicketQueue"))
.collect::<Result<Vec<_>, _>>()?;
Ok(json_output( Ok(json_output(
format!( format!(
"Queued {} ticket(s) for Orchestrator", "Queued {} ticket(s) for Orchestrator",
@@ -1264,15 +1271,17 @@ impl Tool for TicketWorkflowStateTool {
self.backend self.backend
.set_workflow_state(TicketIdOrSlug::Query(params.ticket.clone()), change) .set_workflow_state(TicketIdOrSlug::Query(params.ticket.clone()), change)
.map_err(|error| backend_error("TicketWorkflowState", error))?; .map_err(|error| backend_error("TicketWorkflowState", error))?;
let ticket_ref =
model_ticket_reference(&self.backend, &params.ticket, "TicketWorkflowState")?;
Ok(json_output( Ok(json_output(
format!( format!(
"Transitioned ticket {} state {} -> {}", "Transitioned ticket {} state {} -> {}",
params.ticket, ticket_ref,
from.as_str(), from.as_str(),
to.as_str() to.as_str()
), ),
json!({ json!({
"ticket": params.ticket, "ticket": ticket_ref,
"from": from.as_str(), "from": from.as_str(),
"to": to.as_str(), "to": to.as_str(),
"state": to.as_str(), "state": to.as_str(),
@@ -1296,9 +1305,10 @@ impl Tool for TicketCloseTool {
MarkdownText::new(params.resolution), MarkdownText::new(params.resolution),
) )
.map_err(|error| backend_error("TicketClose", error))?; .map_err(|error| backend_error("TicketClose", error))?;
let ticket_ref = model_ticket_reference(&self.backend, &params.ticket, "TicketClose")?;
Ok(json_output( Ok(json_output(
format!("Closed ticket {}", params.ticket), format!("Closed ticket {ticket_ref}"),
json!({ "ticket": params.ticket, "state": "closed", "ok": true }), json!({ "ticket": ticket_ref, "state": "closed", "ok": true }),
)) ))
} }
} }
@@ -1525,6 +1535,29 @@ impl Tool for TicketDependencyCheckTool {
} }
} }
fn model_ticket_reference(
backend: &TicketToolBackend,
reference: &str,
tool_name: &str,
) -> Result<String, ToolError> {
let ticket = backend
.show(TicketIdOrSlug::Id(reference.to_string()))
.map_err(|error| backend_error(tool_name, error))?;
match ticket.meta.resource_key {
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
Some(_) => Err(ToolError::ExecutionFailed(format!(
"{tool_name} failed: required Ticket human key is unavailable"
))),
None => Ok(ticket.meta.id),
}
}
fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
resource_key.strip_prefix("T-").is_some_and(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
})
}
fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> { fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> {
serde_json::from_str(input_json) serde_json::from_str(input_json)
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}"))) .map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
@@ -1922,6 +1955,12 @@ mod tests {
.with_target_authority(Arc::new(TestTargetAuthority)) .with_target_authority(Arc::new(TestTargetAuthority))
} }
fn sqlite_backend(temp: &TempDir) -> crate::SqliteTicketBackend {
crate::SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace")
.unwrap()
.with_target_authority(Arc::new(TestTargetAuthority))
}
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> { fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
let (_, tool) = definition(); let (_, tool) = definition();
tool tool
@@ -2549,6 +2588,101 @@ mod tests {
); );
} }
#[tokio::test]
async fn queue_workflow_and_close_project_internal_inputs_to_ticket_keys() {
let temp = TempDir::new().unwrap();
let inner = sqlite_backend(&temp);
let mut dependency_input = NewTicket::new("Dependency");
dependency_input.repository_id = Some("main".to_string());
let dependency = inner.create(dependency_input).unwrap();
let mut target_input = NewTicket::new("Target");
target_input.repository_id = Some("main".to_string());
let target = inner.create(target_input).unwrap();
inner
.add_ticket_relation(
TicketIdOrSlug::Id(target.id.clone()),
NewTicketRelation {
kind: TicketRelationKind::DependsOn,
target: dependency.id.clone(),
note: None,
author: None,
},
)
.unwrap();
for id in [&dependency.id, &target.id] {
inner
.mark_ready(
TicketIdOrSlug::Id(id.clone()),
TicketMarkReady {
operation_key: format!("ready-{id}"),
reason: None,
author: None,
intake_summary: None,
},
)
.unwrap();
}
let target_key = target.resource_key.clone().unwrap();
let dependency_key = dependency.resource_key.clone().unwrap();
let backend = inner;
let queue = tool_by_name(TicketToolBackend::new(backend.clone()), "TicketQueue");
let workflow = tool_by_name(
TicketToolBackend::new(backend.clone()),
"TicketWorkflowState",
);
let close = tool_by_name(TicketToolBackend::new(backend), "TicketClose");
let queued = queue
.execute(
&json!({"ticket": target.id.clone()}).to_string(),
Default::default(),
)
.await
.unwrap();
assert!(queued.summary.contains("2 ticket(s)"));
let queued_content = queued.content.unwrap();
assert!(queued_content.contains(&target_key));
assert!(queued_content.contains(&dependency_key));
assert!(!queued_content.contains(&target.id));
assert!(!queued_content.contains(&dependency.id));
for (from, to) in [("queued", "inprogress"), ("inprogress", "done")] {
let transitioned = workflow
.execute(
&json!({
"ticket": target.id.clone(),
"from": from,
"to": to,
"reason": "test_transition",
"body": "transitioned",
"author": "tester"
})
.to_string(),
Default::default(),
)
.await
.unwrap();
assert!(transitioned.summary.contains(&target_key));
assert!(!transitioned.summary.contains(&target.id));
let content = transitioned.content.unwrap();
assert!(content.contains(&target_key));
assert!(!content.contains(&target.id));
}
let closed = close
.execute(
&json!({"ticket": target.id.clone(), "resolution": "Done"}).to_string(),
Default::default(),
)
.await
.unwrap();
assert!(closed.summary.contains(&target_key));
assert!(!closed.summary.contains(&target.id));
let content = closed.content.unwrap();
assert!(content.contains(&target_key));
assert!(!content.contains(&target.id));
}
#[tokio::test] #[tokio::test]
async fn ticket_workflow_tools_mark_ready_and_transition_state() { async fn ticket_workflow_tools_mark_ready_and_transition_state() {
let temp = TempDir::new().unwrap(); let temp = TempDir::new().unwrap();
+156 -11
View File
@@ -1,5 +1,6 @@
use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::{Arc, Mutex};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait; use async_trait::async_trait;
@@ -20,21 +21,65 @@ struct BashParams {
pub(crate) struct BashTool { pub(crate) struct BashTool {
session: WorkdirSessionHandle, session: WorkdirSessionHandle,
state: Arc<Mutex<BashExecutionState>>,
}
#[derive(Clone)]
struct ActiveCommand {
call_id: String,
execution_nonce: u64,
handle: CommandHandle,
}
#[derive(Default)]
struct BashExecutionState {
active: HashMap<String, ActiveCommand>,
cancellation_requested: HashSet<String>,
legacy_cancellation_requested: HashSet<String>,
next_execution_nonce: u64,
} }
struct CommandGuard { struct CommandGuard {
session: WorkdirSessionHandle, session: WorkdirSessionHandle,
state: Arc<Mutex<BashExecutionState>>,
execution_id: String,
execution_nonce: u64,
handle: Option<CommandHandle>, handle: Option<CommandHandle>,
} }
impl Drop for CommandGuard { impl Drop for CommandGuard {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(handle) = self.handle.take() { let Some(handle) = self.handle.take() else {
return;
};
let workdir = self.session.clone(); let workdir = self.session.clone();
let state = Arc::clone(&self.state);
let execution_id = self.execution_id.clone();
let execution_nonce = self.execution_nonce;
// A dropped provider future is not terminal confirmation. Keep the live
// execution registered until cleanup has both requested cancellation and
// observed terminal command output, so cancellation/session teardown
// cannot race with an apparently empty registry.
tokio::spawn(async move { tokio::spawn(async move {
let _ = workdir.cancel_command(handle).await; let _ = workdir.cancel_command(handle.clone()).await;
}); let _ = workdir
.command_output(CommandOutputRequest {
handle,
cursor: 0,
limit: INLINE_BYTE_BUDGET,
wait: true,
})
.await;
let mut state = state.lock().unwrap();
if state
.active
.get(&execution_id)
.is_some_and(|active| active.execution_nonce == execution_nonce)
{
state.active.remove(&execution_id);
state.cancellation_requested.remove(&execution_id);
} }
});
} }
} }
@@ -52,20 +97,50 @@ impl Tool for BashTool {
.unwrap_or(DEFAULT_TIMEOUT_SECS) .unwrap_or(DEFAULT_TIMEOUT_SECS)
.clamp(1, MAX_TIMEOUT_SECS); .clamp(1, MAX_TIMEOUT_SECS);
let cmd_summary = truncate_for_summary(&params.command); let cmd_summary = truncate_for_summary(&params.command);
let execution_id = ctx.execution_id();
let call_id = ctx.call_id;
let execution_nonce = {
let mut state = self.state.lock().unwrap();
state.next_execution_nonce = state.next_execution_nonce.wrapping_add(1);
state.next_execution_nonce
};
let mut guard = CommandGuard {
session: self.session.clone(),
state: self.state.clone(),
execution_id: execution_id.clone(),
execution_nonce,
handle: None,
};
let handle = self let handle = self
.session .session
.start_command(CommandRequest { .start_command(CommandRequest {
command: params.command, command: params.command,
timeout_secs, timeout_secs,
output_limit: INLINE_BYTE_BUDGET, output_limit: INLINE_BYTE_BUDGET,
tool_call_id: Some(ctx.call_id), tool_call_id: Some(call_id.clone()),
}) })
.await .await
.map_err(crate::ToolsError::from)?; .map_err(crate::ToolsError::from)?;
let mut guard = CommandGuard { let cancel_after_start = {
session: self.session.clone(), let mut state = self.state.lock().unwrap();
handle: Some(handle.clone()), state.active.insert(
execution_id.clone(),
ActiveCommand {
call_id: call_id.clone(),
execution_nonce,
handle: handle.clone(),
},
);
state.cancellation_requested.contains(&execution_id)
|| state.legacy_cancellation_requested.contains(&call_id)
}; };
guard.handle = Some(handle.clone());
if cancel_after_start {
self.session
.cancel_command(handle.clone())
.await
.map_err(crate::ToolsError::from)?;
}
let output = self let output = self
.session .session
.command_output(CommandOutputRequest { .command_output(CommandOutputRequest {
@@ -76,9 +151,27 @@ impl Tool for BashTool {
}) })
.await .await
.map_err(crate::ToolsError::from)?; .map_err(crate::ToolsError::from)?;
let cancellation_requested = {
let mut state = self.state.lock().unwrap();
let owns_registration = state
.active
.get(&execution_id)
.is_some_and(|active| active.execution_nonce == execution_nonce);
let exact = if owns_registration {
state.active.remove(&execution_id);
state.cancellation_requested.remove(&execution_id)
} else {
false
};
let legacy = state.legacy_cancellation_requested.remove(&call_id);
exact || legacy
};
guard.handle = None; guard.handle = None;
let summary = if output.timed_out { let timed_out = output.timed_out;
let summary = if cancellation_requested {
format!("$ {cmd_summary} (cancelled)")
} else if output.timed_out {
format!("$ {cmd_summary} (timed out after {timeout_secs}s)") format!("$ {cmd_summary} (timed out after {timeout_secs}s)")
} else { } else {
match output.exit_code { match output.exit_code {
@@ -97,11 +190,62 @@ impl Tool for BashTool {
} else { } else {
Some(output.content) Some(output.content)
}; };
Ok(ToolOutput { let output = ToolOutput {
summary, summary,
content, content,
attachments: Vec::new(), attachments: Vec::new(),
}) };
if cancellation_requested {
Err(ToolError::Cancelled(output))
} else if timed_out {
Err(ToolError::Interrupted(output))
} else {
Ok(output)
}
}
async fn cancel(&self, call_id: &str) -> Result<(), ToolError> {
let handles = {
let mut state = self.state.lock().unwrap();
state
.legacy_cancellation_requested
.insert(call_id.to_string());
state
.active
.values()
.filter(|active| active.call_id == call_id)
.map(|active| active.handle.clone())
.collect::<Vec<_>>()
};
for handle in handles {
self.session
.cancel_command(handle)
.await
.map_err(crate::ToolsError::from)?;
}
Ok(())
}
async fn cancel_execution(
&self,
ctx: &agen::tool::ToolExecutionContext,
) -> Result<(), ToolError> {
let execution_id = ctx.execution_id();
let handle = {
let mut state = self.state.lock().unwrap();
state.cancellation_requested.insert(execution_id.clone());
state
.active
.get(&execution_id)
.map(|active| active.handle.clone())
};
if let Some(handle) = handle {
self.session
.cancel_command(handle)
.await
.map_err(crate::ToolsError::from)?;
}
Ok(())
} }
} }
@@ -123,6 +267,7 @@ pub fn bash_tool(session: WorkdirSessionHandle, _output_dir: PathBuf) -> ToolDef
.input_schema(serde_json::to_value(schema).expect("Bash schema serialization")); .input_schema(serde_json::to_value(schema).expect("Bash schema serialization"));
let tool: Arc<dyn Tool> = Arc::new(BashTool { let tool: Arc<dyn Tool> = Arc::new(BashTool {
session: session.clone(), session: session.clone(),
state: Arc::new(Mutex::new(BashExecutionState::default())),
}); });
(meta, tool) (meta, tool)
}) })
+8 -3
View File
@@ -131,10 +131,11 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
assert!( assert!(
msg.contains("outside allowed read scope") msg.contains("outside allowed read scope")
|| msg.contains("outside allowed write scope") || msg.contains("outside allowed write scope")
|| msg.contains("outside allowed scope")
|| msg.contains("has not been read"), || msg.contains("has not been read"),
"symlink escape not rejected: {msg}" "symlink escape not rejected: {msg}"
); );
if !msg.contains("has not been read") { if msg.contains("outside allowed read scope") || msg.contains("outside allowed write scope") {
assert!( assert!(
msg.contains("add the symlink target"), msg.contains("add the symlink target"),
"symlink escape diagnostic should include remediation: {msg}" "symlink escape diagnostic should include remediation: {msg}"
@@ -233,12 +234,16 @@ async fn absolute_path_is_rejected() {
) )
.await .await
.unwrap_err(); .unwrap_err();
assert!(format!("{err}").contains("invalid Workdir path")); let msg = format!("{err}");
assert!(
msg.contains("invalid logical filesystem path"),
"absolute path was not rejected as invalid: {msg}"
);
} }
#[tokio::test] #[tokio::test]
async fn directory_target_is_rejected_for_read() { async fn directory_target_is_rejected_for_read() {
let (dir, _spill, reg) = setup(); let (_dir, _spill, reg) = setup();
let read = reg.get("Read"); let read = reg.get("Read");
let err = read let err = read
.execute(&json!({ "file_path": "." }).to_string(), Default::default()) .execute(&json!({ "file_path": "." }).to_string(), Default::default())
+89 -4
View File
@@ -7,7 +7,10 @@
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use agen::tool::{Tool, ToolDefinition, ToolMeta}; use agen::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolExecutionHandle,
ToolExecutionTerminal, ToolMeta,
};
use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
use serde_json::json; use serde_json::json;
use tempfile::TempDir; use tempfile::TempDir;
@@ -191,7 +194,7 @@ async fn write_then_grep_finds_content() {
#[tokio::test] #[tokio::test]
async fn glob_finds_written_files() { async fn glob_finds_written_files() {
let (dir, _spill, reg) = setup(); let (_dir, _spill, reg) = setup();
let write = reg.get("Write"); let write = reg.get("Write");
let glob = reg.get("Glob"); let glob = reg.get("Glob");
@@ -229,7 +232,10 @@ async fn absolute_path_is_rejected() {
.await; .await;
// Absolute paths are rejected at the logical WorkdirSession boundary. // Absolute paths are rejected at the logical WorkdirSession boundary.
let msg = format!("{err}"); let msg = format!("{err}");
assert!(msg.contains("invalid Workdir path"), "unexpected: {msg}"); assert!(
msg.contains("invalid logical filesystem path"),
"unexpected: {msg}"
);
} }
#[tokio::test] #[tokio::test]
@@ -340,7 +346,7 @@ async fn tracker_recent_files_tracks_read_write_edit() {
)); ));
let a = dir.path().join("a.txt"); let a = dir.path().join("a.txt");
let b = dir.path().join("b.txt"); let _b = dir.path().join("b.txt");
std::fs::write(&a, "one\n").unwrap(); std::fs::write(&a, "one\n").unwrap();
// Read `a` — should appear in recency. // Read `a` — should appear in recency.
@@ -398,5 +404,84 @@ async fn bash_provider_output_does_not_expose_internal_paths() {
assert_eq!(std::fs::read_dir(spill.path()).unwrap().count(), 0); assert_eq!(std::fs::read_dir(spill.path()).unwrap().count(), 0);
} }
#[tokio::test]
async fn bash_cancellation_returns_bounded_progress_as_terminal_output() {
let (dir, _spill, reg) = setup();
let marker = dir.path().join("must-not-run-after-cancel");
let command = format!(
"printf 'before\\n'; printf 'err-before\\n' >&2; sleep 1; touch {}; printf 'after\\n'",
marker.display()
);
let input = serde_json::to_string(&json!({ "command": command })).unwrap();
let context = ToolExecutionContext::new("call-heavy", "attempt-heavy", 0);
let bash = reg.get("Bash");
let executing = bash.clone();
let execution_context = context.clone();
let execution = tokio::spawn(async move { executing.execute(&input, execution_context).await });
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
bash.cancel_execution(&context)
.await
.expect("signal exact execution cancellation");
let error = tokio::time::timeout(std::time::Duration::from_secs(2), execution)
.await
.expect("cancelled Bash should terminate inside the Engine grace budget")
.expect("Bash task join");
let ToolError::Cancelled(output) = error.expect_err("cancelled command is non-success") else {
panic!("expected typed cancellation result");
};
let content = output.content.expect("bounded progress output");
assert!(
content.contains("before"),
"missing pre-cancel stdout: {content}"
);
assert!(
content.contains("err-before"),
"missing pre-cancel stderr: {content}"
);
assert!(
!content.contains("after"),
"post-cancel output leaked: {content}"
);
assert!(content.len() <= 16 * 1024, "output must remain bounded");
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
assert!(
!marker.exists(),
"the cancelled command continued executing after terminal confirmation"
);
}
#[tokio::test]
async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() {
let (dir, _spill, reg) = setup();
let marker = dir.path().join("must-not-survive-force-close");
let command = format!("sleep 1; touch {}", marker.display());
let input = serde_json::to_string(&json!({ "command": command })).unwrap();
let bash = reg.get("Bash");
let context = ToolExecutionContext::new("call-force", "attempt-force", 0);
let (handle, terminal) = ToolExecutionHandle::start(bash.clone(), input, context);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
handle.force_close();
assert!(matches!(
terminal.await,
ToolExecutionTerminal::OutcomeUnknown
));
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
assert!(
!marker.exists(),
"CommandGuard cleanup allowed a force-closed command to continue"
);
let output = bash
.execute(r#"{"command":"printf 'reused'"}"#, Default::default())
.await
.expect("workdir session remains reusable after cleanup");
assert_eq!(output.content.as_deref(), Some("reused"));
}
// Sanity: unused Path import guard // Sanity: unused Path import guard
const _: fn() -> &'static Path = || Path::new("/"); const _: fn() -> &'static Path = || Path::new("/");
+1
View File
@@ -1244,6 +1244,7 @@ impl App {
id, id,
summary, summary,
output, output,
disposition: _,
is_error, is_error,
} => { } => {
self.latest_llm_wait_event = None; self.latest_llm_wait_event = None;
+1 -1
View File
@@ -1016,7 +1016,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.clear_queued_inputs(); app.clear_queued_inputs();
Some(Method::Cancel) Some(Method::Cancel)
} }
WorkerStatus::Idle => Some(Method::Shutdown), WorkerStatus::Idle | WorkerStatus::Stopped => Some(Method::Shutdown),
}), }),
KeyCode::Char('d') if ctrl => { KeyCode::Char('d') if ctrl => {
app.quit = true; app.quit = true;
+87 -90
View File
@@ -29,7 +29,6 @@ use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap}; use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
use serde::Serialize;
use session_store::FsStore; use session_store::FsStore;
use session_store::FsWorkerStore; use session_store::FsWorkerStore;
use ticket::config::{GitBranchName, TicketConfig, TicketOrchestrationConfig}; use ticket::config::{GitBranchName, TicketConfig, TicketOrchestrationConfig};
@@ -70,10 +69,6 @@ use render::{PanelListRow, row_hit_boxes};
const MAX_ENTRIES: usize = 50; const MAX_ENTRIES: usize = 50;
const CLOSED_VISIBLE_ROWS: usize = 3; const CLOSED_VISIBLE_ROWS: usize = 3;
const ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT: &str = "panel.orchestrator_idle_queue_notice";
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS: usize = 6;
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS: usize = 120;
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS: usize = 2_400;
const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3); const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3);
const DASHBOARD_POLL_INTERVAL: Duration = Duration::from_millis(1_500); const DASHBOARD_POLL_INTERVAL: Duration = Duration::from_millis(1_500);
const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100); const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100);
@@ -581,6 +576,7 @@ pub(crate) enum IntakeRegistryUpdate {
pub(crate) struct ReadyTicketPlanningReturnRequest { pub(crate) struct ReadyTicketPlanningReturnRequest {
workspace_root: PathBuf, workspace_root: PathBuf,
ticket_id: String, ticket_id: String,
ticket_key: String,
user_instruction: String, user_instruction: String,
followup: ReadyTicketPlanningReturnFollowup, followup: ReadyTicketPlanningReturnFollowup,
} }
@@ -910,6 +906,7 @@ struct OrchestratorActiveWorkItem {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct OrchestratorQueuedWorkItem { struct OrchestratorQueuedWorkItem {
id: String, id: String,
resource_key: Option<String>,
title: String, title: String,
classification: OrchestratorQueuedClassification, classification: OrchestratorQueuedClassification,
waiting_reason: Option<String>, waiting_reason: Option<String>,
@@ -974,22 +971,6 @@ impl OrchestratorQueueAttentionNoticeResult {
} }
} }
#[derive(Debug, Serialize)]
struct OrchestratorQueueTemplateContext {
workspace: String,
actionable_tickets: Vec<OrchestratorQueueTemplateTicket>,
waiting_tickets: Vec<OrchestratorQueueTemplateTicket>,
omitted_ticket_count: usize,
}
#[derive(Debug, Serialize)]
struct OrchestratorQueueTemplateTicket {
id: String,
title: String,
classification: &'static str,
waiting_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct PanelRowHitBox { struct PanelRowHitBox {
rect: Rect, rect: Rect,
@@ -1325,7 +1306,16 @@ impl DashboardApp {
if self.orchestrator_work_set.is_empty() { if self.orchestrator_work_set.is_empty() {
self.refresh_orchestrator_work_set(); self.refresh_orchestrator_work_set();
} }
let notice = orchestrator_queue_attention_notice(&self.panel, &self.orchestrator_work_set)?; let notice = match orchestrator_queue_attention_notice(&self.orchestrator_work_set) {
Ok(Some(notice)) => notice,
Ok(None) => return None,
Err(error) => {
self.notice = Some(format!(
"Orchestrator queued-work attention not delivered: {error}"
));
return None;
}
};
if self if self
.orchestrator_queue_attention .orchestrator_queue_attention
.as_ref() .as_ref()
@@ -2042,11 +2032,18 @@ impl DashboardApp {
return None; return None;
}; };
let ticket_id = ticket.id.clone(); let ticket_id = ticket.id.clone();
let ticket_key = match required_ticket_handoff_key(ticket.resource_key.as_deref()) {
Ok(ticket_key) => ticket_key.to_string(),
Err(error) => {
self.notice = Some(error);
return None;
}
};
let mut context = let mut context =
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake); TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
context.ticket = Some(TicketRef::id(ticket_id.clone())); context.ticket = Some(TicketRef::id(ticket_id.clone()));
context.user_instruction = Some(format!( context.user_instruction = Some(format!(
"Continue Intake for existing Ticket {ticket_id}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions." "Continue Intake for existing Ticket {ticket_key}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions."
)); ));
let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) { let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) {
Ok(store) => store, Ok(store) => store,
@@ -2059,7 +2056,7 @@ impl DashboardApp {
Ok(Some(claim)) => { Ok(Some(claim)) => {
let status = local_claim_status_for_pod(&claim.worker_name, &self.list); let status = local_claim_status_for_pod(&claim.worker_name, &self.list);
self.notice = Some(existing_ticket_claim_notice( self.notice = Some(existing_ticket_claim_notice(
&ticket_id, &ticket_key,
&claim.worker_name, &claim.worker_name,
status, status,
)); ));
@@ -2087,7 +2084,7 @@ impl DashboardApp {
self.sending = true; self.sending = true;
self.notice = Some(format!( self.notice = Some(format!(
"Launching Ticket Intake for {} as {}…", "Launching Ticket Intake for {} as {}…",
ticket_id, planned.worker_name ticket_key, planned.worker_name
)); ));
Some(IntakeLaunchRequest { Some(IntakeLaunchRequest {
context, context,
@@ -2158,10 +2155,17 @@ impl DashboardApp {
return None; return None;
}; };
let ticket_id = ticket.id.clone(); let ticket_id = ticket.id.clone();
let ticket_key = match required_ticket_handoff_key(ticket.resource_key.as_deref()) {
Ok(ticket_key) => ticket_key.to_string(),
Err(error) => {
self.notice = Some(error);
return None;
}
};
if ticket.workflow_state != TicketWorkflowState::Ready { if ticket.workflow_state != TicketWorkflowState::Ready {
self.notice = Some(format!( self.notice = Some(format!(
"Ticket {} is {}; expected ready before returning to planning.", "Ticket {} is {}; expected ready before returning to planning.",
ticket_id, ticket_key,
ticket.workflow_state.as_str() ticket.workflow_state.as_str()
)); ));
return None; return None;
@@ -2213,7 +2217,7 @@ impl DashboardApp {
TicketRoleLaunchContext::new(workspace_root.clone(), TicketRole::Intake); TicketRoleLaunchContext::new(workspace_root.clone(), TicketRole::Intake);
context.ticket = Some(TicketRef::id(ticket_id.clone())); context.ticket = Some(TicketRef::id(ticket_id.clone()));
context.user_instruction = Some(build_ready_ticket_refinement_launch_instruction( context.user_instruction = Some(build_ready_ticket_refinement_launch_instruction(
&ticket_id, &ticket_key,
&user_instruction, &user_instruction,
)); ));
let peer_registration = self.prepare_intake_peer_registration(&mut context); let peer_registration = self.prepare_intake_peer_registration(&mut context);
@@ -2237,11 +2241,12 @@ impl DashboardApp {
self.sending = true; self.sending = true;
self.notice = Some(format!( self.notice = Some(format!(
"Returning ready Ticket {} to planning for refinement…", "Returning ready Ticket {} to planning for refinement…",
ticket_id ticket_key
)); ));
Some(ReadyTicketPlanningReturnRequest { Some(ReadyTicketPlanningReturnRequest {
workspace_root, workspace_root,
ticket_id, ticket_id,
ticket_key,
user_instruction, user_instruction,
followup, followup,
}) })
@@ -3645,6 +3650,7 @@ fn derive_orchestrator_work_set(
}; };
Some(OrchestratorQueuedWorkItem { Some(OrchestratorQueuedWorkItem {
id: ticket.id.clone(), id: ticket.id.clone(),
resource_key: ticket.resource_key.clone(),
title: ticket.title.clone(), title: ticket.title.clone(),
classification, classification,
waiting_reason, waiting_reason,
@@ -3728,72 +3734,46 @@ fn orchestrator_work_set_fingerprint(
} }
fn orchestrator_queue_attention_notice( fn orchestrator_queue_attention_notice(
panel: &WorkspacePanelViewModel,
work_set: &OrchestratorWorkSet, work_set: &OrchestratorWorkSet,
) -> Option<OrchestratorQueueAttentionNotice> { ) -> Result<Option<OrchestratorQueueAttentionNotice>, &'static str> {
if work_set.has_active_inprogress() { if work_set.has_active_inprogress() {
return None; return Ok(None);
} }
let actionable = work_set.actionable_queued(); let actionable = work_set.actionable_queued();
if actionable.is_empty() { if actionable.is_empty() {
return None; return Ok(None);
} }
let waiting = work_set let waiting = work_set
.queued .queued
.iter() .iter()
.filter(|item| item.waiting_reason.is_some()) .filter(|item| item.waiting_reason.is_some());
.collect::<Vec<_>>(); let tickets = actionable
let ticket_count = actionable.len() + waiting.len(); .into_iter()
let actionable_tickets = actionable .chain(waiting)
.iter() .map(|item| {
.take(ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS) let resource_key = item
.map(|item| orchestrator_queue_template_ticket(item)) .resource_key
.collect::<Vec<_>>(); .clone()
let remaining_capacity = .ok_or("queued Ticket is missing its required resource key")?;
ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS.saturating_sub(actionable_tickets.len()); worker::OrchestratorQueueAttentionTicket::new(resource_key, item.title.clone())
let waiting_tickets = waiting .map_err(|_| "queued Ticket has an invalid resource key")
.iter()
.take(remaining_capacity)
.map(|item| orchestrator_queue_template_ticket(item))
.collect::<Vec<_>>();
let rendered =
render_orchestrator_queue_attention_template(&OrchestratorQueueTemplateContext {
workspace: bounded_progress_text(
&panel.header.workspace_label,
ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS,
),
actionable_tickets,
waiting_tickets,
omitted_ticket_count: ticket_count
.saturating_sub(ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS),
}) })
.ok()?; .collect::<Result<Vec<_>, _>>()?;
let message = bounded_progress_text(&rendered, ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS); let context = worker::OrchestratorQueueAttentionContext::new(tickets);
let message = render_orchestrator_queue_attention_template(&context)
.map_err(|_| "queued-work attention prompt rendering failed")?;
let fingerprint = format!("idle-queue:{}", work_set.fingerprint); let fingerprint = format!("idle-queue:{}", work_set.fingerprint);
Some(OrchestratorQueueAttentionNotice { Ok(Some(OrchestratorQueueAttentionNotice {
message, message,
fingerprint, fingerprint,
}) }))
}
fn orchestrator_queue_template_ticket(
item: &&OrchestratorQueuedWorkItem,
) -> OrchestratorQueueTemplateTicket {
OrchestratorQueueTemplateTicket {
id: bounded_progress_text(&item.id, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS),
title: bounded_progress_text(&item.title, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS),
classification: item.classification.as_str(),
waiting_reason: item.waiting_reason.as_ref().map(|reason| {
bounded_progress_text(reason, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS)
}),
}
} }
fn render_orchestrator_queue_attention_template( fn render_orchestrator_queue_attention_template(
context: &OrchestratorQueueTemplateContext, context: &worker::OrchestratorQueueAttentionContext,
) -> Result<String, worker::CatalogError> { ) -> Result<String, worker::CatalogError> {
worker::PromptCatalog::builtins_only()? worker::PromptCatalog::builtins_only()?
.render_serializable(ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT, context) .orchestrator_queue_attention(worker::OrchestratorQueueAttentionPrompt::Tui, context)
} }
fn orchestrator_work_set_detail( fn orchestrator_work_set_detail(
@@ -3918,21 +3898,35 @@ fn bounded_refinement_instruction(input: &str) -> String {
.to_string() .to_string()
} }
fn build_ready_ticket_refinement_thread_body(ticket_id: &str, instruction: &str) -> String { fn required_ticket_handoff_key(resource_key: Option<&str>) -> Result<&str, String> {
let resource_key = resource_key.ok_or_else(|| {
"Ticket handoff is unavailable because the canonical T-* resource key is missing. Refresh the panel and retry."
.to_string()
})?;
let sequence = resource_key.strip_prefix("T-").filter(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
});
sequence.map(|_| resource_key).ok_or_else(|| {
"Ticket handoff is unavailable because the canonical T-* resource key is invalid. Refresh the panel and retry."
.to_string()
})
}
fn build_ready_ticket_refinement_thread_body(ticket_key: &str, instruction: &str) -> String {
format!( format!(
"Panel returned ready Ticket {ticket_id} to planning for requirements sync. This is not Queue routing and must not start implementation.\n\n## User refinement instruction\n\n{instruction}\n" "Panel returned ready Ticket {ticket_key} to planning for requirements sync. This is not Queue routing and must not start implementation.\n\n## User refinement instruction\n\n{instruction}\n"
) )
} }
fn build_ready_ticket_refinement_launch_instruction(ticket_id: &str, instruction: &str) -> String { fn build_ready_ticket_refinement_launch_instruction(ticket_key: &str, instruction: &str) -> String {
format!( format!(
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}" "Continue Ticket Intake / requirements sync for existing Ticket {ticket_key}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
) )
} }
fn build_ready_ticket_refinement_notify(ticket_id: &str, instruction: &str) -> String { fn build_ready_ticket_refinement_notify(ticket_key: &str, instruction: &str) -> String {
format!( format!(
"Ticket {ticket_id} was returned from ready to planning from the Panel for requirements sync. Continue Intake/refinement only; do not Queue or route implementation. Read the Ticket thread for the recorded state change and user instruction.\n\nUser refinement instruction:\n\n{instruction}" "Ticket {ticket_key} was returned from ready to planning from the Panel for requirements sync. Continue Intake/refinement only; do not Queue or route implementation. Read the Ticket thread for the recorded state change and user instruction.\n\nUser refinement instruction:\n\n{instruction}"
) )
} }
@@ -3961,10 +3955,12 @@ async fn dispatch_ready_ticket_planning_return(
let ticket = backend let ticket = backend
.show(id.clone()) .show(id.clone())
.map_err(|error| TicketActionError::Ticket(error.to_string()))?; .map_err(|error| TicketActionError::Ticket(error.to_string()))?;
let ticket_key =
required_ticket_handoff_key(Some(&request.ticket_key)).map_err(TicketActionError::Stale)?;
if ticket.meta.workflow_state != TicketWorkflowState::Ready { if ticket.meta.workflow_state != TicketWorkflowState::Ready {
return Err(TicketActionError::Stale(format!( return Err(TicketActionError::Stale(format!(
"Ticket {} is {}; expected ready before returning it to planning. Refresh the panel and retry if appropriate.", "Ticket {} is {}; expected ready before returning it to planning. Refresh the panel and retry if appropriate.",
ticket.meta.id, ticket_key,
ticket.meta.workflow_state.as_str() ticket.meta.workflow_state.as_str()
))); )));
} }
@@ -3973,7 +3969,7 @@ async fn dispatch_ready_ticket_planning_return(
TicketWorkflowState::Planning.as_str(), TicketWorkflowState::Planning.as_str(),
"panel_return_to_planning", "panel_return_to_planning",
MarkdownText::from(build_ready_ticket_refinement_thread_body( MarkdownText::from(build_ready_ticket_refinement_thread_body(
&ticket.meta.id, ticket_key,
&request.user_instruction, &request.user_instruction,
)), )),
); );
@@ -3987,7 +3983,7 @@ async fn dispatch_ready_ticket_planning_return(
ReadyTicketPlanningReturnOutcome { ReadyTicketPlanningReturnOutcome {
notice: format!( notice: format!(
"Ticket {} returned to planning for refinement; launching Ticket Intake…", "Ticket {} returned to planning for refinement; launching Ticket Intake…",
ticket.meta.id ticket_key
), ),
followup: ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request), followup: ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request),
} }
@@ -3997,19 +3993,19 @@ async fn dispatch_ready_ticket_planning_return(
socket_path, socket_path,
} => { } => {
let message = let message =
build_ready_ticket_refinement_notify(&ticket.meta.id, &request.user_instruction); build_ready_ticket_refinement_notify(ticket_key, &request.user_instruction);
match send_notify_only(&socket_path, message, true).await { match send_notify_only(&socket_path, message, true).await {
Ok(()) => ReadyTicketPlanningReturnOutcome { Ok(()) => ReadyTicketPlanningReturnOutcome {
notice: format!( notice: format!(
"Ticket {} returned to planning for refinement; notified live Intake Worker {}.", "Ticket {} returned to planning for refinement; notified live Intake Worker {}.",
ticket.meta.id, worker_name ticket_key, worker_name
), ),
followup: ReadyTicketPlanningReturnAfterMutation::None, followup: ReadyTicketPlanningReturnAfterMutation::None,
}, },
Err(error) => ReadyTicketPlanningReturnOutcome { Err(error) => ReadyTicketPlanningReturnOutcome {
notice: bounded_panel_diagnostic(format!( notice: bounded_panel_diagnostic(format!(
"Ticket {} returned to planning and instruction was recorded, but notifying Intake Worker {} failed: {}", "Ticket {} returned to planning and instruction was recorded, but notifying Intake Worker {} failed: {}",
ticket.meta.id, worker_name, error ticket_key, worker_name, error
)), )),
followup: ReadyTicketPlanningReturnAfterMutation::None, followup: ReadyTicketPlanningReturnAfterMutation::None,
}, },
@@ -4020,7 +4016,7 @@ async fn dispatch_ready_ticket_planning_return(
ReadyTicketPlanningReturnOutcome { ReadyTicketPlanningReturnOutcome {
notice: format!( notice: format!(
"Ticket {} returned to planning for refinement; opening/restoring claimed Intake Worker {}…", "Ticket {} returned to planning for refinement; opening/restoring claimed Intake Worker {}…",
ticket.meta.id, worker_name ticket_key, worker_name
), ),
followup: ReadyTicketPlanningReturnAfterMutation::OpenClaim(request), followup: ReadyTicketPlanningReturnAfterMutation::OpenClaim(request),
} }
@@ -4029,7 +4025,7 @@ async fn dispatch_ready_ticket_planning_return(
ReadyTicketPlanningReturnOutcome { ReadyTicketPlanningReturnOutcome {
notice: bounded_panel_diagnostic(format!( notice: bounded_panel_diagnostic(format!(
"Ticket {} returned to planning and instruction was recorded, but Intake launch was not attempted because existing Intake claim {} is stale; inspect or clear the local claim before launching another Intake Worker.", "Ticket {} returned to planning and instruction was recorded, but Intake launch was not attempted because existing Intake claim {} is stale; inspect or clear the local claim before launching another Intake Worker.",
ticket.meta.id, worker_name ticket_key, worker_name
)), )),
followup: ReadyTicketPlanningReturnAfterMutation::None, followup: ReadyTicketPlanningReturnAfterMutation::None,
} }
@@ -5204,6 +5200,7 @@ fn row_status_label(entry: &WorkerListEntry) -> (&'static str, Style) {
.fg(Color::Cyan) .fg(Color::Cyan)
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Some(WorkerStatus::Stopped) => ("live stopped", Style::default().fg(Color::DarkGray)),
None => ("live", Style::default().fg(Color::DarkGray)), None => ("live", Style::default().fg(Color::DarkGray)),
}; };
} }
+111 -8
View File
@@ -390,6 +390,7 @@ fn planning_return_request(
ReadyTicketPlanningReturnRequest { ReadyTicketPlanningReturnRequest {
workspace_root: temp.path().to_path_buf(), workspace_root: temp.path().to_path_buf(),
ticket_id, ticket_id,
ticket_key: "T-482".to_string(),
user_instruction: instruction.to_string(), user_instruction: instruction.to_string(),
followup: ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim { followup: ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim {
worker_name: "stale-intake".to_string(), worker_name: "stale-intake".to_string(),
@@ -494,6 +495,7 @@ fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launc
}; };
assert_eq!(request.ticket_id, "20260608-000123-ready"); assert_eq!(request.ticket_id, "20260608-000123-ready");
assert_eq!(request.ticket_key, "T-1");
assert_eq!(request.user_instruction, "clarify expected behavior"); assert_eq!(request.user_instruction, "clarify expected behavior");
assert!(matches!( assert!(matches!(
request.followup, request.followup,
@@ -515,6 +517,7 @@ async fn planning_return_with_launch_followup_changes_state_before_launch_follow
let request = ReadyTicketPlanningReturnRequest { let request = ReadyTicketPlanningReturnRequest {
workspace_root: temp.path().to_path_buf(), workspace_root: temp.path().to_path_buf(),
ticket_id: ticket_id.clone(), ticket_id: ticket_id.clone(),
ticket_key: "T-482".to_string(),
user_instruction: "launch intake after state change".to_string(), user_instruction: "launch intake after state change".to_string(),
followup: ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest { followup: ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest {
context: TicketRoleLaunchContext::new(temp.path().to_path_buf(), TicketRole::Intake), context: TicketRoleLaunchContext::new(temp.path().to_path_buf(), TicketRole::Intake),
@@ -2969,7 +2972,7 @@ fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() {
} }
#[test] #[test]
fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() { fn idle_orchestrator_gets_sanitized_attention_for_new_queued_work() {
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]); let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
app.panel.rows = vec![panel_test_ticket_row( app.panel.rows = vec![panel_test_ticket_row(
"00001QUEUE", "00001QUEUE",
@@ -2989,11 +2992,87 @@ fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() {
request request
.notice .notice
.message .message
.starts_with("Workspace Dashboard observed") .starts_with("Queued Tickets require attention:")
); );
assert!(request.notice.message.contains("00001QUEUE")); assert!(request.notice.message.contains("- T-1 — Queued work"));
assert!(request.notice.message.contains("new_queued")); assert!(
assert!(request.notice.message.contains("queued -> inprogress")); request
.notice
.message
.contains("Reread the current Ticket state before acting")
);
assert!(
!request
.notice
.message
.contains(&app.panel.header.workspace_label)
);
for hidden in [
"00001QUEUE",
"Workspace:",
"workspace_id",
"new_queued",
"bounded",
"queued -> inprogress",
] {
assert!(!request.notice.message.contains(hidden), "leaked {hidden}");
}
}
#[test]
fn queued_attention_missing_resource_key_fails_closed_with_panel_notice() {
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
let mut row = panel_test_ticket_row(
"00001QUEUE",
"Queued work",
ActionPriority::Background,
NextUserAction::Wait,
"queued",
);
row.ticket.as_mut().unwrap().resource_key = None;
app.panel.rows = vec![row];
app.refresh_orchestrator_work_set();
assert!(app.prepare_orchestrator_queue_attention_notice().is_none());
assert_eq!(
app.notice.as_deref(),
Some(
"Orchestrator queued-work attention not delivered: queued Ticket is missing its required resource key"
)
);
}
#[test]
fn queued_attention_truncates_only_when_tickets_are_omitted() {
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
app.panel.rows = (1..=worker::OrchestratorQueueAttentionContext::MAX_TICKETS + 1)
.map(|index| {
let mut row = panel_test_ticket_row(
&format!("opaque-{index}"),
&format!("Queued work {index}"),
ActionPriority::Background,
NextUserAction::Wait,
"queued",
);
row.ticket.as_mut().unwrap().resource_key = Some(format!("T-{index}"));
row
})
.collect();
app.refresh_orchestrator_work_set();
let request = app
.prepare_orchestrator_queue_attention_notice()
.expect("bounded queued-work attention");
assert!(request.notice.message.contains("- T-20 — Queued work 20"));
assert!(!request.notice.message.contains("T-21"));
assert!(
request
.notice
.message
.contains("were omitted from this notice: 1")
);
assert!(!request.notice.message.contains("opaque-"));
} }
#[test] #[test]
@@ -3083,7 +3162,9 @@ fn planned_queued_prompts_when_active_work_clears() {
.prepare_orchestrator_queue_attention_notice() .prepare_orchestrator_queue_attention_notice()
.expect("planned queued work should prompt after active work clears"); .expect("planned queued work should prompt after active work clears");
assert!(request.notice.message.contains("planned_queued")); assert!(request.notice.message.contains("- T-1 — Queued work"));
assert!(!request.notice.message.contains("planned_queued"));
assert!(!request.notice.message.contains("00001QUEUE"));
assert!( assert!(
!request !request
.notice .notice
@@ -3138,8 +3219,9 @@ fn rediscovered_queued_work_is_actionable_when_session_work_set_is_empty() {
.prepare_orchestrator_queue_attention_notice() .prepare_orchestrator_queue_attention_notice()
.expect("queued ticket state should be rediscovered safely"); .expect("queued ticket state should be rediscovered safely");
assert!(request.notice.message.contains("new_queued")); assert!(request.notice.message.contains("- T-1 — Queued work"));
assert!(request.notice.message.contains("00001QUEUE")); assert!(!request.notice.message.contains("new_queued"));
assert!(!request.notice.message.contains("00001QUEUE"));
} }
#[test] #[test]
@@ -3425,6 +3507,27 @@ fn ticket_action_error_records_f2_diagnostic_details() {
assert!(!app.panel_diagnostic_open); assert!(!app.panel_diagnostic_open);
} }
#[test]
fn ready_ticket_refinement_projection_uses_only_canonical_resource_key() {
const INTERNAL_ID: &str = "00001KZVNXFNK";
let thread = build_ready_ticket_refinement_thread_body("T-482", "Clarify rollback.");
let launch = build_ready_ticket_refinement_launch_instruction("T-482", "Clarify rollback.");
let notify = build_ready_ticket_refinement_notify("T-482", "Clarify rollback.");
for projection in [&thread, &launch, &notify] {
assert!(projection.contains("T-482"));
assert!(!projection.contains(INTERNAL_ID));
}
}
#[test]
fn ticket_handoff_fails_closed_without_canonical_resource_key() {
assert_eq!(required_ticket_handoff_key(Some("T-482")), Ok("T-482"));
for invalid in [None, Some(""), Some("00001KZVNXFNK"), Some("T-key")] {
assert!(required_ticket_handoff_key(invalid).is_err());
}
}
fn plain_line(line: &Line<'_>) -> String { fn plain_line(line: &Line<'_>) -> String {
line.spans line.spans
.iter() .iter()
+1
View File
@@ -1530,6 +1530,7 @@ fn worker_status_label(entry: &WorkerListEntry) -> &'static str {
Some(WorkerStatus::Idle) => "live idle", Some(WorkerStatus::Idle) => "live idle",
Some(WorkerStatus::Running) => "live running", Some(WorkerStatus::Running) => "live running",
Some(WorkerStatus::Paused) => "live paused", Some(WorkerStatus::Paused) => "live paused",
Some(WorkerStatus::Stopped) => "live stopped",
None => "live", None => "live",
}; };
} }
+157 -58
View File
@@ -251,14 +251,8 @@ impl DelegatingWorkdirSession {
self.ensure_path(path, WorkdirDelegationPermission::Write) self.ensure_path(path, WorkdirDelegationPermission::Write)
} }
fn ensure_command(&self, starting: bool) -> Result<(), WorkdirError> { fn ensure_command(&self) -> Result<(), WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Command, "command execution")?; self.ensure_capability(WorkdirSessionCapability::Command, "command execution")
if starting && self.has_active_write_lease() {
return Err(WorkdirError::Denied(
"command execution is denied while a child holds a write delegation".into(),
));
}
Ok(())
} }
fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> { fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
@@ -281,20 +275,6 @@ impl DelegatingWorkdirSession {
} }
} }
fn has_active_write_lease(&self) -> bool {
let mut leases = self
.child_write_leases
.lock()
.expect("workdir delegation lease mutex poisoned");
leases.retain(|_, lease| lease.validity.upgrade().is_some_and(|v| v.is_active()));
leases.values().any(|lease| {
lease
.rules
.iter()
.any(|rule| rule.permission == WorkdirDelegationPermission::Write)
})
}
fn validate_delegation_rules( fn validate_delegation_rules(
&self, &self,
rules: &[WorkdirDelegationRule], rules: &[WorkdirDelegationRule],
@@ -503,12 +483,12 @@ impl WorkdirSession for DelegatingWorkdirSession {
} }
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> { async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
self.ensure_command(true)?; self.ensure_command()?;
self.source.start_command(request).await self.source.start_command(request).await
} }
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> { async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
self.ensure_command(false)?; self.ensure_command()?;
self.source.command_status(handle).await self.source.command_status(handle).await
} }
@@ -516,12 +496,12 @@ impl WorkdirSession for DelegatingWorkdirSession {
&self, &self,
request: CommandOutputRequest, request: CommandOutputRequest,
) -> Result<CommandOutput, WorkdirError> { ) -> Result<CommandOutput, WorkdirError> {
self.ensure_command(false)?; self.ensure_command()?;
self.source.command_output(request).await self.source.command_output(request).await
} }
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> { async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
self.ensure_command(false)?; self.ensure_command()?;
self.source.cancel_command(handle).await self.source.cancel_command(handle).await
} }
@@ -753,6 +733,31 @@ mod tests {
} }
} }
async fn run_command(
session: &WorkdirSessionHandle,
command: impl Into<String>,
tool_call_id: impl Into<String>,
) -> CommandOutput {
let handle = session
.start_command(CommandRequest {
command: command.into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some(tool_call_id.into()),
})
.await
.unwrap();
session
.command_output(CommandOutputRequest {
handle,
cursor: 0,
limit: 1024,
wait: true,
})
.await
.unwrap()
}
#[tokio::test] #[tokio::test]
async fn delegation_capable_session_forwards_command_telemetry() { async fn delegation_capable_session_forwards_command_telemetry() {
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
@@ -846,6 +851,18 @@ mod tests {
); );
assert!(child.scoped_session.subscribe_command_events().is_none()); assert!(child.scoped_session.subscribe_command_events().is_none());
assert!(child.scoped_session.command_snapshot().is_empty()); assert!(child.scoped_session.command_snapshot().is_empty());
assert!(matches!(
child
.scoped_session
.start_command(CommandRequest {
command: "printf denied".into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some("read-only-command".into()),
})
.await,
Err(WorkdirError::Denied(_))
));
} }
#[cfg(unix)] #[cfg(unix)]
@@ -924,7 +941,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn write_lease_blocks_parent_region_until_release() { async fn write_lease_keeps_typed_parent_writes_exclusive_without_blocking_commands() {
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("leased")).unwrap(); fs::create_dir_all(root.path().join("leased")).unwrap();
fs::create_dir_all(root.path().join("other")).unwrap(); fs::create_dir_all(root.path().join("other")).unwrap();
@@ -938,37 +955,24 @@ mod tests {
.capabilities .capabilities
.supports(WorkdirSessionCapability::Command) .supports(WorkdirSessionCapability::Command)
); );
let command = child let child_output = run_command(
.scoped_session &child.scoped_session,
.start_command(CommandRequest { "printf child-command",
command: "printf child-command".into(), "delegated-child-command",
timeout_secs: 5, )
output_limit: 1024, .await;
tool_call_id: Some("delegated-child-command".into()), assert_eq!(child_output.content, "child-command");
}) let parent_output = run_command(
.await &parent,
.unwrap(); "printf parent-write > leased/from-command; printf parent-command",
let command_output = child "parent-command-during-child-write",
.scoped_session )
.command_output(CommandOutputRequest { .await;
handle: command, assert_eq!(parent_output.status, CommandStatus::Completed);
cursor: 0, assert_eq!(parent_output.content, "parent-command");
limit: 1024, assert_eq!(
wait: true, fs::read_to_string(root.path().join("leased/from-command")).unwrap(),
}) "parent-write"
.await
.unwrap();
assert_eq!(command_output.content, "child-command");
assert!(
parent
.start_command(CommandRequest {
command: "printf parent-command".into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some("blocked-parent-command".into()),
})
.await
.is_err()
); );
assert!(matches!( assert!(matches!(
@@ -982,6 +986,18 @@ mod tests {
.await .await
.unwrap(); .unwrap();
child.release(); child.release();
assert!(matches!(
child
.scoped_session
.start_command(CommandRequest {
command: "printf revoked".into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some("revoked-child-command".into()),
})
.await,
Err(WorkdirError::SessionClosed)
));
parent parent
.write(write("leased/parent", "parent")) .write(write("leased/parent", "parent"))
.await .await
@@ -1033,6 +1049,78 @@ mod tests {
)); ));
} }
#[tokio::test]
async fn nested_write_leases_do_not_block_command_capable_ancestors() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("docs/sub")).unwrap();
let root_session = session(root.path());
let child = root_session
.delegate(request("docs", WorkdirDelegationPermission::Write))
.await
.unwrap();
let nested = child
.scoped_session
.delegate(request("docs/sub", WorkdirDelegationPermission::Write))
.await
.unwrap();
for (session, label) in [
(&root_session, "root"),
(&child.scoped_session, "child"),
(&nested.scoped_session, "nested"),
] {
let output = run_command(
session,
format!("printf {label}"),
format!("{label}-command-during-nested-write"),
)
.await;
assert_eq!(output.status, CommandStatus::Completed);
assert_eq!(output.content, label);
}
assert!(matches!(
root_session.write(write("docs/root", "blocked")).await,
Err(WorkdirError::Denied(_))
));
assert!(matches!(
child
.scoped_session
.write(write("sub/child", "blocked"))
.await,
Err(WorkdirError::Denied(_))
));
nested
.scoped_session
.write(write("nested", "allowed"))
.await
.unwrap();
nested.release();
child.release();
}
#[tokio::test]
async fn reapplied_write_delegation_chain_forwards_command_lifecycle() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("delegated")).unwrap();
let applied = apply_delegation_chain(
session(root.path()),
[request("delegated", WorkdirDelegationPermission::Write)],
)
.await
.unwrap();
let output = run_command(
&applied.scoped_session,
"printf reapplied",
"reapplied-command",
)
.await;
assert_eq!(output.status, CommandStatus::Completed);
assert_eq!(output.content, "reapplied");
}
#[tokio::test] #[tokio::test]
async fn applied_chain_cannot_replace_outer_provider_attenuation() { async fn applied_chain_cannot_replace_outer_provider_attenuation() {
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
@@ -1077,6 +1165,17 @@ mod tests {
.unwrap(); .unwrap();
parent.close().await.unwrap(); parent.close().await.unwrap();
assert!(matches!(
parent
.start_command(CommandRequest {
command: "printf closed".into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some("closed-parent-command".into()),
})
.await,
Err(WorkdirError::SessionClosed)
));
assert!(matches!( assert!(matches!(
child.scoped_session.read(read("a")).await, child.scoped_session.read(read("a")).await,
Err(WorkdirError::SessionClosed) Err(WorkdirError::SessionClosed)
+3 -1
View File
@@ -28,7 +28,9 @@ pub use fs_operation::{
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
}; };
pub use local::{LocalWorkdirSession, SymlinkInfo, direct_symlink, first_symlink}; pub use local::{
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
};
pub use operation::*; pub use operation::*;
/// Persistent, opaque identity of one materialized Workdir. /// Persistent, opaque identity of one materialized Workdir.
+69 -2
View File
@@ -8,7 +8,8 @@
//! `LocalWorkdirSession` is cheap to clone (`Arc` inside). Tool-specific session //! `LocalWorkdirSession` is cheap to clone (`Arc` inside). Tool-specific session
//! state, such as read-before-edit tracking, remains owned by the tool layer. //! state, such as read-before-edit tracking, remains owned by the tool layer.
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::fmt::Debug;
#[cfg(test)] #[cfg(test)]
use std::io::Write as _; use std::io::Write as _;
use std::io::{Read as _, Seek as _, SeekFrom}; use std::io::{Read as _, Seek as _, SeekFrom};
@@ -228,6 +229,8 @@ struct LocalWorkdirSessionInner {
next_command_id: AtomicU64, next_command_id: AtomicU64,
commands: Mutex<HashMap<String, LocalCommand>>, commands: Mutex<HashMap<String, LocalCommand>>,
command_telemetry: CommandTelemetry, command_telemetry: CommandTelemetry,
command_environment: BTreeMap<String, String>,
resources: StdMutex<Vec<Arc<dyn WorkdirSessionResource>>>,
} }
impl Drop for LocalWorkdirSessionInner { impl Drop for LocalWorkdirSessionInner {
@@ -242,6 +245,9 @@ impl Drop for LocalWorkdirSessionInner {
} }
} }
pub trait WorkdirSessionResource: Debug + Send + Sync {}
impl<T> WorkdirSessionResource for T where T: Debug + Send + Sync {}
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside). /// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
/// ///
/// The wrapped [`SharedScope`] is shared with every clone of this /// The wrapped [`SharedScope`] is shared with every clone of this
@@ -318,6 +324,26 @@ impl LocalWorkdirSession {
cwd: PathBuf, cwd: PathBuf,
scope: SharedScope, scope: SharedScope,
capabilities: WorkdirSessionCapabilities, capabilities: WorkdirSessionCapabilities,
) -> Self {
Self::materialized_bound_with_environment(
workdir,
root,
cwd,
scope,
capabilities,
BTreeMap::new(),
Vec::new(),
)
}
pub fn materialized_bound_with_environment(
workdir: Workdir,
root: PathBuf,
cwd: PathBuf,
scope: SharedScope,
capabilities: WorkdirSessionCapabilities,
command_environment: BTreeMap<String, String>,
resources: Vec<Arc<dyn WorkdirSessionResource>>,
) -> Self { ) -> Self {
Self { Self {
inner: Arc::new(LocalWorkdirSessionInner { inner: Arc::new(LocalWorkdirSessionInner {
@@ -331,6 +357,8 @@ impl LocalWorkdirSession {
next_command_id: AtomicU64::new(1), next_command_id: AtomicU64::new(1),
commands: Mutex::new(HashMap::new()), commands: Mutex::new(HashMap::new()),
command_telemetry: CommandTelemetry::new(), command_telemetry: CommandTelemetry::new(),
command_environment,
resources: StdMutex::new(resources),
}), }),
} }
} }
@@ -669,9 +697,18 @@ impl WorkdirSession for LocalWorkdirSession {
let (completion_tx, completion) = watch::channel(false); let (completion_tx, completion) = watch::channel(false);
let command_id = handle.0.clone(); let command_id = handle.0.clone();
let telemetry = self.inner.command_telemetry.clone(); let telemetry = self.inner.command_telemetry.clone();
let command_environment = self.inner.command_environment.clone();
let (cancel, cancel_rx) = watch::channel(false); let (cancel, cancel_rx) = watch::channel(false);
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let output = run_command(cwd, request, command_id, telemetry, cancel_rx).await; let output = run_command(
cwd,
request,
command_id,
telemetry,
command_environment,
cancel_rx,
)
.await;
let _ = completion_tx.send(true); let _ = completion_tx.send(true);
output output
}); });
@@ -840,6 +877,9 @@ impl WorkdirSession for LocalWorkdirSession {
LocalCommand::Completed(_) => {} LocalCommand::Completed(_) => {}
} }
} }
if let Ok(mut resources) = self.inner.resources.lock() {
resources.clear();
}
Ok(()) Ok(())
} }
} }
@@ -909,6 +949,7 @@ async fn run_command(
request: CommandRequest, request: CommandRequest,
command_id: String, command_id: String,
telemetry: CommandTelemetry, telemetry: CommandTelemetry,
command_environment: BTreeMap<String, String>,
mut cancel: watch::Receiver<bool>, mut cancel: watch::Receiver<bool>,
) -> Result<CommandOutput, WorkdirError> { ) -> Result<CommandOutput, WorkdirError> {
let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?; let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?;
@@ -925,6 +966,7 @@ async fn run_command(
.arg("-c") .arg("-c")
.arg(&request.command) .arg(&request.command)
.current_dir(&cwd) .current_dir(&cwd)
.envs(command_environment)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::from(stdout_file)) .stdout(Stdio::from(stdout_file))
.stderr(Stdio::from(stderr_file)) .stderr(Stdio::from(stderr_file))
@@ -2319,6 +2361,31 @@ mod tests {
assert_eq!(terminal, Some((handle.0, CommandStatus::TimedOut, None))); assert_eq!(terminal, Some((handle.0, CommandStatus::TimedOut, None)));
} }
#[tokio::test]
async fn closing_session_releases_runtime_resources() {
#[derive(Debug)]
struct Resource(Arc<AtomicBool>);
impl Drop for Resource {
fn drop(&mut self) {
self.0.store(true, Ordering::Release);
}
}
let dir = TempDir::new().unwrap();
let released = Arc::new(AtomicBool::new(false));
let session = LocalWorkdirSession::materialized_bound_with_environment(
Workdir::new("resource-session"),
dir.path().to_path_buf(),
dir.path().to_path_buf(),
SharedScope::new(Scope::writable(dir.path()).unwrap()),
WorkdirSessionCapabilities::ALL,
BTreeMap::from([("SSH_AUTH_SOCK".to_string(), "test-socket".to_string())]),
vec![Arc::new(Resource(released.clone()))],
);
WorkdirSession::close(&session).await.unwrap();
assert!(released.load(Ordering::Acquire));
}
#[tokio::test] #[tokio::test]
async fn provider_cancels_active_command() { async fn provider_cancels_active_command() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
+23
View File
@@ -30,6 +30,8 @@ impl RuntimeWorkerRef {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum MaterializerKind { pub enum MaterializerKind {
#[default] #[default]
RuntimeGitCache,
/// Legacy persisted value from the pre-cache local `git worktree` materializer.
LocalGitWorktree, LocalGitWorktree,
} }
@@ -109,6 +111,8 @@ pub struct WorkingDirectoryProvenance {
pub creation_selector: Option<String>, pub creation_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_ref: Option<String>, pub creation_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_tree: Option<String>,
pub materializer_kind: MaterializerKind, pub materializer_kind: MaterializerKind,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>, pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
@@ -122,6 +126,10 @@ pub struct WorkingDirectoryCurrentObservation {
pub current_selector: Option<String>, pub current_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub current_ref: Option<String>, pub current_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_at_epoch_seconds: Option<u64>,
pub status: WorkingDirectoryStatusKind, pub status: WorkingDirectoryStatusKind,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanliness: Option<String>, pub cleanliness: Option<String>,
@@ -141,9 +149,15 @@ pub struct WorkingDirectorySummary {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_ref: Option<String>, pub creation_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_selector: Option<String>, pub current_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub current_ref: Option<String>, pub current_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_at_epoch_seconds: Option<u64>,
pub materializer_kind: MaterializerKind, pub materializer_kind: MaterializerKind,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>, pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
@@ -166,6 +180,7 @@ impl WorkingDirectorySummary {
WorkingDirectoryProvenance { WorkingDirectoryProvenance {
creation_selector: self.creation_selector.clone(), creation_selector: self.creation_selector.clone(),
creation_ref: self.creation_ref.clone(), creation_ref: self.creation_ref.clone(),
creation_tree: self.creation_tree.clone(),
materializer_kind: self.materializer_kind.clone(), materializer_kind: self.materializer_kind.clone(),
cleanup_target: self.cleanup_target.clone(), cleanup_target: self.cleanup_target.clone(),
} }
@@ -175,6 +190,8 @@ impl WorkingDirectorySummary {
WorkingDirectoryCurrentObservation { WorkingDirectoryCurrentObservation {
current_selector: self.current_selector.clone(), current_selector: self.current_selector.clone(),
current_ref: self.current_ref.clone(), current_ref: self.current_ref.clone(),
current_tree: self.current_tree.clone(),
observed_at_epoch_seconds: self.observed_at_epoch_seconds,
status: self.status.clone(), status: self.status.clone(),
cleanliness: self.cleanliness.clone(), cleanliness: self.cleanliness.clone(),
primary_worker_id: self.primary_worker_id.clone(), primary_worker_id: self.primary_worker_id.clone(),
@@ -247,8 +264,11 @@ mod tests {
repository_id: "repo".to_string(), repository_id: "repo".to_string(),
creation_selector: Some("develop".to_string()), creation_selector: Some("develop".to_string()),
creation_ref: Some("abc123".to_string()), creation_ref: Some("abc123".to_string()),
creation_tree: Some("tree123".to_string()),
current_selector: Some("work/ticket".to_string()), current_selector: Some("work/ticket".to_string()),
current_ref: Some("def456".to_string()), current_ref: Some("def456".to_string()),
current_tree: Some("tree456".to_string()),
observed_at_epoch_seconds: Some(1_777_777_777),
materializer_kind: MaterializerKind::LocalGitWorktree, materializer_kind: MaterializerKind::LocalGitWorktree,
cleanup_target: Some(WorkingDirectoryCleanupTarget { cleanup_target: Some(WorkingDirectoryCleanupTarget {
kind: "git_worktree".to_string(), kind: "git_worktree".to_string(),
@@ -269,8 +289,11 @@ mod tests {
repository_id: "repo".to_string(), repository_id: "repo".to_string(),
creation_selector: None, creation_selector: None,
creation_ref: None, creation_ref: None,
creation_tree: None,
current_selector: None, current_selector: None,
current_ref: Some("987fed".to_string()), current_ref: Some("987fed".to_string()),
current_tree: None,
observed_at_epoch_seconds: None,
materializer_kind: MaterializerKind::LocalGitWorktree, materializer_kind: MaterializerKind::LocalGitWorktree,
cleanup_target: None, cleanup_target: None,
status: WorkingDirectoryStatusKind::Active, status: WorkingDirectoryStatusKind::Active,
+1
View File
@@ -43,6 +43,7 @@ tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
toml.workspace = true toml.workspace = true
url.workspace = true url.workspace = true
uuid = { workspace = true, features = ["v7"] } uuid = { workspace = true, features = ["v7"] }
zeroize.workspace = true
tower = { workspace = true, features = ["util"], optional = true } tower = { workspace = true, features = ["util"], optional = true }
worker.workspace = true worker.workspace = true
workspace-api = { path = "../workspace-api" } workspace-api = { path = "../workspace-api" }
+71
View File
@@ -97,6 +97,74 @@ pub use workdir::workspace::{
WorkingDirectorySummary, WorkingDirectorySummary,
}; };
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SensitiveString(String);
impl SensitiveString {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn expose(&self) -> &str {
&self.0
}
}
impl Drop for SensitiveString {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.0);
}
}
impl Default for SensitiveString {
fn default() -> Self {
Self(String::new())
}
}
impl std::fmt::Debug for SensitiveString {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("[REDACTED]")
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySshMaterializationAccess {
pub credential_id: String,
pub credential_revision: u64,
pub host_trust_id: String,
pub host_trust_revision: u64,
pub access: workspace_api::RepositoryAccessMode,
pub expires_at_epoch_seconds: u64,
pub repository_id: String,
pub repository_source_fingerprint: String,
pub repository_uri: String,
pub secret_resource: crate::resource::BackendResourceHandle,
#[serde(skip, default)]
pub private_key: SensitiveString,
#[serde(skip, default)]
pub known_hosts_entry: SensitiveString,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryMaterializationContext {
pub workspace_id: String,
pub runtime_id: String,
pub operation_id: String,
pub config_revision: u64,
pub config_projection_digest: String,
#[serde(default)]
pub cache_generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<RepositorySshMaterializationAccess>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryRepositoryAccessRequest {
pub working_directory_id: String,
pub materialization: RepositoryMaterializationContext,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryRequest { pub struct WorkingDirectoryRequest {
pub repository: WorkingDirectoryRepository, pub repository: WorkingDirectoryRepository,
@@ -106,6 +174,9 @@ pub struct WorkingDirectoryRequest {
/// Backend can create canonical registry rows before materialization. /// Backend can create canonical registry rows before materialization.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub backend_workdir_id: Option<String>, pub backend_workdir_id: Option<String>,
/// Backend-authored, operation-scoped repository access and cache identity.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub materialization: Option<RepositoryMaterializationContext>,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+21 -1
View File
@@ -1,4 +1,6 @@
use crate::catalog::{WorkingDirectoryRequest, WorkingDirectoryStatus}; use crate::catalog::{
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
};
use crate::config_bundle::ConfigBundle; use crate::config_bundle::ConfigBundle;
use crate::error::RuntimeError; use crate::error::RuntimeError;
use crate::identity::WorkerRef; use crate::identity::WorkerRef;
@@ -319,6 +321,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
)) ))
} }
fn authorize_working_directory_repository_access(
&self,
_request: &WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), WorkingDirectoryDiagnostic> {
Err(WorkingDirectoryDiagnostic::rejected(
"working_directory_repository_access_unsupported",
"Worker execution backend does not support Repository access authorization",
))
}
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> { fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
Vec::new() Vec::new()
} }
@@ -454,6 +466,14 @@ impl WorkerExecutionBackendRef {
self.backend.create_working_directory(request) self.backend.create_working_directory(request)
} }
pub(crate) fn authorize_working_directory_repository_access(
&self,
request: &WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), WorkingDirectoryDiagnostic> {
self.backend
.authorize_working_directory_repository_access(request)
}
pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> { pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
self.backend.list_working_directories() self.backend.list_working_directories()
} }
+53 -2
View File
@@ -12,7 +12,8 @@ use crate::auth::{
}; };
use crate::catalog::{ use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary, ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkspaceApiRef, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkspaceApiRef,
}; };
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary}; use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
use crate::error::RuntimeError; use crate::error::RuntimeError;
@@ -203,6 +204,10 @@ fn runtime_http_router_with_optional_auth(
"/v1/working-directories", "/v1/working-directories",
get(list_working_directories).post(create_working_directory), get(list_working_directories).post(create_working_directory),
) )
.route(
"/v1/working-directories/repository-access",
post(authorize_working_directory_repository_access),
)
.route( .route(
"/v1/working-directories/{working_directory_id}/sessions", "/v1/working-directories/{working_directory_id}/sessions",
post(open_workdir_session), post(open_workdir_session),
@@ -335,6 +340,11 @@ pub struct RuntimeHttpWorkingDirectoriesResponse {
pub working_directories: Vec<WorkingDirectoryStatus>, pub working_directories: Vec<WorkingDirectoryStatus>,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpRepositoryAccessResponse {
pub authorized: bool,
}
/// Working directory response used by create/detail/delete endpoints. /// Working directory response used by create/detail/delete endpoints.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpWorkingDirectoryResponse { pub struct RuntimeHttpWorkingDirectoryResponse {
@@ -513,6 +523,29 @@ async fn list_workers(
Ok(Json(RuntimeHttpWorkersResponse { workers })) Ok(Json(RuntimeHttpWorkersResponse { workers }))
} }
async fn authorize_working_directory_repository_access(
State(state): State<RuntimeHttpState>,
Extension(auth): Extension<RuntimeAuthContext>,
body: Result<Json<WorkingDirectoryRepositoryAccessRequest>, JsonRejection>,
) -> RestResult<RuntimeHttpRepositoryAccessResponse> {
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
if request.materialization.workspace_id != auth.workspace_id {
return Err(RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"working_directory_materialization_workspace_mismatch",
"Repository access authority does not match the authenticated Workspace",
));
}
state
.runtime
.authorize_working_directory_repository_access_from_resource(request)
.await
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpRepositoryAccessResponse {
authorized: true,
}))
}
async fn list_working_directories( async fn list_working_directories(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> { ) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
@@ -527,12 +560,23 @@ async fn list_working_directories(
async fn create_working_directory( async fn create_working_directory(
State(state): State<RuntimeHttpState>, State(state): State<RuntimeHttpState>,
Extension(auth): Extension<RuntimeAuthContext>,
body: Result<Json<WorkingDirectoryRequest>, JsonRejection>, body: Result<Json<WorkingDirectoryRequest>, JsonRejection>,
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> { ) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?; let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
if let Some(materialization) = request.materialization.as_ref()
&& materialization.workspace_id != auth.workspace_id
{
return Err(RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"working_directory_materialization_workspace_mismatch",
"Repository materialization authority does not match the authenticated Workspace",
));
}
let working_directory = state let working_directory = state
.runtime .runtime
.create_working_directory(request) .create_working_directory_from_resource(request)
.await
.map_err(RuntimeHttpRestError::runtime)?; .map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkingDirectoryResponse { Ok(Json(RuntimeHttpWorkingDirectoryResponse {
working_directory, working_directory,
@@ -1559,6 +1603,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
if path == "/v1/workers" && *method == Method::POST { if path == "/v1/workers" && *method == Method::POST {
return Some("workers:create"); return Some("workers:create");
} }
if path == "/v1/working-directories/repository-access" && *method == Method::POST {
return Some("workdirs:operate");
}
if path.starts_with("/v1/workdir-sessions") if path.starts_with("/v1/workdir-sessions")
|| (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions")) || (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions"))
{ {
@@ -2220,6 +2267,10 @@ mod tests {
#[test] #[test]
fn workdir_routes_require_dedicated_operation_permission() { fn workdir_routes_require_dedicated_operation_permission() {
assert_eq!(
required_runtime_permission(&Method::POST, "/v1/working-directories/repository-access",),
Some("workdirs:operate")
);
assert_eq!( assert_eq!(
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"), required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
Some("workdirs:operate") Some("workdirs:operate")
+33 -9
View File
@@ -23,10 +23,21 @@ use worker_runtime::http_server::{
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection, RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
}; };
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
use worker_runtime::working_directory::LocalGitWorktreeMaterializer; use worker_runtime::working_directory::RuntimeGitCacheMaterializer;
use worker_runtime::{Runtime, RuntimeOptions}; use worker_runtime::{Runtime, RuntimeOptions};
fn main() -> ExitCode { fn main() -> ExitCode {
let mut arguments = std::env::args().skip(1).collect::<Vec<_>>();
if arguments.first().map(String::as_str) == Some("__repository-ssh") {
arguments.remove(0);
return match worker_runtime::working_directory::run_repository_ssh_client(&arguments) {
Ok(status) => ExitCode::from(u8::try_from(status).unwrap_or(1)),
Err(error) => {
eprintln!("{error}");
ExitCode::from(1)
}
};
}
match run() { match run() {
Ok(()) => ExitCode::SUCCESS, Ok(()) => ExitCode::SUCCESS,
Err(error) => { Err(error) => {
@@ -169,6 +180,9 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
factory = factory.with_remote_worker_mutation_identity(identity); factory = factory.with_remote_worker_mutation_identity(identity);
} }
} }
let mut backend_resource_client: Option<
Arc<dyn worker_runtime::resource::BackendResourceClient>,
> = None;
if let Some(endpoint) = config.backend_resource_endpoint.clone() { if let Some(endpoint) = config.backend_resource_endpoint.clone() {
let identity = runtime_auth.identity.as_ref().ok_or_else(|| { let identity = runtime_auth.identity.as_ref().ok_or_else(|| {
ProcessError::Auth( ProcessError::Auth(
@@ -181,26 +195,28 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
.to_owned(), .to_owned(),
)); ));
}; };
factory = factory.with_resource_client(Arc::new( let client = Arc::new(
worker_runtime::resource::HttpBackendResourceClient::new( worker_runtime::resource::HttpBackendResourceClient::new(
endpoint, endpoint,
config.backend_resource_token.clone(), config.backend_resource_token.clone(),
) )
.with_runtime_request_source(identity, trusted_server.server_id.clone()), .with_runtime_request_source(identity, trusted_server.server_id.clone()),
)); );
factory = factory.with_resource_client(client.clone());
backend_resource_client = Some(client);
} }
let backend = Arc::new( let backend = Arc::new(
WorkerRuntimeExecutionBackend::new(factory) WorkerRuntimeExecutionBackend::new(factory)
.map_err(ProcessError::WorkerAdapter)? .map_err(ProcessError::WorkerAdapter)?
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new( .with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
fs_paths.workdir_target.clone(), fs_paths.workdir_target.clone(),
)), )),
); );
match &config.http.store { let runtime = match &config.http.store {
RuntimeHttpStoreSelection::Memory => { RuntimeHttpStoreSelection::Memory => {
Runtime::with_execution_backend(runtime_options_from_http(&config.http), backend) Runtime::with_execution_backend(runtime_options_from_http(&config.http), backend)
.map_err(ProcessError::Runtime) .map_err(ProcessError::Runtime)?
} }
RuntimeHttpStoreSelection::Fs { root } => { RuntimeHttpStoreSelection::Fs { root } => {
let mut options = FsRuntimeStoreOptions::new(root.clone()).with_runtime_id( let mut options = FsRuntimeStoreOptions::new(root.clone()).with_runtime_id(
@@ -213,12 +229,20 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
); );
options.display_name = config.http.display_name.clone(); options.display_name = config.http.display_name.clone();
Runtime::with_fs_store_and_execution_backend(options, backend) Runtime::with_fs_store_and_execution_backend(options, backend)
.map_err(ProcessError::Runtime) .map_err(ProcessError::Runtime)?
} }
_ => Err(ProcessError::usage( _ => {
return Err(ProcessError::usage(
"unsupported Runtime catalog store selection".to_string(), "unsupported Runtime catalog store selection".to_string(),
)), ));
} }
};
if let Some(client) = backend_resource_client {
runtime
.install_backend_resource_client(client)
.map_err(ProcessError::Runtime)?;
}
Ok(runtime)
} }
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions { fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
+56 -5
View File
@@ -11,18 +11,46 @@ use std::sync::Mutex;
pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str = pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str =
"application/vnd.yoi.profile-source-archive+tar"; "application/vnd.yoi.profile-source-archive+tar";
pub const REPOSITORY_SSH_ACCESS_CONTENT_TYPE: &str =
"application/vnd.yoi.repository-ssh-access+json";
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024; pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
pub const DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES: u64 = 64 * 1024;
#[derive(Clone, Serialize, Deserialize)]
pub struct RepositorySshAccessSecret {
pub private_key: String,
pub known_hosts_entry: String,
}
impl Drop for RepositorySshAccessSecret {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.private_key);
zeroize::Zeroize::zeroize(&mut self.known_hosts_entry);
}
}
impl std::fmt::Debug for RepositorySshAccessSecret {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RepositorySshAccessSecret")
.field("private_key", &"[REDACTED]")
.field("known_hosts_entry", &"[REDACTED]")
.finish()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum BackendResourceKind { pub enum BackendResourceKind {
ProfileSourceArchive, ProfileSourceArchive,
RepositorySshAccess,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum BackendResourceOperation { pub enum BackendResourceOperation {
FetchArchive, FetchArchive,
FetchOnce,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -66,7 +94,7 @@ pub struct BackendResourceFetchRequest {
pub audit_correlation_id: String, pub audit_correlation_id: String,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackendResourceFetchResponse { pub struct BackendResourceFetchResponse {
pub kind: BackendResourceKind, pub kind: BackendResourceKind,
pub resource_id: String, pub resource_id: String,
@@ -76,6 +104,29 @@ pub struct BackendResourceFetchResponse {
pub audit_correlation_id: String, pub audit_correlation_id: String,
} }
impl std::fmt::Debug for BackendResourceFetchResponse {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("BackendResourceFetchResponse")
.field("kind", &self.kind)
.field("resource_id", &self.resource_id)
.field("digest", &self.digest)
.field("content_type", &self.content_type)
.field(
"bytes",
&format_args!("[REDACTED; {} bytes]", self.bytes.len()),
)
.field("audit_correlation_id", &self.audit_correlation_id)
.finish()
}
}
impl Drop for BackendResourceFetchResponse {
fn drop(&mut self) {
self.bytes.fill(0);
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "code", rename_all = "snake_case")] #[serde(tag = "code", rename_all = "snake_case")]
pub enum BackendResourceError { pub enum BackendResourceError {
@@ -248,7 +299,7 @@ pub fn build_profile_source_archive_fetch_request(
pub fn profile_source_archive_from_response( pub fn profile_source_archive_from_response(
handle: &BackendResourceHandle, handle: &BackendResourceHandle,
response: BackendResourceFetchResponse, mut response: BackendResourceFetchResponse,
) -> Result<ProfileSourceArchive, BackendResourceError> { ) -> Result<ProfileSourceArchive, BackendResourceError> {
if handle.kind != BackendResourceKind::ProfileSourceArchive if handle.kind != BackendResourceKind::ProfileSourceArchive
|| response.kind != BackendResourceKind::ProfileSourceArchive || response.kind != BackendResourceKind::ProfileSourceArchive
@@ -263,7 +314,7 @@ pub fn profile_source_archive_from_response(
if response.content_type != handle.content_type { if response.content_type != handle.content_type {
return Err(BackendResourceError::ContentTypeMismatch { return Err(BackendResourceError::ContentTypeMismatch {
expected: handle.content_type.clone(), expected: handle.content_type.clone(),
actual: response.content_type, actual: response.content_type.clone(),
}); });
} }
let actual_bytes = response.bytes.len() as u64; let actual_bytes = response.bytes.len() as u64;
@@ -278,7 +329,7 @@ pub fn profile_source_archive_from_response(
return Err(BackendResourceError::DigestMismatch { return Err(BackendResourceError::DigestMismatch {
expected: handle.digest.clone(), expected: handle.digest.clone(),
actual: if response.digest != handle.digest { actual: if response.digest != handle.digest {
response.digest response.digest.clone()
} else { } else {
actual_digest actual_digest
}, },
@@ -296,7 +347,7 @@ pub fn profile_source_archive_from_response(
} }
})?, })?,
}, },
content: response.bytes, content: std::mem::take(&mut response.bytes),
}) })
} }
+449 -4
View File
@@ -1,6 +1,6 @@
use crate::catalog::{ use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck, ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck,
WorkerStatus, WorkerSummary, WorkingDirectoryRequest, WorkerStatus, WorkerSummary, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest,
WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkspaceApiRef, WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkspaceApiRef,
}; };
use crate::config_bundle::{ use crate::config_bundle::{
@@ -26,6 +26,10 @@ use crate::management::{
}; };
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
use crate::observation::{WorkerObservationCursor, WorkerObservationEvent}; use crate::observation::{WorkerObservationCursor, WorkerObservationEvent};
use crate::resource::{
BackendResourceClient, BackendResourceError, BackendResourceFetchRequest, BackendResourceKind,
REPOSITORY_SSH_ACCESS_CONTENT_TYPE, RepositorySshAccessSecret,
};
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
use crate::retention::{ use crate::retention::{
FsWorkerRetentionProvider, WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult, FsWorkerRetentionProvider, WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult,
@@ -172,6 +176,14 @@ impl Runtime {
Ok(runtime) Ok(runtime)
} }
pub fn install_backend_resource_client(
&self,
client: Arc<dyn BackendResourceClient>,
) -> Result<(), RuntimeError> {
self.lock()?.backend_resource_client = Some(BackendResourceClientRef(client));
Ok(())
}
/// Create or restore a filesystem-backed Runtime. /// Create or restore a filesystem-backed Runtime.
/// ///
/// The store is scoped by `options.root`; if the directory already exists, /// The store is scoped by `options.root`; if the directory already exists,
@@ -366,6 +378,103 @@ impl Runtime {
.map_err(RuntimeError::from) .map_err(RuntimeError::from)
} }
pub async fn create_working_directory_from_resource(
&self,
mut request: WorkingDirectoryRequest,
) -> Result<CatalogWorkingDirectoryStatus, RuntimeError> {
if let Some(ssh) = request
.materialization
.as_mut()
.and_then(|materialization| materialization.ssh.as_mut())
{
self.resolve_repository_access_resource(ssh).await?;
}
self.create_working_directory(request)
}
pub fn authorize_working_directory_repository_access(
&self,
request: WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), RuntimeError> {
let backend = {
let state = self.lock()?;
state.ensure_running()?;
state.execution_backend.clone().ok_or_else(|| {
RuntimeError::ExecutionBackendUnavailable {
message: "working directory Repository access requires an execution backend"
.to_string(),
}
})?
};
backend
.authorize_working_directory_repository_access(&request)
.map_err(RuntimeError::from)
}
async fn resolve_repository_access_resource(
&self,
ssh: &mut crate::catalog::RepositorySshMaterializationAccess,
) -> Result<(), RuntimeError> {
if !ssh.private_key.expose().is_empty() && !ssh.known_hosts_entry.expose().is_empty() {
return Ok(());
}
let (client, runtime_id) = {
let state = self.lock()?;
let client = state.backend_resource_client.clone().ok_or_else(|| {
RuntimeError::InvalidRequest(
"Backend Repository access resource client is unavailable".to_string(),
)
})?;
let runtime_id = state.runtime_identity.clone().ok_or_else(|| {
RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string())
})?;
(client, runtime_id)
};
let mut response = client
.0
.fetch_resource(BackendResourceFetchRequest {
handle: ssh.secret_resource.clone(),
runtime_id,
worker_id: None,
audit_correlation_id: ssh.secret_resource.audit_correlation_id.clone(),
})
.await
.map_err(repository_resource_error)?;
if response.kind != BackendResourceKind::RepositorySshAccess
|| response.content_type != REPOSITORY_SSH_ACCESS_CONTENT_TYPE
|| response.resource_id != ssh.secret_resource.resource_id
|| response.digest != ssh.secret_resource.digest
|| response.bytes.len() as u64 > ssh.secret_resource.max_bytes
{
return Err(RuntimeError::InvalidRequest(
"Backend Repository SSH access resource response was invalid".to_string(),
));
}
let secret = serde_json::from_slice::<RepositorySshAccessSecret>(&response.bytes);
response.bytes.fill(0);
let mut secret = secret.map_err(|_| {
RuntimeError::InvalidRequest(
"Backend Repository SSH access resource payload was invalid".to_string(),
)
})?;
ssh.private_key =
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key));
ssh.known_hosts_entry =
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.known_hosts_entry));
Ok(())
}
pub async fn authorize_working_directory_repository_access_from_resource(
&self,
mut request: WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), RuntimeError> {
let ssh = request.materialization.ssh.as_mut().ok_or_else(|| {
RuntimeError::InvalidRequest("Repository SSH access metadata is missing".to_string())
})?;
self.resolve_repository_access_resource(ssh).await?;
self.authorize_working_directory_repository_access(request)
}
/// List Runtime-owned working directories through the attached execution backend. /// List Runtime-owned working directories through the attached execution backend.
pub fn list_working_directories( pub fn list_working_directories(
&self, &self,
@@ -566,12 +675,13 @@ impl Runtime {
let worker_id = request.worker_id; let worker_id = request.worker_id;
let worker_ref = WorkerRef::new(worker_id); let worker_ref = WorkerRef::new(worker_id);
let durable_request = durable_create_worker_request(&request);
let record = WorkerRecord { let record = WorkerRecord {
worker_ref: worker_ref.clone(), worker_ref: worker_ref.clone(),
worker_id: worker_id.clone(), worker_id: worker_id.clone(),
status: WorkerStatus::Stopped, status: WorkerStatus::Stopped,
workspace_id: scope.map(|scope| scope.workspace_id.clone()), workspace_id: scope.map(|scope| scope.workspace_id.clone()),
request: request.clone(), request: durable_request,
run_generation: 1, run_generation: 1,
working_directory: None, working_directory: None,
execution_handle: None, execution_handle: None,
@@ -1842,6 +1952,15 @@ struct SubscriptionSink {
lagged: Arc<AtomicBool>, lagged: Arc<AtomicBool>,
} }
#[derive(Clone)]
struct BackendResourceClientRef(Arc<dyn BackendResourceClient>);
impl std::fmt::Debug for BackendResourceClientRef {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("BackendResourceClientRef(..)")
}
}
#[derive(Debug)] #[derive(Debug)]
struct RuntimeState { struct RuntimeState {
display_name: Option<String>, display_name: Option<String>,
@@ -1853,6 +1972,7 @@ struct RuntimeState {
persistence: RuntimePersistence, persistence: RuntimePersistence,
status: RuntimeStatus, status: RuntimeStatus,
execution_backend: Option<WorkerExecutionBackendRef>, execution_backend: Option<WorkerExecutionBackendRef>,
backend_resource_client: Option<BackendResourceClientRef>,
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: u64, next_diagnostic_id: u64,
workers: BTreeMap<WorkerId, WorkerRecord>, workers: BTreeMap<WorkerId, WorkerRecord>,
@@ -1880,6 +2000,7 @@ impl RuntimeState {
persistence: RuntimePersistence::Memory, persistence: RuntimePersistence::Memory,
status: RuntimeStatus::Running, status: RuntimeStatus::Running,
execution_backend: None, execution_backend: None,
backend_resource_client: None,
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: 1, next_diagnostic_id: 1,
workers: BTreeMap::new(), workers: BTreeMap::new(),
@@ -1908,6 +2029,7 @@ impl RuntimeState {
persistence: RuntimePersistence::Fs(store), persistence: RuntimePersistence::Fs(store),
status: RuntimeStatus::Running, status: RuntimeStatus::Running,
execution_backend: None, execution_backend: None,
backend_resource_client: None,
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: 1, next_diagnostic_id: 1,
workers: BTreeMap::new(), workers: BTreeMap::new(),
@@ -1959,6 +2081,7 @@ impl RuntimeState {
persistence: RuntimePersistence::Fs(store), persistence: RuntimePersistence::Fs(store),
status: persisted.status, status: persisted.status,
execution_backend: None, execution_backend: None,
backend_resource_client: None,
next_diagnostic_id, next_diagnostic_id,
workers, workers,
config_bundles: BTreeMap::new(), config_bundles: BTreeMap::new(),
@@ -2619,6 +2742,7 @@ impl RuntimeState {
protocol::WorkerStatus::Running => Some(WorkerStatus::Running), protocol::WorkerStatus::Running => Some(WorkerStatus::Running),
protocol::WorkerStatus::Idle => Some(WorkerStatus::Idle), protocol::WorkerStatus::Idle => Some(WorkerStatus::Idle),
protocol::WorkerStatus::Paused => Some(WorkerStatus::Paused), protocol::WorkerStatus::Paused => Some(WorkerStatus::Paused),
protocol::WorkerStatus::Stopped => Some(WorkerStatus::Stopped),
}, },
protocol::Event::RunEnd { result } => match result { protocol::Event::RunEnd { result } => match result {
protocol::RunResult::Finished | protocol::RunResult::RolledBack => { protocol::RunResult::Finished | protocol::RunResult::RolledBack => {
@@ -2714,6 +2838,33 @@ fn worker_status_from_run_state(run_state: WorkerExecutionRunState) -> WorkerSta
} }
} }
fn repository_resource_error(error: BackendResourceError) -> RuntimeError {
let category = match error {
BackendResourceError::Expired => "expired",
BackendResourceError::Unauthorized { .. } => "unauthorized",
BackendResourceError::UnsupportedKind => "unsupported_kind",
BackendResourceError::MissingResource => "missing_resource",
BackendResourceError::Oversized { .. } => "oversized",
BackendResourceError::DigestMismatch { .. } => "digest_mismatch",
BackendResourceError::ContentTypeMismatch { .. } => "content_type_mismatch",
BackendResourceError::InvalidResponse { .. } => "invalid_response",
BackendResourceError::Transport { .. } => "transport",
};
RuntimeError::InvalidRequest(format!(
"Backend Repository SSH access resource fetch failed: {category}"
))
}
fn durable_create_worker_request(request: &CreateWorkerRequest) -> CreateWorkerRequest {
let mut durable = request.clone();
if let Some(working_directory) = durable.working_directory_request.as_mut()
&& let Some(materialization) = working_directory.materialization.as_mut()
{
materialization.ssh = None;
}
durable
}
fn requested_primary_workdir_id(request: &CreateWorkerRequest) -> Option<&str> { fn requested_primary_workdir_id(request: &CreateWorkerRequest) -> Option<&str> {
request request
.working_directory .working_directory
@@ -2884,7 +3035,9 @@ fn subscription_worker_state(status: WorkerStatus) -> SubscriptionWorkerState {
mod tests { mod tests {
use super::*; use super::*;
use crate::catalog::{ use crate::catalog::{
ConfigBundleRef, ProfileSelector, WorkingDirectoryClaim, WorkspaceApiRef, ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext,
RepositorySshMaterializationAccess, SensitiveString, WorkingDirectoryClaim,
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef,
}; };
use crate::config_bundle::{ use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration, ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
@@ -2894,6 +3047,8 @@ mod tests {
WorkerExecutionBackend, WorkerExecutionContext, WorkerExecutionHandle, WorkerExecutionBackend, WorkerExecutionContext, WorkerExecutionHandle,
WorkerExecutionRestoreRequest, WorkerExecutionRunState, WorkerExecutionRestoreRequest, WorkerExecutionRunState,
}; };
use crate::working_directory::WorkingDirectoryDiagnostic;
use async_trait::async_trait;
use std::collections::BTreeMap; use std::collections::BTreeMap;
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
@@ -2950,7 +3105,7 @@ mod tests {
&mut activity, &mut activity,
&internal_worker_status_event( &internal_worker_status_event(
internal_worker_ref("child-b", None), internal_worker_ref("child-b", None),
protocol::WorkerStatus::Idle, protocol::WorkerStatus::Stopped,
), ),
)); ));
} }
@@ -3115,6 +3270,243 @@ mod tests {
} }
} }
#[test]
fn durable_worker_request_omits_repository_credentials() {
let mut request = task_request("worker-secret-redaction");
request.working_directory_request = Some(WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: "repository-1".to_string(),
provider: "git".to_string(),
source: workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::Ssh,
uri: "ssh://git@example.test/repo.git".to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
materializer: MaterializerKind::RuntimeGitCache,
backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(),
credential_revision: 1,
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: repository_resource_handle(),
private_key: SensitiveString::new("private-key-bytes"),
known_hosts_entry: SensitiveString::new("known-hosts-entry"),
}),
}),
});
let durable = durable_create_worker_request(&request);
assert!(
request
.working_directory_request
.as_ref()
.and_then(|working_directory| working_directory.materialization.as_ref())
.and_then(|materialization| materialization.ssh.as_ref())
.is_some()
);
assert!(
durable
.working_directory_request
.as_ref()
.and_then(|working_directory| working_directory.materialization.as_ref())
.and_then(|materialization| materialization.ssh.as_ref())
.is_none()
);
let serialized = serde_json::to_string(&durable).unwrap();
assert!(!serialized.contains("private-key-bytes"));
assert!(!serialized.contains("known-hosts-entry"));
}
fn repository_resource_handle() -> crate::resource::BackendResourceHandle {
crate::resource::BackendResourceHandle {
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
workspace_id: "workspace-1".to_string(),
scope_id: Some("repository-ssh-access".to_string()),
runtime_id: Some("runtime-1".to_string()),
worker_id: None,
resource_id: "repository-access-1".to_string(),
digest: "opaque:repository-access-1".to_string(),
operation: crate::resource::BackendResourceOperation::FetchOnce,
expires_at_unix_seconds: i64::MAX,
nonce: "repository-access-1".to_string(),
revision: "1".to_string(),
generation: None,
max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
audit_correlation_id: "repository-access-1".to_string(),
profile_source_graph: None,
}
}
#[tokio::test]
async fn repository_access_resource_is_fetched_before_provider_authorization() {
let (runtime, backend) = runtime_and_backend();
backend
.repository_access_available
.store(true, Ordering::SeqCst);
runtime.bind_runtime_identity("runtime-1").unwrap();
let handle = repository_resource_handle();
runtime
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
resource_id: handle.resource_id.clone(),
digest: handle.digest.clone(),
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
private_key: "private-key-bytes".to_string(),
known_hosts_entry: "known-hosts-entry".to_string(),
})
.unwrap(),
audit_correlation_id: handle.audit_correlation_id.clone(),
})),
}))
.unwrap();
let request = WorkingDirectoryRepositoryAccessRequest {
working_directory_id: "working-directory-1".to_string(),
materialization: RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(),
credential_revision: 1,
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: handle,
private_key: SensitiveString::default(),
known_hosts_entry: SensitiveString::default(),
}),
},
};
let replay = request.clone();
runtime
.authorize_working_directory_repository_access_from_resource(request)
.await
.unwrap();
assert!(
runtime
.authorize_working_directory_repository_access_from_resource(replay)
.await
.is_err()
);
let accesses = backend.repository_accesses.lock().unwrap();
assert_eq!(accesses.len(), 1);
let access = accesses[0].materialization.ssh.as_ref().unwrap();
assert_eq!(access.private_key.expose(), "private-key-bytes");
assert_eq!(access.known_hosts_entry.expose(), "known-hosts-entry");
}
#[tokio::test]
async fn working_directory_create_fetches_repository_access_before_provider_call() {
let (runtime, backend) = runtime_and_backend();
backend
.repository_access_available
.store(true, Ordering::SeqCst);
runtime.bind_runtime_identity("runtime-1").unwrap();
let handle = repository_resource_handle();
runtime
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
resource_id: handle.resource_id.clone(),
digest: handle.digest.clone(),
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
private_key: "create-private-key-bytes".to_string(),
known_hosts_entry: "create-known-hosts-entry".to_string(),
})
.unwrap(),
audit_correlation_id: handle.audit_correlation_id.clone(),
})),
}))
.unwrap();
let request = WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: "repository-1".to_string(),
provider: "git".to_string(),
source: workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::Ssh,
uri: "ssh://git@example.test/repo.git".to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
materializer: MaterializerKind::RuntimeGitCache,
backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-create".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(),
credential_revision: 1,
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: handle,
private_key: SensitiveString::default(),
known_hosts_entry: SensitiveString::default(),
}),
}),
};
assert!(
runtime
.create_working_directory_from_resource(request)
.await
.is_err()
);
let requests = backend.working_directory_requests.lock().unwrap();
let access = requests[0]
.materialization
.as_ref()
.and_then(|materialization| materialization.ssh.as_ref())
.unwrap();
assert_eq!(access.private_key.expose(), "create-private-key-bytes");
assert_eq!(
access.known_hosts_entry.expose(),
"create-known-hosts-entry"
);
}
fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest { fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest {
let mut request = task_request(objective); let mut request = task_request(objective);
request.workspace_api = Some(WorkspaceApiRef { request.workspace_api = Some(WorkspaceApiRef {
@@ -3196,6 +3588,9 @@ mod tests {
config_bundles: Mutex<Vec<Option<ConfigBundle>>>, config_bundles: Mutex<Vec<Option<ConfigBundle>>>,
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>, contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
dispatched_inputs: Mutex<Vec<WorkerInput>>, dispatched_inputs: Mutex<Vec<WorkerInput>>,
repository_accesses: Mutex<Vec<WorkingDirectoryRepositoryAccessRequest>>,
repository_access_available: AtomicBool,
working_directory_requests: Mutex<Vec<WorkingDirectoryRequest>>,
preserve_commit_ack_submission_id: AtomicBool, preserve_commit_ack_submission_id: AtomicBool,
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
snapshots: Mutex<BTreeMap<WorkerId, protocol::Event>>, snapshots: Mutex<BTreeMap<WorkerId, protocol::Event>>,
@@ -3236,6 +3631,38 @@ mod tests {
"test-execution-backend" "test-execution-backend"
} }
fn create_working_directory(
&self,
request: &WorkingDirectoryRequest,
) -> Result<CatalogWorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
self.working_directory_requests
.lock()
.unwrap()
.push(request.clone());
Err(WorkingDirectoryDiagnostic::rejected(
"working_directory_unsupported",
"Worker execution backend does not support working directory materialization",
))
}
fn authorize_working_directory_repository_access(
&self,
request: &WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), WorkingDirectoryDiagnostic> {
self.repository_accesses
.lock()
.unwrap()
.push(request.clone());
if self.repository_access_available.load(Ordering::SeqCst) {
Ok(())
} else {
Err(WorkingDirectoryDiagnostic::rejected(
"working_directory_repository_access_unsupported",
"Worker execution backend does not support Repository access authorization",
))
}
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
self.run_generations self.run_generations
.lock() .lock()
@@ -3343,6 +3770,24 @@ mod tests {
} }
} }
struct TestRepositoryResourceClient {
response: Mutex<Option<crate::resource::BackendResourceFetchResponse>>,
}
#[async_trait]
impl BackendResourceClient for TestRepositoryResourceClient {
async fn fetch_resource(
&self,
_request: BackendResourceFetchRequest,
) -> Result<crate::resource::BackendResourceFetchResponse, BackendResourceError> {
self.response
.lock()
.unwrap()
.take()
.ok_or(BackendResourceError::MissingResource)
}
}
fn runtime_with_backend() -> Runtime { fn runtime_with_backend() -> Runtime {
let runtime = Runtime::with_execution_backend( let runtime = Runtime::with_execution_backend(
RuntimeOptions::default(), RuntimeOptions::default(),
+66 -25
View File
@@ -20,7 +20,7 @@ use crate::auth::{
}; };
use crate::catalog::{ use crate::catalog::{
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource, CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
}; };
use crate::execution::{ use crate::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
@@ -38,9 +38,7 @@ use crate::working_directory::{
}; };
use async_trait::async_trait; use async_trait::async_trait;
use protocol::{Event, Method, Segment, WorkerStatus}; use protocol::{Event, Method, Segment, WorkerStatus};
use session_store::{ use session_store::{CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore};
CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore, collect_state,
};
#[cfg(test)] #[cfg(test)]
use session_store::{FsStore, FsWorkerStore}; use session_store::{FsStore, FsWorkerStore};
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
@@ -68,8 +66,10 @@ const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
const USER_INPUT_COMMIT_TIMEOUT: Duration = Duration::from_secs(9); const USER_INPUT_COMMIT_TIMEOUT: Duration = Duration::from_secs(9);
fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool { fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
let LogEntry::UserInput { extensions, .. } = entry else { let extensions = match entry {
return false; LogEntry::UserInput { extensions, .. }
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
_ => return false,
}; };
extensions.iter().any(|extension| { extensions.iter().any(|extension| {
extension.domain == WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN extension.domain == WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN
@@ -212,11 +212,11 @@ impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider {
return Err(WorkerObservationError::NotFound); return Err(WorkerObservationError::NotFound);
} }
let entries = sink.subscribe_with_snapshot().0; let entries = sink.subscribe_with_snapshot().0;
let state = collect_state(&entries); WorkerSessionCapture::from_log_entries(
Ok(WorkerSessionCapture { format!("runtime:{runtime_id}:worker:{worker_id}"),
segment_id: format!("runtime:{runtime_id}:worker:{worker_id}"), &entries,
items: state.history, )
}) .map_err(WorkerObservationError::Unavailable)
} }
} }
@@ -706,13 +706,17 @@ fn runtime_local_workdir_session(
root: &Path, root: &Path,
cwd: &Path, cwd: &Path,
scope: manifest::SharedScope, scope: manifest::SharedScope,
command_environment: std::collections::BTreeMap<String, String>,
resources: Vec<Arc<dyn workdir::WorkdirSessionResource>>,
) -> WorkdirSessionHandle { ) -> WorkdirSessionHandle {
Arc::new(LocalWorkdirSession::materialized_bound( Arc::new(LocalWorkdirSession::materialized_bound_with_environment(
Workdir::new(workdir_id), Workdir::new(workdir_id),
root.to_path_buf(), root.to_path_buf(),
cwd.to_path_buf(), cwd.to_path_buf(),
scope, scope,
WorkdirSessionCapabilities::ALL, WorkdirSessionCapabilities::ALL,
command_environment,
resources,
)) ))
} }
@@ -893,6 +897,8 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
binding.root(), binding.root(),
binding.cwd(), binding.cwd(),
worker.scope().clone(), worker.scope().clone(),
binding.command_environment(),
binding.session_resources(),
))); )));
} else { } else {
worker.bind_workdir_session(None); worker.bind_workdir_session(None);
@@ -1071,6 +1077,8 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
binding.root(), binding.root(),
binding.cwd(), binding.cwd(),
worker.scope().clone(), worker.scope().clone(),
binding.command_environment(),
binding.session_resources(),
))); )));
} else { } else {
worker.bind_workdir_session(None); worker.bind_workdir_session(None);
@@ -1538,7 +1546,9 @@ fn accepted_notify_run_state(status: WorkerStatus, auto_run: bool) -> WorkerExec
match status { match status {
WorkerStatus::Running => WorkerExecutionRunState::Busy, WorkerStatus::Running => WorkerExecutionRunState::Busy,
WorkerStatus::Idle if auto_run => WorkerExecutionRunState::Busy, WorkerStatus::Idle if auto_run => WorkerExecutionRunState::Busy,
WorkerStatus::Idle | WorkerStatus::Paused => WorkerExecutionRunState::Idle, WorkerStatus::Idle | WorkerStatus::Paused | WorkerStatus::Stopped => {
WorkerExecutionRunState::Idle
}
} }
} }
@@ -1582,6 +1592,19 @@ where
Ok(materializer.create(request)?.status()) Ok(materializer.create(request)?.status())
} }
fn authorize_working_directory_repository_access(
&self,
request: &WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), WorkingDirectoryDiagnostic> {
let materializer = self.working_directory_materializer.as_ref().ok_or_else(|| {
WorkingDirectoryDiagnostic::rejected(
"working_directory_materializer_unavailable",
"working directory Repository access requested, but no materializer is configured for this runtime backend",
)
})?;
materializer.authorize_repository_access(request)
}
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> { fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
self.working_directory_materializer self.working_directory_materializer
.as_ref() .as_ref()
@@ -1624,6 +1647,8 @@ where
binding.root(), binding.root(),
binding.cwd(), binding.cwd(),
manifest::SharedScope::new(scope), manifest::SharedScope::new(scope),
binding.command_environment(),
binding.session_resources(),
)) ))
} }
@@ -2142,7 +2167,7 @@ mod tests {
use crate::identity::WorkerRef; use crate::identity::WorkerRef;
use crate::management::RuntimeOptions; use crate::management::RuntimeOptions;
use crate::observation::WorkerObservationCursor; use crate::observation::WorkerObservationCursor;
use crate::working_directory::LocalGitWorktreeMaterializer; use crate::working_directory::RuntimeGitCacheMaterializer;
use agen::Engine; use agen::Engine;
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent}; use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request}; use agen::llm_client::{ClientError, LlmClient, Request};
@@ -2482,7 +2507,9 @@ mod tests {
let scope = Scope::writable(&scope_root).map_err(|err| err.to_string())?; let scope = Scope::writable(&scope_root).map_err(|err| err.to_string())?;
let worker = Worker::new( let worker = Worker::new(
manifest, manifest,
Engine::new(self.client.clone()), Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
self.client.clone(),
),
store, store,
workspace_context, workspace_context,
filesystem_authority, filesystem_authority,
@@ -2728,8 +2755,9 @@ mod tests {
source_fingerprint: "sha256:test".to_string(), source_fingerprint: "sha256:test".to_string(),
selector: Some(RepositorySelector::from("HEAD")), selector: Some(RepositorySelector::from("HEAD")),
}, },
materializer: MaterializerKind::LocalGitWorktree, materializer: MaterializerKind::RuntimeGitCache,
backend_workdir_id: None, backend_workdir_id: None,
materialization: None,
} }
} }
@@ -2853,12 +2881,16 @@ mod tests {
root.path(), root.path(),
root.path(), root.path(),
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()), manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
Default::default(),
Vec::new(),
); );
let restored = runtime_local_workdir_session( let restored = runtime_local_workdir_session(
"working-directory-42", "working-directory-42",
root.path(), root.path(),
root.path(), root.path(),
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()), manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
Default::default(),
Vec::new(),
); );
assert_eq!(spawned.workdir().id().as_str(), "working-directory-42"); assert_eq!(spawned.workdir().id().as_str(), "working-directory-42");
@@ -3213,14 +3245,17 @@ mod tests {
matches!( matches!(
entry, entry,
LogEntry::UserInput { segments, .. } LogEntry::UserInput { segments, .. }
| LogEntry::AnnotatedUserInput { segments, .. }
if segments == &vec![Segment::text("start the ticket")] if segments == &vec![Segment::text("start the ticket")]
) )
})); }));
let submission_id = entries let submission_id = entries
.iter() .iter()
.find_map(|entry| { .find_map(|entry| {
let LogEntry::UserInput { extensions, .. } = entry else { let extensions = match entry {
return None; LogEntry::UserInput { extensions, .. }
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
_ => return None,
}; };
extensions extensions
.iter() .iter()
@@ -3330,7 +3365,7 @@ mod tests {
}; };
let backend = WorkerRuntimeExecutionBackend::new(factory) let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap() .unwrap()
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new( .with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(), runtime_base.path(),
)); ));
let runtime = let runtime =
@@ -3485,7 +3520,7 @@ mod tests {
}; };
let backend = WorkerRuntimeExecutionBackend::new(factory) let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap() .unwrap()
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new( .with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(), runtime_base.path(),
)); ));
let runtime = let runtime =
@@ -3524,7 +3559,7 @@ mod tests {
let repo = create_clean_repo(); let repo = create_clean_repo();
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory) let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
.unwrap() .unwrap()
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new( .with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(), runtime_base.path(),
)); ));
let runtime = let runtime =
@@ -3560,7 +3595,7 @@ mod tests {
let repo = create_clean_repo(); let repo = create_clean_repo();
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory) let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
.unwrap() .unwrap()
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new( .with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(), runtime_base.path(),
)); ));
let runtime = let runtime =
@@ -3574,9 +3609,15 @@ mod tests {
assert!(format!("{error:?}").contains("spawn failed")); assert!(format!("{error:?}").contains("spawn failed"));
let working_directories_root = runtime_base.path(); let working_directories_root = runtime_base.path();
let remaining_entries = fs::read_dir(working_directories_root) let remaining_workdirs = fs::read_dir(working_directories_root)
.map(|entries| entries.count()) .map(|entries| {
entries
.flatten()
.filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
.count()
})
.unwrap_or(0); .unwrap_or(0);
assert_eq!(remaining_entries, 0); assert_eq!(remaining_workdirs, 0);
assert!(working_directories_root.join(".repository-cache").is_dir());
} }
} }
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -66,11 +66,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
WorkerRunResult::Finished => println!("(finished)"), WorkerRunResult::Finished => println!("(finished)"),
WorkerRunResult::Paused => println!("(paused)"), WorkerRunResult::Paused => println!("(paused)"),
WorkerRunResult::LimitReached => println!("(turn limit reached)"), WorkerRunResult::LimitReached => println!("(turn limit reached)"),
WorkerRunResult::Interrupted { message, .. } => println!("(interrupted: {message})"),
WorkerRunResult::RolledBack => println!("(empty turn rolled back)"), WorkerRunResult::RolledBack => println!("(empty turn rolled back)"),
} }
// 5. Extract the assistant's reply from history // 5. Extract the assistant's reply from history
let history = worker.engine().history(); let history = worker.history();
if let Some(text) = history if let Some(text) = history
.iter() .iter()
.rev() .rev()
+1 -1
View File
@@ -22,7 +22,7 @@ use crate::compact::token_counter::{
EstimateSource, savings_for_prune_impl, token_estimates_for_prune_impl, EstimateSource, savings_for_prune_impl, token_estimates_for_prune_impl,
}; };
impl<C: LlmClient, St: Store> Worker<C, St> { impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
/// Enable prune projection on the underlying Engine. /// Enable prune projection on the underlying Engine.
/// ///
/// Registers the config and token/savings-estimator closures on the Engine. /// Registers the config and token/savings-estimator closures on the Engine.
+4 -4
View File
@@ -242,13 +242,13 @@ pub(crate) fn savings_for_prune_impl(
// ── Worker に生やす公開 API ─────────────────────────────────────────────── // ── Worker に生やす公開 API ───────────────────────────────────────────────
impl<C: LlmClient, St: Store> Worker<C, St> { impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
/// 現在の history 全体の推定トークン数。 /// 現在の history 全体の推定トークン数。
/// ///
/// 最後の measurement と、その後に追加された未測定分の byte/4 外挿。 /// 最後の measurement と、その後に追加された未測定分の byte/4 外挿。
pub fn total_tokens(&self) -> TokenEstimate { pub fn total_tokens(&self) -> TokenEstimate {
let usage = self.usage_history(); let usage = self.usage_history();
agen::token_counter::total_tokens(self.history(), &usage) agen::token_counter::total_tokens(&self.history(), &usage)
} }
/// 任意の history index 時点でのプロンプト全長推定。 /// 任意の history index 時点でのプロンプト全長推定。
@@ -259,7 +259,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
/// pointer 以降に増えたプロンプト長を測るのに使う。 /// pointer 以降に増えたプロンプト長を測るのに使う。
pub fn total_tokens_at(&self, history_len: usize) -> TokenEstimate { pub fn total_tokens_at(&self, history_len: usize) -> TokenEstimate {
let usage = self.usage_history(); let usage = self.usage_history();
agen::token_counter::total_tokens_at(self.history(), &usage, history_len) agen::token_counter::total_tokens_at(&self.history(), &usage, history_len)
} }
/// 末尾から `retained` トークン以上を残すための分割位置。 /// 末尾から `retained` トークン以上を残すための分割位置。
@@ -267,7 +267,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
/// `history[..cut.index]` が要約/破棄される側、`history[cut.index..]` が残る側。 /// `history[..cut.index]` が要約/破棄される側、`history[cut.index..]` が残る側。
pub fn split_for_retained(&self, retained: u64) -> SplitPoint { pub fn split_for_retained(&self, retained: u64) -> SplitPoint {
let usage = self.usage_history(); let usage = self.usage_history();
split_for_retained_impl(self.history(), &usage, retained) split_for_retained_impl(&self.history(), &usage, retained)
} }
} }
+180 -13
View File
@@ -485,6 +485,7 @@ impl WorkerController {
// into the controller task so the in-flight turn can be reached // into the controller task so the in-flight turn can be reached
// via these handles while worker itself is borrowed by drive_turn. // via these handles while worker itself is borrowed by drive_turn.
let cancel_tx = worker.engine_mut().cancel_sender(); let cancel_tx = worker.engine_mut().cancel_sender();
let pause_tx = worker.engine_mut().pause_sender();
let notify_buffer = worker.notify_buffer_handle(); let notify_buffer = worker.notify_buffer_handle();
tokio::spawn(controller_loop( tokio::spawn(controller_loop(
@@ -494,6 +495,7 @@ impl WorkerController {
shared_state, shared_state,
runtime_dir, runtime_dir,
cancel_tx, cancel_tx,
pause_tx,
notify_buffer, notify_buffer,
self_parent_socket, self_parent_socket,
spawner_name, spawner_name,
@@ -763,6 +765,19 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
id: result.tool_use_id.clone(), id: result.tool_use_id.clone(),
summary: result.summary.clone(), summary: result.summary.clone(),
output: result.content.clone(), output: result.content.clone(),
disposition: Some(match result.disposition {
agen::ToolResultDisposition::Success => protocol::ToolResultDisposition::Success,
agen::ToolResultDisposition::Error => protocol::ToolResultDisposition::Error,
agen::ToolResultDisposition::Interrupted => {
protocol::ToolResultDisposition::Interrupted
}
agen::ToolResultDisposition::Cancelled => {
protocol::ToolResultDisposition::Cancelled
}
agen::ToolResultDisposition::OutcomeUnknown => {
protocol::ToolResultDisposition::OutcomeUnknown
}
}),
is_error: result.is_error, is_error: result.is_error,
}); });
}); });
@@ -983,6 +998,14 @@ where
if feature_config.sub_worker.enabled { if feature_config.sub_worker.enabled {
worker.register_worker_orchestration_instruction(); worker.register_worker_orchestration_instruction();
if !feature_config.worker.enabled {
feature_registry.add_module(
crate::feature::builtin::manage_worker::sub_worker_control_feature(
worker.workspace_client_handle(),
spawned_registry.clone(),
),
);
}
} }
let host_worker_observation_provider = worker.worker_observation_provider(); let host_worker_observation_provider = worker.worker_observation_provider();
@@ -1115,6 +1138,7 @@ async fn controller_loop<C, St>(
shared_state: Arc<WorkerSharedState>, shared_state: Arc<WorkerSharedState>,
runtime_dir: Arc<RuntimeDir>, runtime_dir: Arc<RuntimeDir>,
cancel_tx: mpsc::Sender<()>, cancel_tx: mpsc::Sender<()>,
pause_tx: mpsc::Sender<()>,
notify_buffer: NotifyBuffer, notify_buffer: NotifyBuffer,
self_parent_socket: Option<PathBuf>, self_parent_socket: Option<PathBuf>,
spawner_name: String, spawner_name: String,
@@ -1161,6 +1185,9 @@ async fn controller_loop<C, St>(
// clear at run start prevents stale partial output left by an older // clear at run start prevents stale partial output left by an older
// interrupted/error turn from being carried into the next snapshot. // interrupted/error turn from being carried into the next snapshot.
worker.clear_in_flight_events(); worker.clear_in_flight_events();
let parent_originated = run.is_parent_originated();
let user_input_run = matches!(&run, PendingRun::Run(_) | PendingRun::RunTracked { .. });
if !user_input_run {
set_controller_status( set_controller_status(
&shared_state, &shared_state,
&runtime_dir, &runtime_dir,
@@ -1168,15 +1195,25 @@ async fn controller_loop<C, St>(
WorkerStatus::Running, WorkerStatus::Running,
) )
.await; .await;
let parent_originated = run.is_parent_originated(); }
let (mut new_status, shutdown) = match run { let (mut new_status, shutdown) = match run {
PendingRun::Run(input) => { PendingRun::Run(input) => {
let (input_commit_tx, input_commit_rx) = oneshot::channel();
drive_turn( drive_turn(
worker.run(input), worker.run_with_input_extensions_and_commit_hook(
input,
Vec::new(),
move || {
let _ = input_commit_tx.send(());
},
),
&mut method_rx, &mut method_rx,
&event_tx, &event_tx,
&cancel_tx, &cancel_tx,
&pause_tx,
&shared_state, &shared_state,
&runtime_dir,
Some(input_commit_rx),
&notify_buffer, &notify_buffer,
self_parent_socket.as_ref(), self_parent_socket.as_ref(),
&spawner_name, &spawner_name,
@@ -1186,12 +1223,22 @@ async fn controller_loop<C, St>(
.await .await
} }
PendingRun::RunTracked { input, extension } => { PendingRun::RunTracked { input, extension } => {
let (input_commit_tx, input_commit_rx) = oneshot::channel();
drive_turn( drive_turn(
worker.run_with_input_extensions(input, vec![extension]), worker.run_with_input_extensions_and_commit_hook(
input,
vec![extension],
move || {
let _ = input_commit_tx.send(());
},
),
&mut method_rx, &mut method_rx,
&event_tx, &event_tx,
&cancel_tx, &cancel_tx,
&pause_tx,
&shared_state, &shared_state,
&runtime_dir,
Some(input_commit_rx),
&notify_buffer, &notify_buffer,
self_parent_socket.as_ref(), self_parent_socket.as_ref(),
&spawner_name, &spawner_name,
@@ -1206,7 +1253,10 @@ async fn controller_loop<C, St>(
&mut method_rx, &mut method_rx,
&event_tx, &event_tx,
&cancel_tx, &cancel_tx,
&pause_tx,
&shared_state, &shared_state,
&runtime_dir,
None,
&notify_buffer, &notify_buffer,
self_parent_socket.as_ref(), self_parent_socket.as_ref(),
&spawner_name, &spawner_name,
@@ -1221,7 +1271,10 @@ async fn controller_loop<C, St>(
&mut method_rx, &mut method_rx,
&event_tx, &event_tx,
&cancel_tx, &cancel_tx,
&pause_tx,
&shared_state, &shared_state,
&runtime_dir,
None,
&notify_buffer, &notify_buffer,
self_parent_socket.as_ref(), self_parent_socket.as_ref(),
&spawner_name, &spawner_name,
@@ -1346,7 +1399,7 @@ async fn controller_loop<C, St>(
}); });
} }
}, },
WorkerStatus::Idle => { WorkerStatus::Idle | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error { let _ = event_tx.send(Event::Error {
code: ErrorCode::NotRunning, code: ErrorCode::NotRunning,
message: "Worker is not running".into(), message: "Worker is not running".into(),
@@ -1387,7 +1440,7 @@ async fn controller_loop<C, St>(
.into(), .into(),
}); });
} }
WorkerStatus::Running => { WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error { let _ = event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning, code: ErrorCode::AlreadyRunning,
message: message:
@@ -1401,7 +1454,7 @@ async fn controller_loop<C, St>(
WorkerStatus::Idle | WorkerStatus::Paused => { WorkerStatus::Idle | WorkerStatus::Paused => {
emit_rewind_targets(&worker, &event_tx) emit_rewind_targets(&worker, &event_tx)
} }
WorkerStatus::Running => { WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error { let _ = event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning, code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind can only run while idle or paused" message: "Worker is already executing a turn; rewind can only run while idle or paused"
@@ -1430,7 +1483,7 @@ async fn controller_loop<C, St>(
.into(), .into(),
}); });
} }
WorkerStatus::Running => { WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error { let _ = event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning, code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind can only run while idle or paused" message: "Worker is already executing a turn; rewind can only run while idle or paused"
@@ -1618,7 +1671,10 @@ async fn drive_turn<F>(
method_rx: &mut mpsc::Receiver<Method>, method_rx: &mut mpsc::Receiver<Method>,
event_tx: &broadcast::Sender<Event>, event_tx: &broadcast::Sender<Event>,
cancel_tx: &mpsc::Sender<()>, cancel_tx: &mpsc::Sender<()>,
pause_tx: &mpsc::Sender<()>,
shared_state: &Arc<WorkerSharedState>, shared_state: &Arc<WorkerSharedState>,
runtime_dir: &RuntimeDir,
mut input_commit_rx: Option<oneshot::Receiver<()>>,
notify_buffer: &NotifyBuffer, notify_buffer: &NotifyBuffer,
parent_socket: Option<&PathBuf>, parent_socket: Option<&PathBuf>,
self_name: &str, self_name: &str,
@@ -1634,14 +1690,58 @@ where
loop { loop {
tokio::select! { tokio::select! {
// If input commit and provider completion become ready together, expose
// Running only after processing the commit fence. This makes the
// Running snapshot contract deterministic even for immediate clients.
biased;
committed = async {
input_commit_rx
.as_mut()
.expect("input commit receiver guarded by select condition")
.await
}, if input_commit_rx.is_some() => {
input_commit_rx = None;
if committed.is_ok() {
set_controller_status(
shared_state,
runtime_dir,
event_tx,
WorkerStatus::Running,
)
.await;
}
}
result = &mut worker_future => { result = &mut worker_future => {
return match result { return match result {
Ok(r) => { Ok(r) => {
let (status, run_result) = match r { let (status, run_result) = match r {
WorkerRunResult::Finished if pause_requested => {
(WorkerStatus::Paused, RunResult::Paused)
}
WorkerRunResult::Finished => (WorkerStatus::Idle, RunResult::Finished), WorkerRunResult::Finished => (WorkerStatus::Idle, RunResult::Finished),
WorkerRunResult::Paused => (WorkerStatus::Paused, RunResult::Paused), WorkerRunResult::Paused => (WorkerStatus::Paused, RunResult::Paused),
WorkerRunResult::LimitReached => (WorkerStatus::Idle, RunResult::LimitReached), WorkerRunResult::LimitReached => (WorkerStatus::Idle, RunResult::LimitReached),
WorkerRunResult::RolledBack => (WorkerStatus::Idle, RunResult::RolledBack), WorkerRunResult::RolledBack => (WorkerStatus::Idle, RunResult::RolledBack),
WorkerRunResult::Interrupted { .. } if pause_requested => {
let _ = event_tx.send(Event::RunEnd { result: RunResult::Paused });
return (WorkerStatus::Paused, shutdown_requested);
}
WorkerRunResult::Interrupted { code, message } => {
let _ = event_tx.send(Event::Error {
code,
message: message.clone(),
});
if parent_originated {
crate::ipc::event::fire_and_forget(
parent_socket.cloned(),
protocol::WorkerEvent::Errored {
worker_name: self_name.to_string(),
message,
},
);
}
return (WorkerStatus::Idle, shutdown_requested);
}
}; };
let _ = event_tx.send(Event::RunEnd { result: run_result }); let _ = event_tx.send(Event::RunEnd { result: run_result });
if parent_originated && matches!(run_result, RunResult::Finished) { if parent_originated && matches!(run_result, RunResult::Finished) {
@@ -1690,7 +1790,7 @@ where
} }
Some(Method::Pause) => { Some(Method::Pause) => {
pause_requested = true; pause_requested = true;
let _ = cancel_tx.try_send(()); let _ = pause_tx.try_send(());
} }
Some(Method::Shutdown) => { Some(Method::Shutdown) => {
shutdown_requested = true; shutdown_requested = true;
@@ -1752,7 +1852,7 @@ where
fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, event_tx: &broadcast::Sender<Event>) fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, event_tx: &broadcast::Sender<Event>)
where where
C: LlmClient, C: LlmClient + 'static,
St: Store, St: Store,
{ {
match worker.list_rewind_targets() { match worker.list_rewind_targets() {
@@ -1778,7 +1878,7 @@ fn apply_rewind<C, St>(
expected_head_entries: usize, expected_head_entries: usize,
) -> bool ) -> bool
where where
C: LlmClient, C: LlmClient + 'static,
St: Store, St: Store,
{ {
match worker.rewind_to(target, expected_head_entries) { match worker.rewind_to(target, expected_head_entries) {
@@ -1826,7 +1926,7 @@ fn model_supports_image_attachments(model: &manifest::ModelManifest) -> bool {
fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting
where where
C: LlmClient, C: LlmClient + 'static,
St: Store, St: Store,
{ {
let manifest = worker.manifest(); let manifest = worker.manifest();
@@ -1942,11 +2042,13 @@ mod tests {
event_tx: broadcast::Sender<Event>, event_tx: broadcast::Sender<Event>,
cancel_tx: mpsc::Sender<()>, cancel_tx: mpsc::Sender<()>,
_cancel_rx: mpsc::Receiver<()>, _cancel_rx: mpsc::Receiver<()>,
pause_tx: mpsc::Sender<()>,
_pause_rx: mpsc::Receiver<()>,
shared_state: Arc<WorkerSharedState>, shared_state: Arc<WorkerSharedState>,
notify_buffer: NotifyBuffer, notify_buffer: NotifyBuffer,
spawned_registry: Arc<SpawnedWorkerRegistry>, spawned_registry: Arc<SpawnedWorkerRegistry>,
parent_socket_path: PathBuf, parent_socket_path: PathBuf,
_runtime_dir: Arc<RuntimeDir>, runtime_dir: Arc<RuntimeDir>,
_temp: TempDir, _temp: TempDir,
} }
@@ -1960,6 +2062,7 @@ mod tests {
let (method_tx, method_rx) = mpsc::channel::<Method>(16); let (method_tx, method_rx) = mpsc::channel::<Method>(16);
let (event_tx, _) = broadcast::channel::<Event>(16); let (event_tx, _) = broadcast::channel::<Event>(16);
let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1); let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1);
let (pause_tx, pause_rx) = mpsc::channel::<()>(1);
let shared_state = Arc::new(WorkerSharedState::new( let shared_state = Arc::new(WorkerSharedState::new(
"child-worker".to_string(), "child-worker".to_string(),
session_store::new_segment_id(), session_store::new_segment_id(),
@@ -1985,11 +2088,13 @@ mod tests {
event_tx, event_tx,
cancel_tx, cancel_tx,
_cancel_rx: cancel_rx, _cancel_rx: cancel_rx,
pause_tx,
_pause_rx: pause_rx,
shared_state, shared_state,
notify_buffer, notify_buffer,
spawned_registry, spawned_registry,
parent_socket_path, parent_socket_path,
_runtime_dir: runtime_dir, runtime_dir,
_temp: temp, _temp: temp,
} }
} }
@@ -2042,7 +2147,10 @@ mod tests {
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx,
&env.shared_state, &env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer, &env.notify_buffer,
Some(&env.parent_socket_path), Some(&env.parent_socket_path),
"child-worker", "child-worker",
@@ -2063,6 +2171,44 @@ mod tests {
} }
} }
#[tokio::test]
async fn pause_waits_for_run_boundary_and_uses_safe_pause_channel() {
let mut env = make_env().await;
let method_tx = env._method_tx.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await;
method_tx.send(Method::Pause).await.expect("send pause");
});
let worker_future = async {
tokio::time::sleep(Duration::from_millis(100)).await;
Ok::<_, WorkerError>(WorkerRunResult::Finished)
};
let started_at = std::time::Instant::now();
let (status, shutdown) = drive_turn(
worker_future,
&mut env.method_rx,
&env.event_tx,
&env.cancel_tx,
&env.pause_tx,
&env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer,
None,
"child-worker",
&env.spawned_registry,
true,
)
.await;
assert_eq!(status, WorkerStatus::Paused);
assert!(!shutdown);
assert!(started_at.elapsed() >= Duration::from_millis(100));
assert!(env._pause_rx.try_recv().is_ok());
assert!(env._cancel_rx.try_recv().is_err());
}
#[tokio::test] #[tokio::test]
async fn non_parent_originated_finished_stays_silent() { async fn non_parent_originated_finished_stays_silent() {
let mut env = make_env().await; let mut env = make_env().await;
@@ -2074,7 +2220,10 @@ mod tests {
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx,
&env.shared_state, &env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer, &env.notify_buffer,
Some(&env.parent_socket_path), Some(&env.parent_socket_path),
"child-worker", "child-worker",
@@ -2109,7 +2258,10 @@ mod tests {
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx,
&env.shared_state, &env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer, &env.notify_buffer,
Some(&env.parent_socket_path), Some(&env.parent_socket_path),
"child-worker", "child-worker",
@@ -2150,7 +2302,10 @@ mod tests {
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx,
&env.shared_state, &env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer, &env.notify_buffer,
Some(&env.parent_socket_path), Some(&env.parent_socket_path),
"child-worker", "child-worker",
@@ -2189,7 +2344,10 @@ mod tests {
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx,
&env.shared_state, &env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer, &env.notify_buffer,
Some(&env.parent_socket_path), Some(&env.parent_socket_path),
"parent", "parent",
@@ -2225,7 +2383,10 @@ mod tests {
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx,
&env.shared_state, &env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer, &env.notify_buffer,
Some(&env.parent_socket_path), Some(&env.parent_socket_path),
"parent", "parent",
@@ -2259,7 +2420,10 @@ mod tests {
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx,
&env.shared_state, &env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer, &env.notify_buffer,
Some(&env.parent_socket_path), Some(&env.parent_socket_path),
"parent", "parent",
@@ -2292,7 +2456,10 @@ mod tests {
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx,
&env.shared_state, &env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer, &env.notify_buffer,
Some(&env.parent_socket_path), Some(&env.parent_socket_path),
"child-worker", "child-worker",
+2 -2
View File
@@ -1795,9 +1795,9 @@ impl FeatureRegistryBuilder {
} }
/// Install modules into the existing Engine tool path and hook builder. /// Install modules into the existing Engine tool path and hook builder.
pub(crate) fn install_into_engine<C: LlmClient>( pub(crate) fn install_into_engine<C: LlmClient, A>(
self, self,
worker: &mut Engine<C, Mutable>, worker: &mut Engine<C, Mutable, A>,
hook_builder: &mut HookRegistryBuilder, hook_builder: &mut HookRegistryBuilder,
) -> FeatureRegistryInstallReport { ) -> FeatureRegistryInstallReport {
let mut pending_tools = Vec::new(); let mut pending_tools = Vec::new();
+1
View File
@@ -12,6 +12,7 @@ pub mod memory_extract;
pub mod merge_request; pub mod merge_request;
pub mod objective; pub mod objective;
pub mod orchestration; pub mod orchestration;
mod resource_projection;
pub mod session_explore; pub mod session_explore;
pub mod task; pub mod task;
pub mod ticket; pub mod ticket;
@@ -6,7 +6,9 @@ use memory::backend::{
MemoryBackendOperation, MemoryBackendOperationResult, MemoryStageCandidateOperation, MemoryBackendOperation, MemoryBackendOperationResult, MemoryStageCandidateOperation,
}; };
use memory::extract::{CandidateKind, ExtractedCandidate, StagingEvidence}; use memory::extract::{CandidateKind, ExtractedCandidate, StagingEvidence};
use memory::schema::{EvidenceKind, SourceEvidenceRef, SourceRef}; use memory::schema::{
EvidenceKind, EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef, SourceRef,
};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::Deserialize; use serde::Deserialize;
@@ -174,17 +176,29 @@ impl Tool for StageMemoryCandidateTool {
"StageMemoryCandidate requires at least one entry_ref".to_string(), "StageMemoryCandidate requires at least one entry_ref".to_string(),
)); ));
} }
let mut evidence = Vec::with_capacity(params.entry_refs.len()); let mut entries = Vec::with_capacity(params.entry_refs.len());
let mut source_refs = Vec::with_capacity(params.entry_refs.len());
for entry_ref in &params.entry_refs { for entry_ref in &params.entry_refs {
let projection = self.state.view.evidence_for(entry_ref).ok_or_else(|| { entries.push(self.state.view.evidence_for(entry_ref).ok_or_else(|| {
ToolError::InvalidArgument(format!( ToolError::InvalidArgument(format!(
"unknown SessionEntryRef {entry_ref:?} for this extraction capture" "unknown SessionEntryRef {entry_ref:?} for this extraction capture"
)) ))
})?; })?);
evidence.push(staging_evidence(&projection));
source_refs.push(source_evidence_ref(&projection));
} }
if matches!(params.kind, CandidateKind::Preference)
&& entries.iter().any(|entry| {
!matches!(
entry.origin,
crate::WorkerHistoryProvenance::HumanInput { .. }
)
})
{
return Err(ToolError::InvalidArgument(
"preference candidates require exclusively HumanInput evidence; model, Worker, Flow, backend, derived, and legacy-unknown origins are not preference authority"
.to_string(),
));
}
let evidence = entries.iter().map(staging_evidence).collect();
let source_refs = entries.iter().map(source_evidence_ref).collect();
let candidate = ExtractedCandidate { let candidate = ExtractedCandidate {
kind: params.kind, kind: params.kind,
claim: params.claim, claim: params.claim,
@@ -310,11 +324,65 @@ fn evidence_kind(entry: &SessionEntryEvidence) -> EvidenceKind {
} }
} }
fn evidence_origin(origin: &crate::WorkerHistoryProvenance) -> EvidenceOrigin {
use crate::WorkerHistoryProvenance as Origin;
let mut evidence = EvidenceOrigin {
kind: EvidenceOriginKind::LegacyUnknown,
account_id: None,
workspace_id: None,
runtime_id: None,
worker_id: None,
flow_selector: None,
flow_definition_id: None,
flow_definition_revision: None,
};
match origin {
Origin::HumanInput { account_id } => {
evidence.kind = EvidenceOriginKind::HumanInput;
evidence.account_id = Some(account_id.clone());
}
Origin::WorkerInput { actor } => {
evidence.kind = EvidenceOriginKind::WorkerInput;
evidence.workspace_id = actor.workspace_id.clone();
evidence.runtime_id = actor.runtime_id.clone();
evidence.worker_id = Some(actor.worker_id.clone());
}
Origin::FlowInstruction {
selector,
definition_id,
definition_revision,
..
} => {
evidence.kind = EvidenceOriginKind::FlowInstruction;
evidence.flow_selector = Some(selector.clone());
evidence.flow_definition_id = Some(definition_id.clone());
evidence.flow_definition_revision = Some(*definition_revision);
}
Origin::BackendInstruction { .. } => evidence.kind = EvidenceOriginKind::BackendInstruction,
Origin::ModelOutput { worker } => {
evidence.kind = EvidenceOriginKind::ModelOutput;
evidence.workspace_id = worker.workspace_id.clone();
evidence.runtime_id = worker.runtime_id.clone();
evidence.worker_id = Some(worker.worker_id.clone());
}
Origin::ToolOutput { worker } => {
evidence.kind = EvidenceOriginKind::ToolOutput;
evidence.workspace_id = worker.workspace_id.clone();
evidence.runtime_id = worker.runtime_id.clone();
evidence.worker_id = Some(worker.worker_id.clone());
}
Origin::DerivedSummary => evidence.kind = EvidenceOriginKind::DerivedSummary,
Origin::LegacyUnknown => evidence.kind = EvidenceOriginKind::LegacyUnknown,
}
evidence
}
fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence { fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
StagingEvidence { StagingEvidence {
id: entry.entry_ref.to_string(), id: entry.entry_ref.to_string(),
kind: evidence_kind(entry), kind: evidence_kind(entry),
entry_range: Some(entry.entry_range), entry_range: Some(entry.entry_range),
origin: Some(evidence_origin(&entry.origin)),
excerpt: Some(entry.excerpt.clone()), excerpt: Some(entry.excerpt.clone()),
summary: Some(entry.summary.clone()), summary: Some(entry.summary.clone()),
} }
@@ -325,6 +393,7 @@ fn source_evidence_ref(entry: &SessionEntryEvidence) -> SourceEvidenceRef {
segment_id: Some(entry.segment_id.clone()), segment_id: Some(entry.segment_id.clone()),
entry_range: Some(entry.entry_range), entry_range: Some(entry.entry_range),
evidence_id: Some(entry.entry_ref.to_string()), evidence_id: Some(entry.entry_ref.to_string()),
origin: Some(evidence_origin(&entry.origin)),
evidence_kind: Some(evidence_kind(entry)), evidence_kind: Some(evidence_kind(entry)),
label: Some(entry.label.clone()), label: Some(entry.label.clone()),
summary: Some(entry.summary.clone()), summary: Some(entry.summary.clone()),
@@ -432,6 +501,15 @@ mod tests {
assert!(input.contains("StageMemoryCandidate.entry_refs")); assert!(input.contains("StageMemoryCandidate.entry_refs"));
} }
#[test]
fn human_origin_projects_account_authority_into_evidence() {
let origin = evidence_origin(&crate::WorkerHistoryProvenance::HumanInput {
account_id: "account-1".into(),
});
assert_eq!(origin.kind, EvidenceOriginKind::HumanInput);
assert_eq!(origin.account_id.as_deref(), Some("account-1"));
}
#[test] #[test]
fn backend_input_failures_remain_invalid_argument_tool_errors() { fn backend_input_failures_remain_invalid_argument_tool_errors() {
let backend = map_memory_stage_error(WorkspaceMemoryBackendError::Backend( let backend = map_memory_stage_error(WorkspaceMemoryBackendError::Backend(
@@ -445,6 +523,19 @@ mod tests {
assert!(matches!(http, ToolError::InvalidArgument(_))); assert!(matches!(http, ToolError::InvalidArgument(_)));
} }
#[tokio::test]
async fn preference_rejects_legacy_unknown_before_backend_mutation() {
let tool = StageMemoryCandidateTool { state: state() };
let error = tool
.execute(
r#"{"kind":"preference","claim":"claim","why_useful":"useful","entry_refs":["E00000000"]}"#,
agen::tool::ToolExecutionContext::direct(),
)
.await
.unwrap_err();
assert!(format!("{error:?}").contains("exclusively HumanInput evidence"));
}
#[tokio::test] #[tokio::test]
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() { async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
let tool = StageMemoryCandidateTool { state: state() }; let tool = StageMemoryCandidateTool { state: state() };
+210 -25
View File
@@ -14,6 +14,8 @@ use serde_json::json;
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use super::resource_projection::{project_objective_detail, project_objective_query};
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct WorkspaceHttpObjectiveBackend { pub struct WorkspaceHttpObjectiveBackend {
client: Arc<dyn WorkspaceClient>, client: Arc<dyn WorkspaceClient>,
@@ -37,6 +39,7 @@ impl WorkspaceHttpObjectiveBackend {
) )
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
let response = project_objective_query(response).map_err(ToolError::ExecutionFailed)?;
Ok(ToolOutput { Ok(ToolOutput {
summary: "Queried Objectives".to_string(), summary: "Queried Objectives".to_string(),
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
@@ -58,8 +61,10 @@ impl WorkspaceHttpObjectiveBackend {
) )
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
let response = project_objective_detail(response).map_err(ToolError::ExecutionFailed)?;
let objective_ref = response.objective_ref().to_string();
Ok(ToolOutput { Ok(ToolOutput {
summary: format!("Read objective {id}"), summary: format!("Read objective {objective_ref}"),
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
attachments: Vec::new(), attachments: Vec::new(),
}) })
@@ -84,7 +89,7 @@ impl WorkspaceHttpObjectiveBackend {
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
Ok(objective_output( Ok(objective_output(
format!("Created objective {}", response.id), format!("Created objective {}", &response.resource_key),
response, response,
)?) )?)
} }
@@ -112,7 +117,7 @@ impl WorkspaceHttpObjectiveBackend {
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
Ok(objective_output( Ok(objective_output(
format!("Edited objective {}", response.id), format!("Edited objective {}", &response.resource_key),
response, response,
)?) )?)
} }
@@ -134,7 +139,7 @@ impl WorkspaceHttpObjectiveBackend {
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
Ok(objective_output( Ok(objective_output(
format!("Updated objective {} state", response.id), format!("Updated objective {} state", &response.resource_key),
response, response,
)?) )?)
} }
@@ -142,6 +147,7 @@ impl WorkspaceHttpObjectiveBackend {
async fn link_ticket(&self, input: ObjectiveLinkTicketInput) -> Result<ToolOutput, ToolError> { async fn link_ticket(&self, input: ObjectiveLinkTicketInput) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ObjectiveLinkTicket")?; let id = validate_id(&input.id, "ObjectiveLinkTicket")?;
let ticket_id = validate_id(&input.ticket_id, "ObjectiveLinkTicket")?; let ticket_id = validate_id(&input.ticket_id, "ObjectiveLinkTicket")?;
let ticket_resource_key = self.ticket_resource_key(ticket_id).await?;
let url = format!("{}/ticket-links", self.objective_url(id)); let url = format!("{}/ticket-links", self.objective_url(id));
let response = send_json::<ObjectiveLinkTicketRequest, ObjectiveDetail>( let response = send_json::<ObjectiveLinkTicketRequest, ObjectiveDetail>(
self.client.as_ref(), self.client.as_ref(),
@@ -154,7 +160,10 @@ impl WorkspaceHttpObjectiveBackend {
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
Ok(objective_output( Ok(objective_output(
format!("Linked ticket {ticket_id} to objective {}", response.id), format!(
"Linked ticket {ticket_resource_key} to objective {}",
&response.resource_key
),
response, response,
)?) )?)
} }
@@ -165,16 +174,46 @@ impl WorkspaceHttpObjectiveBackend {
) -> Result<ToolOutput, ToolError> { ) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ObjectiveUnlinkTicket")?; let id = validate_id(&input.id, "ObjectiveUnlinkTicket")?;
let ticket_id = validate_id(&input.ticket_id, "ObjectiveUnlinkTicket")?; let ticket_id = validate_id(&input.ticket_id, "ObjectiveUnlinkTicket")?;
let ticket_resource_key = self.ticket_resource_key(ticket_id).await?;
let url = format!("{}/ticket-links/{}", self.objective_url(id), ticket_id); let url = format!("{}/ticket-links/{}", self.objective_url(id), ticket_id);
let response = delete_json::<ObjectiveDetail>(self.client.as_ref(), &url) let response = delete_json::<ObjectiveDetail>(self.client.as_ref(), &url)
.await .await
.map_err(backend_error)?; .map_err(backend_error)?;
Ok(objective_output( Ok(objective_output(
format!("Unlinked ticket {ticket_id} from objective {}", response.id), format!(
"Unlinked ticket {ticket_resource_key} from objective {}",
&response.resource_key
),
response, response,
)?) )?)
} }
async fn ticket_resource_key(&self, ticket_reference: &str) -> Result<String, ToolError> {
let workspace_id = self.client.workspace_id().unwrap_or_default();
let response: serde_json::Value = decode_response(
self.client
.execute(WorkspaceRequest::get(format!(
"/api/w/{workspace_id}/tickets/{ticket_reference}"
)))
.map_err(WorkspaceObjectiveBackendError::from)
.map_err(backend_error)?,
)
.map_err(backend_error)?;
response
.get("resource_key")
.or_else(|| {
response
.get("meta")
.and_then(|meta| meta.get("resource_key"))
})
.and_then(serde_json::Value::as_str)
.filter(|key| is_canonical_resource_key(key, "T-"))
.map(ToOwned::to_owned)
.ok_or_else(|| {
ToolError::ExecutionFailed("required T- human key is unavailable".to_string())
})
}
fn objective_url(&self, id: &str) -> String { fn objective_url(&self, id: &str) -> String {
let workspace_id = self.client.workspace_id().unwrap_or_default(); let workspace_id = self.client.workspace_id().unwrap_or_default();
format!("/api/w/{workspace_id}/objectives/{id}") format!("/api/w/{workspace_id}/objectives/{id}")
@@ -185,7 +224,7 @@ impl WorkspaceHttpObjectiveBackend {
pub enum WorkspaceObjectiveBackendError { pub enum WorkspaceObjectiveBackendError {
#[error("workspace objective backend request failed: {0}")] #[error("workspace objective backend request failed: {0}")]
Request(#[from] crate::worker::WorkspaceClientError), Request(#[from] crate::worker::WorkspaceClientError),
#[error("workspace objective backend returned HTTP {status}: {body}")] #[error("workspace objective backend returned HTTP {status}")]
Http { Http {
status: reqwest::StatusCode, status: reqwest::StatusCode,
body: String, body: String,
@@ -247,10 +286,26 @@ fn decode_response<T: for<'de> Deserialize<'de>>(
serde_json::from_str(&response.body).map_err(Into::into) serde_json::from_str(&response.body).map_err(Into::into)
} }
fn is_canonical_resource_key(resource_key: &str, prefix: &str) -> bool {
resource_key.strip_prefix(prefix).is_some_and(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
})
}
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> { fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
if !is_canonical_resource_key(&response.resource_key, "O-") {
return Err(ToolError::ExecutionFailed(
"required O- human key is unavailable".to_string(),
));
}
let projected = serde_json::json!({
"objective": &response.resource_key,
"title": response.title,
"state": response.state,
});
Ok(ToolOutput { Ok(ToolOutput {
summary, summary,
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?), content: Some(serde_json::to_string_pretty(&projected).map_err(decode_error)?),
attachments: Vec::new(), attachments: Vec::new(),
}) })
@@ -260,7 +315,7 @@ fn validate_id<'a>(id: &'a str, tool_name: &str) -> Result<&'a str, ToolError> {
let id = id.trim(); let id = id.trim();
if id.is_empty() || id.contains('/') { if id.is_empty() || id.contains('/') {
return Err(ToolError::InvalidArgument(format!( return Err(ToolError::InvalidArgument(format!(
"{tool_name} requires non-empty canonical id without '/'" "{tool_name} requires a non-empty Objective reference without '/'"
))); )));
} }
Ok(id) Ok(id)
@@ -411,9 +466,9 @@ const EDIT_DESCRIPTION: &str =
const SET_STATE_DESCRIPTION: &str = const SET_STATE_DESCRIPTION: &str =
"Set an Objective state through Backend Workspace API authority."; "Set an Objective state through Backend Workspace API authority.";
const LINK_TICKET_DESCRIPTION: &str = const LINK_TICKET_DESCRIPTION: &str =
"Link a Ticket id to an Objective through Backend Workspace API authority."; "Link a Ticket reference to an Objective through Backend Workspace API authority.";
const UNLINK_TICKET_DESCRIPTION: &str = const UNLINK_TICKET_DESCRIPTION: &str =
"Unlink a Ticket id from an Objective through Backend Workspace API authority."; "Unlink a Ticket reference from an Objective through Backend Workspace API authority.";
fn list_schema() -> serde_json::Value { fn list_schema() -> serde_json::Value {
json!({ json!({
@@ -422,7 +477,7 @@ fn list_schema() -> serde_json::Value {
"properties":{ "properties":{
"query":{"type":["string","null"]}, "query":{"type":["string","null"]},
"states":{"type":"array","items":{"type":"string"},"default":[]}, "states":{"type":"array","items":{"type":"string"},"default":[]},
"linked_ticket_id":{"type":["string","null"]}, "linked_ticket_id":{"type":["string","null"],"description":"Linked Ticket reference. Prefer T-*; canonical internal ids remain accepted for compatibility."},
"updated_after":{"type":["string","null"]}, "updated_after":{"type":["string","null"]},
"updated_before":{"type":["string","null"]}, "updated_before":{"type":["string","null"]},
"sort":{"type":["string","null"],"enum":["relevance","updated_desc","created_desc","title",null]}, "sort":{"type":["string","null"],"enum":["relevance","updated_desc","created_desc","title",null]},
@@ -438,7 +493,7 @@ fn show_schema() -> serde_json::Value {
"additionalProperties": false, "additionalProperties": false,
"required":["id"], "required":["id"],
"properties":{ "properties":{
"id":{"type":"string"}, "id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
"event_limit":{"type":["integer","null"],"minimum":1,"maximum":50}, "event_limit":{"type":["integer","null"],"minimum":1,"maximum":50},
"event_cursor":{"type":["string","null"]} "event_cursor":{"type":["string","null"]}
} }
@@ -454,7 +509,7 @@ fn create_schema() -> serde_json::Value {
"title":{"type":"string","minLength":1}, "title":{"type":"string","minLength":1},
"body_md":{"type":"string"}, "body_md":{"type":"string"},
"state":{"type":"string","default":"active"}, "state":{"type":"string","default":"active"},
"linked_tickets":{"type":"array","items":{"type":"string"}} "linked_tickets":{"type":"array","items":{"type":"string"},"description":"Linked Ticket references. Prefer T-*; canonical internal ids remain accepted for compatibility."}
} }
}) })
} }
@@ -465,7 +520,7 @@ fn edit_schema() -> serde_json::Value {
"additionalProperties": false, "additionalProperties": false,
"required":["id"], "required":["id"],
"properties":{ "properties":{
"id":{"type":"string"}, "id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
"title":{"type":["string","null"]}, "title":{"type":["string","null"]},
"old_string":{"type":["string","null"]}, "old_string":{"type":["string","null"]},
"new_string":{"type":["string","null"]}, "new_string":{"type":["string","null"]},
@@ -480,7 +535,7 @@ fn set_state_schema() -> serde_json::Value {
"additionalProperties": false, "additionalProperties": false,
"required":["id","state"], "required":["id","state"],
"properties":{ "properties":{
"id":{"type":"string"}, "id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
"state":{"type":"string","minLength":1} "state":{"type":"string","minLength":1}
} }
}) })
@@ -500,8 +555,8 @@ fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
"additionalProperties": false, "additionalProperties": false,
"required": required, "required": required,
"properties":{ "properties":{
"id":{"type":"string"}, "id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
"ticket_id":{"type":"string"} "ticket_id":{"type":"string","description":"Ticket reference. Prefer T-*; canonical internal ids remain accepted for compatibility."}
} }
}) })
} }
@@ -595,21 +650,20 @@ fn default_state() -> String {
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct ObjectiveDetail { struct ObjectiveDetail {
id: String, resource_key: String,
title: String, title: String,
state: String, state: String,
created_at: Option<String>,
updated_at: Option<String>,
linked_tickets: Vec<String>,
body: String,
body_truncated: bool,
record_source: String,
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use agen::tool::ToolDefinition; use agen::tool::ToolDefinition;
use std::{
io::{Read, Write},
net::TcpListener,
thread,
};
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> { fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
let mut names = definitions let mut names = definitions
@@ -656,4 +710,135 @@ mod tests {
let link = link_ticket_schema(); let link = link_ticket_schema();
assert_eq!(link["required"], json!(["id", "ticket_id"])); assert_eq!(link["required"], json!(["id", "ticket_id"]));
} }
#[tokio::test(flavor = "multi_thread")]
async fn objective_show_summary_uses_projected_human_key() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(
request.starts_with("POST /api/w/workspace/objectives/00001INTERNAL/show HTTP/1.1")
);
let body = serde_json::json!({
"id": "00001INTERNAL",
"resource_key": "O-3",
"title": "Objective",
"body": "Body",
"state": "active",
"created_at": null,
"updated_at": null,
"linked_ticket_summaries": [],
"events": [],
"event_page": {"next_cursor": null, "has_more": false}
})
.to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
)
.unwrap();
});
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
));
let output = backend
.show(ShowObjectiveInput {
id: "00001INTERNAL".to_string(),
event_limit: None,
event_cursor: None,
})
.await
.unwrap();
server.join().unwrap();
assert_eq!(output.summary, "Read objective O-3");
assert!(!output.summary.contains("00001INTERNAL"));
assert!(!output.content.unwrap().contains("00001INTERNAL"));
}
#[tokio::test(flavor = "multi_thread")]
async fn objective_link_summaries_resolve_internal_ticket_ids_to_human_keys() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
for mutation in ["POST", "DELETE"] {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with("GET /api/w/workspace/tickets/00001INTERNAL HTTP/1.1"));
let response_body = serde_json::json!({"resource_key": "T-7"}).to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
response_body.len(),
response_body
)
.unwrap();
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with(&format!(
"{mutation} /api/w/workspace/objectives/O-3/ticket-links"
)));
let response_body = serde_json::json!({
"resource_key": "O-3",
"title": "Objective",
"state": "active"
})
.to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
response_body.len(),
response_body
)
.unwrap();
}
});
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
));
let linked = backend
.link_ticket(ObjectiveLinkTicketInput {
id: "O-3".to_string(),
ticket_id: "00001INTERNAL".to_string(),
})
.await
.unwrap();
let unlinked = backend
.unlink_ticket(ObjectiveUnlinkTicketInput {
id: "O-3".to_string(),
ticket_id: "00001INTERNAL".to_string(),
})
.await
.unwrap();
server.join().unwrap();
for output in [linked, unlinked] {
assert!(output.summary.contains("T-7"));
assert!(!output.summary.contains("00001INTERNAL"));
assert!(!output.content.unwrap().contains("00001INTERNAL"));
}
}
#[test]
fn objective_output_rejects_noncanonical_human_keys() {
let response = ObjectiveDetail {
resource_key: "O-internal".to_string(),
title: "Objective".to_string(),
state: "active".to_string(),
};
assert!(objective_output("created".to_string(), response).is_err());
}
} }
@@ -89,18 +89,19 @@ impl Tool for SpawnTicketCoderTool {
let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| { let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| {
ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}")) ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}"))
})?; })?;
let ticket_id = authority_id(input.ticket_id, "ticket_id")?; let ticket_ref = authority_id(input.ticket_id, "ticket_id")?;
let workflow_state = self let ticket = self
.ticket_service .ticket_service
.workflow_state(&ticket_id) .ticket_handoff(&ticket_ref)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !matches!( if !matches!(
workflow_state, ticket.workflow_state,
ticket::TicketWorkflowState::Queued | ticket::TicketWorkflowState::InProgress ticket::TicketWorkflowState::Queued | ticket::TicketWorkflowState::InProgress
) { ) {
return Err(ToolError::ExecutionFailed(format!( return Err(ToolError::ExecutionFailed(format!(
"Ticket {ticket_id} must be queued or inprogress before spawning its Coder; current state is {}", "Ticket {} must be queued or inprogress before spawning its Coder; current state is {}",
workflow_state.as_str() ticket.resource_key,
ticket.workflow_state.as_str()
))); )));
} }
let call_id = non_empty(ctx.call_id, "tool call_id")?; let call_id = non_empty(ctx.call_id, "tool call_id")?;
@@ -115,14 +116,14 @@ impl Tool for SpawnTicketCoderTool {
)?, )?,
relative_cwd, relative_cwd,
profile: CODER_PROFILE.to_string(), profile: CODER_PROFILE.to_string(),
ticket_id: Some(ticket_id.clone()), ticket_id: Some(ticket.id.clone()),
operation_id: Some(format!("spawn-ticket-coder:{ticket_id}:{call_id}")), operation_id: Some(format!("spawn-ticket-coder:{}:{call_id}", ticket.id)),
display_name: format!("Coder · {ticket_id}"), display_name: format!("Coder · {}", ticket.resource_key),
initial_submit: vec![ initial_submit: vec![
Segment::Flow { Segment::Flow {
selector: CODER_FLOW.to_string(), selector: CODER_FLOW.to_string(),
}, },
Segment::text(format!("Implement Ticket {ticket_id}.")), Segment::text(format!("Implement Ticket {}.", ticket.resource_key)),
], ],
}) })
.await .await
@@ -134,7 +135,7 @@ impl Tool for SpawnTicketCoderTool {
))); )));
} }
Ok(ToolOutput { Ok(ToolOutput {
summary: format!("Spawned Coder for Ticket {ticket_id}"), summary: format!("Spawned Coder for Ticket {}", ticket.resource_key),
content: Some(response.body), content: Some(response.body),
attachments: Vec::new(), attachments: Vec::new(),
}) })
@@ -201,21 +202,31 @@ mod tests {
use crate::worker::{WorkspaceClientError, WorkspaceResponse}; use crate::worker::{WorkspaceClientError, WorkspaceResponse};
use super::*; use super::*;
use crate::feature::builtin::ticket::TicketHandoff;
#[derive(Default)] #[derive(Default)]
struct RecordingTicketService; struct RecordingTicketService;
impl TicketService for RecordingTicketService { impl TicketService for RecordingTicketService {
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> { fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
Ok(TicketWorkflowState::Queued) assert_eq!(ticket_ref, "T-482");
Ok(TicketHandoff {
id: "00001KZXN51C7".to_string(),
resource_key: "T-482".to_string(),
workflow_state: TicketWorkflowState::Queued,
})
} }
} }
struct FixedTicketService(TicketWorkflowState); struct FixedTicketService(TicketWorkflowState);
impl TicketService for FixedTicketService { impl TicketService for FixedTicketService {
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> { fn ticket_handoff(&self, _ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
Ok(self.0) Ok(TicketHandoff {
id: "00001KZXN51C7".to_string(),
resource_key: "T-482".to_string(),
workflow_state: self.0,
})
} }
} }
@@ -247,7 +258,7 @@ mod tests {
}; };
tool.execute( tool.execute(
&serde_json::json!({ &serde_json::json!({
"ticket_id": "00001KZXN51C7", "ticket_id": "T-482",
"runtime_id": "runtime-1", "runtime_id": "runtime-1",
"working_directory_id": "workdir-1" "working_directory_id": "workdir-1"
}) })
@@ -265,16 +276,20 @@ mod tests {
request.operation_id.as_deref(), request.operation_id.as_deref(),
Some("spawn-ticket-coder:00001KZXN51C7:call-7") Some("spawn-ticket-coder:00001KZXN51C7:call-7")
); );
assert_eq!(request.display_name, "Coder · 00001KZXN51C7"); assert_eq!(request.display_name, "Coder · T-482");
assert_eq!( assert_eq!(
request.initial_submit, request.initial_submit,
vec![ vec![
Segment::Flow { Segment::Flow {
selector: CODER_FLOW.to_string() selector: CODER_FLOW.to_string()
}, },
Segment::text("Implement Ticket 00001KZXN51C7.") Segment::text("Implement Ticket T-482.")
] ]
); );
assert!(!request.display_name.contains("00001KZXN51C7"));
assert!(request.initial_submit.iter().all(|segment| {
!Segment::flatten_to_text(std::slice::from_ref(segment)).contains("00001KZXN51C7")
}));
} }
#[tokio::test] #[tokio::test]
@@ -0,0 +1,794 @@
use serde::Serialize;
use serde_json::{Map, Value};
#[derive(Debug, Serialize)]
pub(super) struct ModelTicketQueryResponse {
tickets: Vec<ModelTicketQueryItem>,
next_cursor: Option<String>,
has_more: bool,
}
#[derive(Debug, Serialize)]
struct ModelTicketQueryItem {
ticket: String,
title: String,
state: String,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
workspace_action_priority: Option<String>,
matched_fields: Vec<String>,
snippet: Option<String>,
current_coder: Option<ModelWorkerSummary>,
linked_objectives: Vec<String>,
relation_count: usize,
blocker_count: usize,
unresolved_blocker_count: usize,
unresolved_review_count: usize,
evidence: Option<ModelTicketEvidence>,
merge_request: Option<ModelMergeRequest>,
}
#[derive(Debug, Serialize)]
pub(super) struct ModelTicketDetail {
ticket: String,
title: String,
body: String,
state: String,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
thread: Vec<ModelTicketEvent>,
relations: ModelTicketRelations,
linked_objectives: Vec<ModelObjectiveSummary>,
assignments: Vec<ModelAssignment>,
current_coder: Option<ModelWorkerSummary>,
implementation_reports: Vec<ModelEvidenceEvent>,
merge_request: Option<ModelMergeRequest>,
evidence: Option<ModelTicketEvidence>,
actions: Option<ModelTicketActions>,
event_page: Option<ModelEventPage>,
}
#[derive(Debug, Serialize)]
pub(super) struct ModelObjectiveQueryResponse {
objectives: Vec<ModelObjectiveQueryItem>,
next_cursor: Option<String>,
has_more: bool,
}
#[derive(Debug, Serialize)]
struct ModelObjectiveQueryItem {
objective: String,
title: String,
summary: Option<String>,
state: String,
created_at: Option<String>,
updated_at: Option<String>,
linked_tickets: Vec<String>,
linked_ticket_count: usize,
}
#[derive(Debug, Serialize)]
pub(super) struct ModelObjectiveDetail {
objective: String,
title: String,
body: String,
state: String,
created_at: Option<String>,
updated_at: Option<String>,
linked_tickets: Vec<ModelTicketSummary>,
events: Vec<ModelObjectiveEvent>,
event_page: ModelObjectiveEventPage,
}
impl ModelObjectiveDetail {
pub(super) fn objective_ref(&self) -> &str {
&self.objective
}
}
#[derive(Debug, Serialize)]
struct ModelWorkerSummary {
worker: String,
}
#[derive(Debug, Serialize)]
struct ModelTicketEvent {
sequence: usize,
kind: String,
body: Option<String>,
created_at: Option<String>,
}
#[derive(Debug, Serialize, Default)]
struct ModelTicketRelations {
outgoing: Vec<ModelRelation>,
incoming: Vec<ModelRelation>,
blockers: Vec<ModelBlocker>,
notices: Vec<ModelNotice>,
}
#[derive(Debug, Serialize)]
struct ModelRelation {
ticket: String,
kind: String,
note: Option<String>,
created_at: Option<String>,
}
#[derive(Debug, Serialize)]
struct ModelBlocker {
ticket: String,
kind: String,
state: Option<String>,
resolved: bool,
}
#[derive(Debug, Serialize)]
struct ModelNotice {
kind: String,
}
#[derive(Debug, Serialize)]
struct ModelObjectiveSummary {
objective: String,
title: String,
state: String,
}
#[derive(Debug, Serialize)]
struct ModelTicketSummary {
ticket: String,
title: String,
state: String,
}
#[derive(Debug, Serialize)]
struct ModelAssignment {
role: String,
principal: String,
assigned_at: String,
}
#[derive(Debug, Serialize)]
struct ModelEvidenceEvent {
sequence: usize,
kind: String,
created_at: Option<String>,
excerpt: String,
}
#[derive(Debug, Serialize)]
struct ModelMergeRequest {
state: String,
selector_from: Option<String>,
selector_to: String,
review_status: String,
subject_ref: Option<String>,
review_excerpt: Option<String>,
}
#[derive(Debug, Serialize)]
struct ModelTicketEvidence {
has_merge_request: bool,
has_current_subject_ref: bool,
has_review_request: bool,
has_commit: bool,
review_status: Option<String>,
approved_current_subject: bool,
unresolved_request_changes: bool,
complete_for_integration: bool,
missing: Vec<String>,
}
#[derive(Debug, Serialize)]
struct ModelTicketActions {
can_assign_orchestrator: bool,
can_unassign_orchestrator: bool,
can_queue: bool,
can_start_manual_coder: bool,
}
#[derive(Debug, Serialize)]
struct ModelEventPage {
next_cursor: Option<String>,
has_more: bool,
}
#[derive(Debug, Serialize)]
struct ModelObjectiveEvent {
kind: String,
created_at: String,
body: Option<String>,
}
#[derive(Debug, Serialize)]
struct ModelObjectiveEventPage {
next_cursor: Option<String>,
has_more: bool,
}
pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryResponse, String> {
let root = object(&value, "Ticket query response")?;
let page = object_field(root, "page")?;
let tickets = array_field(root, "items")?
.iter()
.map(project_ticket_query_item)
.collect::<Result<Vec<_>, _>>()?;
Ok(ModelTicketQueryResponse {
tickets,
next_cursor: optional_string(page, "next_cursor")?,
has_more: bool_field(page, "has_more")?,
})
}
fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, String> {
let item = object(value, "Ticket query item")?;
Ok(ModelTicketQueryItem {
ticket: human_ref(item, "resource_key", "T-")?,
title: string_field(item, "title")?,
state: string_field(item, "state")?,
readiness: optional_string(item, "readiness")?,
priority: optional_string(item, "priority")?,
created_at: optional_string(item, "created_at")?,
updated_at: optional_string(item, "updated_at")?,
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")
.filter(|value| !value.is_null())
.map(project_worker)
.transpose()?,
linked_objectives: string_array(item, "linked_objective_keys")?
.into_iter()
.map(|key| validate_human_ref(key, "O-"))
.collect::<Result<Vec<_>, _>>()?,
relation_count: usize_field(item, "relation_count")?,
blocker_count: usize_field(item, "blocker_count")?,
unresolved_blocker_count: usize_field(item, "unresolved_blocker_count")?,
unresolved_review_count: usize_field(item, "unresolved_review_count")?,
evidence: item.get("evidence").map(project_evidence).transpose()?,
merge_request: item
.get("merge_request")
.filter(|value| !value.is_null())
.map(project_merge_request)
.transpose()?,
})
}
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")
.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()))
.collect::<Result<Vec<_>, _>>()?;
Ok(ModelTicketDetail {
ticket: human_ref(root, "resource_key", "T-")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
readiness: optional_string(root, "readiness")?,
priority: optional_string(root, "priority")?,
created_at: optional_string(root, "created_at")?,
updated_at: optional_string(root, "updated_at")?,
thread: array_field(root, "events")?
.iter()
.map(project_ticket_event)
.collect::<Result<Vec<_>, _>>()?,
relations: project_relations(root.get("relations"))?,
linked_objectives: array_field(root, "linked_objectives")?
.iter()
.map(project_objective_summary)
.collect::<Result<Vec<_>, _>>()?,
assignments,
current_coder,
implementation_reports: array_field(root, "implementation_reports")?
.iter()
.map(project_evidence_event)
.collect::<Result<Vec<_>, _>>()?,
merge_request: root
.get("merge_request")
.filter(|value| !value.is_null())
.map(project_merge_request)
.transpose()?,
evidence: root.get("evidence").map(project_evidence).transpose()?,
actions: root
.get("action_eligibility")
.filter(|value| !value.is_null())
.map(project_actions)
.transpose()?,
event_page: root
.get("event_page")
.filter(|value| !value.is_null())
.map(project_event_page)
.transpose()?,
})
}
pub(super) fn project_objective_query(value: Value) -> Result<ModelObjectiveQueryResponse, String> {
let root = object(&value, "Objective query response")?;
let page = object_field(root, "page")?;
Ok(ModelObjectiveQueryResponse {
objectives: array_field(root, "items")?
.iter()
.map(project_objective_query_item)
.collect::<Result<Vec<_>, _>>()?,
next_cursor: optional_string(page, "next_cursor")?,
has_more: bool_field(page, "has_more")?,
})
}
fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem, String> {
let item = object(value, "Objective query item")?;
let linked_tickets = string_array(item, "linked_ticket_keys")?
.into_iter()
.map(|key| validate_human_ref(key, "T-"))
.collect::<Result<Vec<_>, _>>()?;
Ok(ModelObjectiveQueryItem {
objective: human_ref(item, "resource_key", "O-")?,
title: string_field(item, "title")?,
summary: optional_string(item, "snippet")?,
state: string_field(item, "state")?,
created_at: optional_string(item, "created_at")?,
updated_at: optional_string(item, "updated_at")?,
linked_ticket_count: linked_tickets.len(),
linked_tickets,
})
}
pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDetail, String> {
let root = object(&value, "Objective detail response")?;
Ok(ModelObjectiveDetail {
objective: human_ref(root, "resource_key", "O-")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
created_at: optional_string(root, "created_at")?,
updated_at: optional_string(root, "updated_at")?,
linked_tickets: array_field(root, "linked_ticket_summaries")?
.iter()
.map(project_ticket_summary)
.collect::<Result<Vec<_>, _>>()?,
events: array_field(root, "events")?
.iter()
.map(project_objective_event)
.collect::<Result<Vec<_>, _>>()?,
event_page: project_objective_event_page(
root.get("event_page")
.ok_or_else(|| "Objective detail response is missing event_page".to_string())?,
)?,
})
}
fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
let worker = object(value, "Worker summary")?;
Ok(ModelWorkerSummary {
worker: human_ref(worker, "worker_resource_key", "W-")?,
})
}
fn project_ticket_event(value: &Value) -> Result<ModelTicketEvent, String> {
let event = object(value, "Ticket event")?;
Ok(ModelTicketEvent {
sequence: usize_field(event, "sequence")?,
kind: string_field(event, "kind")?,
body: match event.get("body") {
None | Some(Value::Null) => None,
Some(Value::String(body)) => Some(body.clone()),
Some(_) => return Err("invalid Ticket event body".to_string()),
},
created_at: optional_string(event, "at")?,
})
}
fn project_relations(value: Option<&Value>) -> Result<ModelTicketRelations, String> {
let Some(value) = value else {
return Ok(ModelTicketRelations::default());
};
let relations = object(value, "Ticket relations")?;
Ok(ModelTicketRelations {
outgoing: array_field(relations, "outgoing")?
.iter()
.map(|value| project_relation(value, "target_resource_key", "kind"))
.collect::<Result<Vec<_>, _>>()?,
incoming: array_field(relations, "incoming")?
.iter()
.map(|value| project_relation(value, "source_resource_key", "forward_kind"))
.collect::<Result<Vec<_>, _>>()?,
blockers: array_field(relations, "blockers")?
.iter()
.map(project_blocker)
.collect::<Result<Vec<_>, _>>()?,
notices: array_field(relations, "notices")?
.iter()
.map(project_notice)
.collect::<Result<Vec<_>, _>>()?,
})
}
fn project_relation(
value: &Value,
ticket_key: &str,
kind_key: &str,
) -> Result<ModelRelation, String> {
let relation = object(value, "Ticket relation")?;
let relation_data = relation.get("relation").and_then(Value::as_object);
let kind = if kind_key == "kind" {
relation_data
.ok_or_else(|| "Ticket relation is missing relation data".to_string())
.and_then(|data| string_field(data, "kind"))?
} else {
string_field(relation, kind_key)?
};
let note = match relation_data {
Some(data) => optional_string(data, "note")?,
None => optional_string(relation, "note")?,
};
let created_at = match relation_data {
Some(data) => optional_string(data, "at")?,
None => optional_string(relation, "at")?,
};
Ok(ModelRelation {
ticket: human_ref(relation, ticket_key, "T-")?,
kind,
note,
created_at,
})
}
fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
let blocker = object(value, "Ticket blocker")?;
Ok(ModelBlocker {
ticket: human_ref(blocker, "blocking_resource_key", "T-")?,
kind: string_field(blocker, "relation_kind")?,
state: optional_string(blocker, "blocking_state")?,
resolved: bool_field(blocker, "resolved")?,
})
}
fn project_notice(value: &Value) -> Result<ModelNotice, String> {
let notice = object(value, "Ticket notice")?;
Ok(ModelNotice {
kind: string_field(notice, "kind")?,
})
}
fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, String> {
let summary = object(value, "Objective summary")?;
Ok(ModelObjectiveSummary {
objective: human_ref(summary, "resource_key", "O-")?,
title: string_field(summary, "title")?,
state: string_field(summary, "state")?,
})
}
fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
let summary = object(value, "Ticket summary")?;
Ok(ModelTicketSummary {
ticket: human_ref(summary, "resource_key", "T-")?,
title: string_field(summary, "title")?,
state: string_field(summary, "state")?,
})
}
fn project_assignment(
value: &Value,
current_coder: 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
.map(|coder| coder.worker.clone())
.ok_or_else(|| {
"Worker assignment is missing a Workspace human key projection".to_string()
})?,
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
"user" => "user".to_string(),
other => format!("source:{other}"),
};
Ok(ModelAssignment {
role: string_field(assignment, "role")?,
principal,
assigned_at: string_field(assignment, "assigned_at")?,
})
}
fn project_evidence_event(value: &Value) -> Result<ModelEvidenceEvent, String> {
let event = object(value, "Ticket evidence event")?;
Ok(ModelEvidenceEvent {
sequence: usize_field(event, "sequence")?,
kind: string_field(event, "kind")?,
created_at: optional_string(event, "at")?,
excerpt: string_field(event, "excerpt")?,
})
}
fn project_merge_request(value: &Value) -> Result<ModelMergeRequest, String> {
let merge = object(value, "Merge Request summary")?;
Ok(ModelMergeRequest {
state: string_field(merge, "state")?,
selector_from: optional_string(merge, "selector_from")?,
selector_to: string_field(merge, "selector_to")?,
review_status: string_field(merge, "review_status")?,
subject_ref: optional_string(merge, "subject_ref")?,
review_excerpt: optional_string(merge, "review_excerpt")?,
})
}
fn project_evidence(value: &Value) -> Result<ModelTicketEvidence, String> {
let evidence = object(value, "Ticket evidence")?;
Ok(ModelTicketEvidence {
has_merge_request: bool_field(evidence, "has_merge_request")?,
has_current_subject_ref: bool_field(evidence, "has_current_subject_ref")?,
has_review_request: bool_field(evidence, "has_review_request")?,
has_commit: bool_field(evidence, "has_commit")?,
review_status: optional_string(evidence, "review_status")?,
approved_current_subject: bool_field(evidence, "approved_current_subject")?,
unresolved_request_changes: bool_field(evidence, "unresolved_request_changes")?,
complete_for_integration: bool_field(evidence, "complete_for_integration")?,
missing: string_array(evidence, "missing")?,
})
}
fn project_actions(value: &Value) -> Result<ModelTicketActions, String> {
let actions = object(value, "Ticket actions")?;
Ok(ModelTicketActions {
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")?,
})
}
fn project_event_page(value: &Value) -> Result<ModelEventPage, String> {
let page = object(value, "Ticket event page")?;
Ok(ModelEventPage {
next_cursor: optional_string(page, "next_cursor")?,
has_more: bool_field(page, "has_more")?,
})
}
fn project_objective_event(value: &Value) -> Result<ModelObjectiveEvent, String> {
let event = object(value, "Objective event")?;
let body = optional_string(event, "body")?;
Ok(ModelObjectiveEvent {
kind: string_field(event, "kind")?,
created_at: string_field(event, "created_at")?,
body,
})
}
fn project_objective_event_page(value: &Value) -> Result<ModelObjectiveEventPage, String> {
let page = object(value, "Objective event page")?;
Ok(ModelObjectiveEventPage {
next_cursor: optional_string(page, "next_cursor")?,
has_more: bool_field(page, "has_more")?,
})
}
fn object<'a>(value: &'a Value, context: &str) -> Result<&'a Map<String, Value>, String> {
value
.as_object()
.ok_or_else(|| format!("{context} must be an object"))
}
fn object_field<'a>(
object: &'a Map<String, Value>,
key: &str,
) -> Result<&'a Map<String, Value>, String> {
object
.get(key)
.and_then(Value::as_object)
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn array_field<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a [Value], String> {
object
.get(key)
.and_then(Value::as_array)
.map(Vec::as_slice)
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn string_field(object: &Map<String, Value>, key: &str) -> Result<String, String> {
object
.get(key)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn optional_string(object: &Map<String, Value>, key: &str) -> Result<Option<String>, String> {
match object.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(value)) => Ok(Some(value.clone())),
Some(_) => Err(format!("invalid {key}")),
}
}
fn bool_field(object: &Map<String, Value>, key: &str) -> Result<bool, String> {
object
.get(key)
.and_then(Value::as_bool)
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn usize_field(object: &Map<String, Value>, key: &str) -> Result<usize, String> {
object
.get(key)
.and_then(Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn string_array(object: &Map<String, Value>, key: &str) -> Result<Vec<String>, String> {
array_field(object, key)?
.iter()
.map(|value| {
value
.as_str()
.map(ToOwned::to_owned)
.ok_or_else(|| format!("invalid {key}"))
})
.collect()
}
fn human_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
let value = object
.get(key)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| format!("required {prefix} human key is unavailable"))?;
validate_human_ref(value, prefix)
}
fn validate_human_ref(value: String, prefix: &str) -> Result<String, String> {
let valid = value.strip_prefix(prefix).is_some_and(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
});
if valid {
Ok(value)
} else {
Err(format!("required {prefix} human key is unavailable"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn objective_projection_exposes_only_human_resource_references() {
let projected = project_objective_detail(json!({
"id": "00001M10HW6BV",
"resource_key": "O-543",
"title": "Objective",
"body": "Body",
"state": "active",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-02T00:00:00Z",
"linked_tickets": ["00001M0E82D1V"],
"linked_ticket_summaries": [{
"id": "00001M0E82D1V",
"resource_key": "T-496",
"title": "Ticket",
"state": "done",
"updated_at": "2026-01-02T00:00:00Z"
}],
"events": [{
"sequence": 3,
"event_ref": "objective-event-3",
"kind": "linked_ticket",
"created_at": "2026-01-02T00:00:00Z",
"body": "linked"
}],
"event_page": {"next_cursor": null, "has_more": false, "window_start_sequence": 3, "window_end_sequence": 3}
})).expect("projection");
let json = serde_json::to_value(projected).expect("serialize");
let text = json.to_string();
assert!(text.contains("O-543"));
assert!(text.contains("T-496"));
assert!(!text.contains("00001M10HW6BV"));
assert!(!text.contains("00001M0E82D1V"));
assert!(!text.contains("event_ref"));
}
#[test]
fn query_projections_accept_workspace_api_shapes_and_scrub_internal_ids() {
let ticket = project_ticket_query(json!({
"page": {"next_cursor": null, "has_more": false},
"record_authority": "workspace_sqlite",
"items": [{
"id": "00001TICKETINTERNAL",
"resource_key": "T-543",
"title": "Ticket",
"state": "inprogress",
"readiness": null,
"priority": "high",
"created_at": null,
"updated_at": "2026-01-01T00:00:00Z",
"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"},
"linked_objective_ids": ["00001OBJECTIVEINTERNAL"],
"linked_objective_keys": ["O-6"],
"relation_count": 0,
"blocker_count": 0,
"unresolved_blocker_count": 0,
"unresolved_review_count": 0,
"evidence": {
"has_merge_request": false,
"has_current_subject_ref": false,
"has_review_request": false,
"has_commit": false,
"review_status": null,
"approved_current_subject": false,
"unresolved_request_changes": false,
"complete_for_integration": false,
"missing": ["merge_request"]
},
"merge_request": null
}]
})).expect("Ticket query projection");
let ticket_json = serde_json::to_string(&ticket).expect("serialize Ticket query");
assert!(ticket_json.contains("T-543"));
assert!(ticket_json.contains("O-6"));
assert!(ticket_json.contains("W-12"));
assert!(!ticket_json.contains("00001TICKETINTERNAL"));
assert!(!ticket_json.contains("runtime-internal"));
assert!(!ticket_json.contains("worker-internal"));
let objective = project_objective_query(json!({
"page": {"next_cursor": null, "has_more": false},
"record_authority": "workspace_sqlite",
"items": [{
"id": "00001OBJECTIVEINTERNAL",
"resource_key": "O-6",
"title": "Objective",
"state": "active",
"created_at": null,
"updated_at": null,
"matched_fields": [],
"snippet": null,
"linked_ticket_count": 1,
"linked_tickets": ["00001TICKETINTERNAL"],
"linked_ticket_keys": ["T-543"]
}]
}))
.expect("Objective query projection");
let objective_json = serde_json::to_string(&objective).expect("serialize Objective query");
assert!(objective_json.contains("O-6"));
assert!(objective_json.contains("T-543"));
assert!(objective_json.contains("\"summary\":null"));
assert!(!objective_json.contains("00001OBJECTIVEINTERNAL"));
assert!(!objective_json.contains("00001TICKETINTERNAL"));
}
#[test]
fn human_resource_projection_rejects_noncanonical_keys() {
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
assert!(validate_human_ref(key.to_string(), prefix).is_err());
}
}
#[test]
fn ticket_projection_fails_closed_without_worker_resource_key() {
let error = project_worker(&json!({"worker_resource_key": null}))
.expect_err("missing W-key must fail");
assert!(error.contains("W-"));
}
}
@@ -193,6 +193,7 @@ impl Tool for ShowOverviewTool {
.map(|entry| { .map(|entry| {
serde_json::json!({ serde_json::json!({
"entry_ref": entry.id, "entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range, "entry_range": entry.entry_range,
"kind": entry.kind.as_str(), "kind": entry.kind.as_str(),
"label": entry.label, "label": entry.label,
@@ -234,15 +235,16 @@ impl Tool for SearchEntriesTool {
.transpose()?; .transpose()?;
let from = params.from.as_deref().map(parse_entry_ref).transpose()?; let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
let through = params.through.as_deref().map(parse_entry_ref).transpose()?; let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
let view = self.state.view();
if let (Some(from), Some(through)) = (&from, &through) { if let (Some(from), Some(through)) = (&from, &through) {
if from.source_index() > through.source_index() { if view.source_index_for_ref(from) > view.source_index_for_ref(through) {
return Err(ToolError::InvalidArgument( return Err(ToolError::InvalidArgument(
"SearchEntries from must not be after through".to_string(), "SearchEntries from must not be after through".to_string(),
)); ));
} }
} }
let limit = bounded_limit(params.limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT); let limit = bounded_limit(params.limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT);
let hits = self.state.view().search(&SearchOptions { let hits = view.search(&SearchOptions {
query: params.query, query: params.query,
kind, kind,
tool_part, tool_part,
@@ -318,6 +320,7 @@ impl Tool for ReadEntryTool {
.map(|entry| { .map(|entry| {
serde_json::json!({ serde_json::json!({
"entry_ref": entry.id, "entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range, "entry_range": entry.entry_range,
"kind": entry.kind.as_str(), "kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()), "tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
+256 -18
View File
@@ -33,6 +33,8 @@ use crate::feature::{
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod}; use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use super::resource_projection::{project_ticket_detail, project_ticket_query};
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum WorkspaceTicketReadKind { enum WorkspaceTicketReadKind {
Query, Query,
@@ -153,8 +155,10 @@ struct WorkspaceQueryTicketInput {
/// stale_after_rescope, and missing_evidence. /// stale_after_rescope, and missing_evidence.
#[serde(default)] #[serde(default)]
attention: Vec<WorkspaceTicketAttentionFilter>, attention: Vec<WorkspaceTicketAttentionFilter>,
/// Related Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
related_ticket_id: Option<String>, related_ticket_id: Option<String>,
relation_kind: Option<WorkspaceTicketRelationFilter>, relation_kind: Option<WorkspaceTicketRelationFilter>,
/// Linked Objective reference. Prefer `O-*`; canonical internal ids remain accepted for compatibility.
linked_objective_id: Option<String>, linked_objective_id: Option<String>,
updated_after: Option<String>, updated_after: Option<String>,
updated_before: Option<String>, updated_before: Option<String>,
@@ -169,6 +173,7 @@ struct WorkspaceQueryTicketInput {
#[derive(Debug, Deserialize, Serialize, JsonSchema)] #[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct WorkspaceShowTicketInput { struct WorkspaceShowTicketInput {
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
id: String, id: String,
/// Most-recent thread entries to return, bounded by the Backend to 1..=50. /// Most-recent thread entries to return, bounded by the Backend to 1..=50.
event_limit: Option<usize>, event_limit: Option<usize>,
@@ -229,13 +234,27 @@ impl Tool for WorkspaceTicketReadTool {
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() { if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!( return Err(ToolError::ExecutionFailed(format!(
"Workspace Ticket API returned HTTP {}: {}", "Workspace Ticket API request failed with HTTP status {}",
response.status, response.body response.status
))); )));
} }
let response_value: Value = serde_json::from_str(&response.body).map_err(|error| {
ToolError::ExecutionFailed(format!(
"Workspace Ticket API returned invalid JSON: {error}"
))
})?;
let content = match self.kind {
WorkspaceTicketReadKind::Query => serde_json::to_string(
&project_ticket_query(response_value).map_err(ToolError::ExecutionFailed)?,
),
WorkspaceTicketReadKind::Show => serde_json::to_string(
&project_ticket_detail(response_value).map_err(ToolError::ExecutionFailed)?,
),
}
.map_err(|error| ToolError::Internal(error.to_string()))?;
Ok(ToolOutput { Ok(ToolOutput {
summary: self.kind.name().to_string(), summary: self.kind.name().to_string(),
content: Some(response.body), content: Some(content),
attachments: Vec::new(), attachments: Vec::new(),
}) })
} }
@@ -267,7 +286,20 @@ pub const TICKET_SERVICE_ID: &str = "ticket.authority";
const TICKET_SERVICE_VERSION: &str = "1"; const TICKET_SERVICE_VERSION: &str = "1";
pub trait TicketService: Send + Sync { pub trait TicketService: Send + Sync {
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError>; fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketHandoff {
pub id: String,
pub resource_key: String,
pub workflow_state: TicketWorkflowState,
}
fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
resource_key.strip_prefix("T-").is_some_and(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
})
} }
struct BackendTicketService { struct BackendTicketService {
@@ -275,10 +307,18 @@ struct BackendTicketService {
} }
impl TicketService for BackendTicketService { impl TicketService for BackendTicketService {
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError> { fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
self.backend let ticket = self.backend.show(ticket_ref.into())?;
.show(ticket_id.into()) let resource_key = ticket
.map(|ticket| ticket.meta.workflow_state) .meta
.resource_key
.filter(|key| is_canonical_ticket_resource_key(key))
.ok_or_else(|| TicketError::Conflict("ticket resource key is unavailable".into()))?;
Ok(TicketHandoff {
id: ticket.meta.id,
resource_key,
workflow_state: ticket.meta.workflow_state,
})
} }
} }
@@ -712,14 +752,69 @@ impl WorkspaceHttpTicketBackend {
})?; })?;
if !response.is_success() { if !response.is_success() {
return Err(TicketError::Conflict(format!( return Err(TicketError::Conflict(format!(
"ticket REST API returned HTTP {}: {}", "ticket REST API request failed with HTTP status {}",
response.status, response.body response.status
))); )));
} }
serde_json::from_str(&response.body) let mut value: Value = serde_json::from_str(&response.body).map_err(|error| {
TicketError::Conflict(format!("decode ticket REST response: {error}"))
})?;
Self::canonicalize_ticket_references(&mut value);
serde_json::from_value(value)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}"))) .map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
} }
fn canonicalize_ticket_references(value: &mut Value) {
match value {
Value::Array(values) => {
for value in values {
Self::canonicalize_ticket_references(value);
}
}
Value::Object(object) => {
for value in object.values_mut() {
Self::canonicalize_ticket_references(value);
}
if let Some(resource_key) = object
.get("resource_key")
.and_then(Value::as_str)
.filter(|key| is_canonical_ticket_resource_key(key))
.map(ToOwned::to_owned)
&& object.contains_key("id")
{
object.insert("id".to_string(), Value::String(resource_key));
}
}
_ => {}
}
}
fn resolve_ticket_resource_key(
client: Arc<dyn WorkspaceClient>,
base: &str,
reference: &TicketIdOrSlug,
) -> TicketResult<String> {
let response: Value = Self::request(
client,
WorkspaceRequestMethod::Get,
format!("{base}/{}", Self::ticket_path(reference)),
None,
)?;
response
.get("resource_key")
.or_else(|| {
response
.get("meta")
.and_then(|meta| meta.get("resource_key"))
})
.and_then(Value::as_str)
.filter(|key| is_canonical_ticket_resource_key(key))
.map(ToOwned::to_owned)
.ok_or_else(|| {
TicketError::Conflict("required Ticket human key is unavailable".to_string())
})
}
fn request_unit( fn request_unit(
client: Arc<dyn WorkspaceClient>, client: Arc<dyn WorkspaceClient>,
method: WorkspaceRequestMethod, method: WorkspaceRequestMethod,
@@ -739,8 +834,8 @@ impl WorkspaceHttpTicketBackend {
})?; })?;
if !response.is_success() { if !response.is_success() {
return Err(TicketError::Conflict(format!( return Err(TicketError::Conflict(format!(
"ticket REST API returned HTTP {}: {}", "ticket REST API request failed with HTTP status {}",
response.status, response.body response.status
))); )));
} }
Ok(TicketBackendOperationResult::Unit) Ok(TicketBackendOperationResult::Unit)
@@ -781,12 +876,22 @@ impl WorkspaceHttpTicketBackend {
Ok(TicketBackendOperationResult::Tickets(tickets)) Ok(TicketBackendOperationResult::Tickets(tickets))
} }
TicketBackendOperation::Show { id } => { TicketBackendOperation::Show { id } => {
let ticket = Self::request( let ticket: Ticket = Self::request(
client, client,
WorkspaceRequestMethod::Get, WorkspaceRequestMethod::Get,
format!("{base}/{}/record", Self::ticket_path(&id)), format!("{base}/{}/record", Self::ticket_path(&id)),
None, None,
)?; )?;
if !ticket
.meta
.resource_key
.as_deref()
.is_some_and(is_canonical_ticket_resource_key)
{
return Err(TicketError::Conflict(
"required Ticket human key is unavailable".to_string(),
));
}
Ok(TicketBackendOperationResult::Ticket(ticket)) Ok(TicketBackendOperationResult::Ticket(ticket))
} }
TicketBackendOperation::Create { input } => { TicketBackendOperation::Create { input } => {
@@ -889,7 +994,14 @@ impl WorkspaceHttpTicketBackend {
})?), })?),
), ),
TicketBackendOperation::AddTicketRelation { id, relation } => { TicketBackendOperation::AddTicketRelation { id, relation } => {
let relation = Self::request( let source_resource_key =
Self::resolve_ticket_resource_key(client.clone(), &base, &id)?;
let target_resource_key = Self::resolve_ticket_resource_key(
client.clone(),
&base,
&TicketIdOrSlug::Id(relation.target.clone()),
)?;
let mut relation: TicketRelation = Self::request(
client, client,
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
format!("{base}/{}/relations", Self::ticket_path(&id)), format!("{base}/{}/relations", Self::ticket_path(&id)),
@@ -897,20 +1009,30 @@ impl WorkspaceHttpTicketBackend {
TicketError::Conflict(format!("serialize Ticket relation: {error}")) TicketError::Conflict(format!("serialize Ticket relation: {error}"))
})?), })?),
)?; )?;
relation.ticket_id = source_resource_key;
relation.target = target_resource_key;
relation.author = "workspace".to_string();
Ok(TicketBackendOperationResult::Relation(relation)) Ok(TicketBackendOperationResult::Relation(relation))
} }
TicketBackendOperation::RemoveTicketRelation { id, kind, target } => { TicketBackendOperation::RemoveTicketRelation { id, kind, target } => {
let source_resource_key =
Self::resolve_ticket_resource_key(client.clone(), &base, &id)?;
let target_resource_key =
Self::resolve_ticket_resource_key(client.clone(), &base, &target)?;
let target = match target { let target = match target {
TicketIdOrSlug::Id(value) TicketIdOrSlug::Id(value)
| TicketIdOrSlug::Slug(value) | TicketIdOrSlug::Slug(value)
| TicketIdOrSlug::Query(value) => value, | TicketIdOrSlug::Query(value) => value,
}; };
let relation = Self::request( let mut relation: TicketRelation = Self::request(
client, client,
WorkspaceRequestMethod::Delete, WorkspaceRequestMethod::Delete,
format!("{base}/{}/relations", Self::ticket_path(&id)), format!("{base}/{}/relations", Self::ticket_path(&id)),
Some(serde_json::json!({ "kind": kind, "target": target })), Some(serde_json::json!({ "kind": kind, "target": target })),
)?; )?;
relation.ticket_id = source_resource_key;
relation.target = target_resource_key;
relation.author = "workspace".to_string();
Ok(TicketBackendOperationResult::Relation(relation)) Ok(TicketBackendOperationResult::Relation(relation))
} }
TicketBackendOperation::QueryTicketRelations { ticket, kind } => { TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
@@ -1245,6 +1367,23 @@ mod tests {
.expect("tool exists") .expect("tool exists")
} }
#[test]
fn workspace_ticket_backend_canonicalizes_model_facing_ticket_ids() {
let mut value = serde_json::json!({
"id": "00001INTERNAL",
"resource_key": "T-42",
"nested": {
"id": "00002INTERNAL",
"resource_key": "T-43"
},
"body": "user-authored 00003BODY stays unchanged"
});
WorkspaceHttpTicketBackend::canonicalize_ticket_references(&mut value);
assert_eq!(value["id"], "T-42");
assert_eq!(value["nested"]["id"], "T-43");
assert_eq!(value["body"], "user-authored 00003BODY stays unchanged");
}
#[test] #[test]
fn workspace_ticket_reads_expose_bounded_query_and_show_contracts_without_legacy_aliases() { fn workspace_ticket_reads_expose_bounded_query_and_show_contracts_without_legacy_aliases() {
let client: Arc<dyn WorkspaceClient> = Arc::new( let client: Arc<dyn WorkspaceClient> = Arc::new(
@@ -1721,11 +1860,102 @@ provider = "github"
server.join().unwrap(); server.join().unwrap();
} }
#[test]
fn workspace_http_backend_records_relation_with_authoritative_human_keys() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = thread::spawn(move || {
for (expected_path, resource_key) in [
("GET /api/w/workspace-a/tickets/01SOURCE HTTP/1.1", "T-1"),
("GET /api/w/workspace-a/tickets/01TARGET HTTP/1.1", "T-2"),
] {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with(expected_path));
let body = serde_json::json!({"meta": {"resource_key": resource_key}}).to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(), body
)
.unwrap();
}
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(
request.starts_with("POST /api/w/workspace-a/tickets/01SOURCE/relations HTTP/1.1")
);
let body = serde_json::to_string(&TicketRelation {
ticket_id: "01SOURCE".to_string(),
kind: TicketRelationKind::DependsOn,
target: "01TARGET".to_string(),
note: None,
author: "worker-internal".to_string(),
at: "2026-08-06T00:00:00Z".to_string(),
})
.unwrap();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
)
.unwrap();
});
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace-a", format!("http://{addr}")),
));
let relation = backend
.add_ticket_relation(
TicketIdOrSlug::Id("01SOURCE".to_string()),
NewTicketRelation {
kind: TicketRelationKind::DependsOn,
target: "01TARGET".to_string(),
note: None,
author: None,
},
)
.unwrap();
server.join().unwrap();
assert_eq!(relation.ticket_id, "T-1");
assert_eq!(relation.target, "T-2");
assert_eq!(relation.author, "workspace");
}
#[test] #[test]
fn workspace_http_backend_deletes_exact_ticket_relation() { fn workspace_http_backend_deletes_exact_ticket_relation() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap()); let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || { let server = thread::spawn(move || {
for (expected_path, resource_key) in [
("GET /api/w/workspace-a/tickets/01SOURCE HTTP/1.1", "T-1"),
("GET /api/w/workspace-a/tickets/01TARGET HTTP/1.1", "T-2"),
] {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with(expected_path));
let response_body = serde_json::json!({
"meta": {"resource_key": resource_key}
})
.to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
response_body.len(),
response_body
)
.unwrap();
}
let (mut stream, _) = listener.accept().unwrap(); let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192]; let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap(); let len = stream.read(&mut buffer).unwrap();
@@ -1766,8 +1996,16 @@ provider = "github"
.unwrap(); .unwrap();
server.join().unwrap(); server.join().unwrap();
assert_eq!(removed.ticket_id, "01SOURCE"); assert_eq!(removed.ticket_id, "T-1");
assert_eq!(removed.target, "01TARGET"); assert_eq!(removed.target, "T-2");
}
#[test]
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
assert!(is_canonical_ticket_resource_key("T-482"));
for invalid in ["", "00001KZVNXFNK", "T-", "T-key", "O-482"] {
assert!(!is_canonical_ticket_resource_key(invalid));
}
} }
#[test] #[test]
@@ -1,11 +1,12 @@
use std::sync::Arc; use std::sync::Arc;
#[cfg(test)]
use agen::Item; use agen::Item;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait; use async_trait::async_trait;
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use session_store::collect_state; use session_store::{LogEntry, collect_state};
use super::manage_worker::{WORKER_CONTROL_SERVICE_ID, WorkerControlService}; use super::manage_worker::{WORKER_CONTROL_SERVICE_ID, WorkerControlService};
use crate::feature::{ use crate::feature::{
@@ -60,7 +61,27 @@ pub struct WorkerObservationSubject {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct WorkerSessionCapture { pub struct WorkerSessionCapture {
pub segment_id: String, pub segment_id: String,
pub items: Vec<Item>, pub entries: Vec<agen::HistoryEntry<crate::SessionHistoryMetadata>>,
}
impl WorkerSessionCapture {
pub fn from_log_entries(
segment_id: impl Into<String>,
log_entries: &[LogEntry],
) -> Result<Self, String> {
let segment_id = segment_id.into();
let state = collect_state(log_entries);
let parsed_segment_id = segment_id.parse().unwrap_or_default();
let entries = crate::session_history::restore_history_entries(
state.session_id.unwrap_or_default(),
parsed_segment_id,
log_entries,
)?;
Ok(Self {
segment_id,
entries,
})
}
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -161,9 +182,17 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
}) })
.collect::<Result<Vec<session_store::LogEntry>, _>>()?; .collect::<Result<Vec<session_store::LogEntry>, _>>()?;
let state = collect_state(&entries); let state = collect_state(&entries);
let segment_id = response.segment_id;
let parsed_segment_id = segment_id.parse().unwrap_or_default();
let typed_entries = crate::session_history::restore_history_entries(
state.session_id.unwrap_or_default(),
parsed_segment_id,
&entries,
)
.map_err(WorkerObservationError::Unavailable)?;
Ok(WorkerSessionCapture { Ok(WorkerSessionCapture {
segment_id: response.segment_id, segment_id,
items: state.history, entries: typed_entries,
}) })
} }
} }
@@ -392,9 +421,15 @@ impl WorkerObservationProvider for SpawnedSubWorkerObservationProvider {
.ok_or(WorkerObservationError::NotFound)?; .ok_or(WorkerObservationError::NotFound)?;
let entries = record.session.entries(); let entries = record.session.entries();
let state = collect_state(&entries); let state = collect_state(&entries);
let typed_entries = crate::session_history::restore_history_entries(
state.session_id.unwrap_or_default(),
Default::default(),
&entries,
)
.map_err(WorkerObservationError::Unavailable)?;
Ok(WorkerSessionCapture { Ok(WorkerSessionCapture {
segment_id: format!("subworker:{name}"), segment_id: format!("subworker:{name}"),
items: state.history, entries: typed_entries,
}) })
} }
} }
@@ -508,6 +543,7 @@ impl Tool for ViewSessionOverviewTool {
.map(|entry| { .map(|entry| {
serde_json::json!({ serde_json::json!({
"entry_ref": entry.id, "entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range, "entry_range": entry.entry_range,
"kind": entry.kind.as_str(), "kind": entry.kind.as_str(),
"label": entry.label, "label": entry.label,
@@ -547,7 +583,7 @@ impl Tool for SearchSessionEntriesTool {
let from = params.from.as_deref().map(parse_entry_ref).transpose()?; let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
let through = params.through.as_deref().map(parse_entry_ref).transpose()?; let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
if let (Some(from), Some(through)) = (&from, &through) { if let (Some(from), Some(through)) = (&from, &through) {
if from.source_index() > through.source_index() { if view.source_index_for_ref(from) > view.source_index_for_ref(through) {
return Err(ToolError::InvalidArgument( return Err(ToolError::InvalidArgument(
"SearchSessionEntries from must not be after through".to_string(), "SearchSessionEntries from must not be after through".to_string(),
)); ));
@@ -573,6 +609,7 @@ impl Tool for SearchSessionEntriesTool {
.map(|entry| { .map(|entry| {
serde_json::json!({ serde_json::json!({
"entry_ref": entry.id, "entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range, "entry_range": entry.entry_range,
"kind": entry.kind.as_str(), "kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()), "tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
@@ -628,6 +665,7 @@ impl Tool for ReadSessionEntryTool {
.map(|entry| { .map(|entry| {
serde_json::json!({ serde_json::json!({
"entry_ref": entry.id, "entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range, "entry_range": entry.entry_range,
"kind": entry.kind.as_str(), "kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()), "tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
@@ -661,7 +699,10 @@ async fn latest_view(
.capture_worker_session(subject) .capture_worker_session(subject)
.await .await
.map_err(tool_error)?; .map_err(tool_error)?;
Ok(SessionCapture::new(capture.segment_id, capture.items)) Ok(SessionCapture::from_history_entries(
capture.segment_id,
capture.entries,
))
} }
fn parse_input<T: serde::de::DeserializeOwned>( fn parse_input<T: serde::de::DeserializeOwned>(
@@ -751,9 +792,23 @@ mod tests {
if subject != &granted_subject() { if subject != &granted_subject() {
return Err(WorkerObservationError::NotFound); return Err(WorkerObservationError::NotFound);
} }
let entries = self
.captures
.lock()
.unwrap()
.clone()
.into_iter()
.enumerate()
.map(|(index, item)| {
let mut metadata = crate::SessionHistoryMetadata::legacy_unknown();
metadata.entry_id =
session_store::LoggedSessionHistoryEntryId(format!("fake-{index:08}"));
agen::HistoryEntry::new(item, metadata)
})
.collect();
Ok(WorkerSessionCapture { Ok(WorkerSessionCapture {
segment_id: "segment".to_string(), segment_id: "segment".to_string(),
items: self.captures.lock().unwrap().clone(), entries,
}) })
} }
} }
@@ -796,7 +851,7 @@ mod tests {
let read = read_definition(provider.clone())().1; let read = read_definition(provider.clone())().1;
let hidden = read let hidden = read
.execute( .execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"unauthorized"},"entry_ref":"E00000000"}"#, r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"unauthorized"},"entry_ref":"Efake-00000000"}"#,
agen::tool::ToolExecutionContext::direct(), agen::tool::ToolExecutionContext::direct(),
) )
.await .await
@@ -810,7 +865,7 @@ mod tests {
.push(message("a1", Role::Assistant, "second")); .push(message("a1", Role::Assistant, "second"));
let output = read let output = read
.execute( .execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"E00000000"}"#, r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"Efake-00000000"}"#,
agen::tool::ToolExecutionContext::direct(), agen::tool::ToolExecutionContext::direct(),
) )
.await .await
@@ -819,7 +874,7 @@ mod tests {
let output = read let output = read
.execute( .execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"E00000001"}"#, r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"Efake-00000001"}"#,
agen::tool::ToolExecutionContext::direct(), agen::tool::ToolExecutionContext::direct(),
) )
.await .await
+177 -25
View File
@@ -10,7 +10,7 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use agen::timeline::event::UsageEvent; use agen::timeline::event::UsageEvent;
use agen::{Engine, llm_client::LlmClient}; use agen::{Engine, EngineError, llm_client::LlmClient};
use manifest::{Scope, WorkerManifest}; use manifest::{Scope, WorkerManifest};
use protocol::{Event, InFlightSnapshot, WorkerStatus}; use protocol::{Event, InFlightSnapshot, WorkerStatus};
use session_store::{LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntry}; use session_store::{LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntry};
@@ -55,7 +55,17 @@ pub(crate) struct InternalWorkerSpec {
pub input: String, pub input: String,
pub cache_key: Option<String>, pub cache_key: Option<String>,
pub max_turns: Option<u32>, pub max_turns: Option<u32>,
pub engine_configurator: Option<Box<dyn FnOnce(&mut Engine<Box<dyn LlmClient>>) + Send>>, pub engine_configurator: Option<
Box<
dyn FnOnce(
&mut Engine<
Box<dyn LlmClient>,
agen::state::Mutable,
crate::SessionHistoryMetadata,
>,
) + Send,
>,
>,
pub features: FeatureRegistryBuilder, pub features: FeatureRegistryBuilder,
pub required_tools: &'static [&'static str], pub required_tools: &'static [&'static str],
pub authority: InternalWorkerAuthority, pub authority: InternalWorkerAuthority,
@@ -124,7 +134,9 @@ where
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>)); let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
let usage_slot = last_usage.clone(); let usage_slot = last_usage.clone();
let mut engine = Engine::new(client).system_prompt(system_prompt); let mut engine =
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
.system_prompt(system_prompt);
engine.on_usage(move |usage| { engine.on_usage(move |usage| {
if let Ok(mut slot) = usage_slot.lock() { if let Ok(mut slot) = usage_slot.lock() {
*slot = Some(usage.clone()); *slot = Some(usage.clone());
@@ -199,12 +211,28 @@ where
on_cancel_sender(worker.engine_mut().cancel_sender()); on_cancel_sender(worker.engine_mut().cancel_sender());
match worker.run_text(&input).await { match worker.run_text(&input).await {
Ok(lifecycle) => Ok(InternalWorkerResult { Ok(lifecycle @ WorkerRunResult::Finished)
| Ok(lifecycle @ WorkerRunResult::Paused)
| Ok(lifecycle @ WorkerRunResult::RolledBack) => Ok(InternalWorkerResult {
usage: last_usage.lock().ok().and_then(|slot| slot.clone()), usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
identity, identity,
lifecycle, lifecycle,
history_entries: store.entries_count(session_id, segment_id), history_entries: store.entries_count(session_id, segment_id),
}), }),
Ok(WorkerRunResult::LimitReached) => Err(InternalWorkerError {
source: WorkerError::Engine(EngineError::Aborted(
"internal Worker reached its turn limit".to_string(),
)),
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
identity,
history_entries: store.entries_count(session_id, segment_id),
}),
Ok(WorkerRunResult::Interrupted { message, .. }) => Err(InternalWorkerError {
source: WorkerError::Engine(EngineError::Aborted(message)),
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
identity,
history_entries: store.entries_count(session_id, segment_id),
}),
Err(source) => Err(InternalWorkerError { Err(source) => Err(InternalWorkerError {
source, source,
usage: last_usage.lock().ok().and_then(|slot| slot.clone()), usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
@@ -232,6 +260,7 @@ impl Default for InternalWorkerVisibility {
pub(crate) enum InternalWorkerSessionStatus { pub(crate) enum InternalWorkerSessionStatus {
Idle, Idle,
Running, Running,
Paused,
Stopping, Stopping,
Stopped, Stopped,
Failed, Failed,
@@ -242,9 +271,10 @@ impl InternalWorkerSessionStatus {
match self { match self {
Self::Idle => 0, Self::Idle => 0,
Self::Running => 1, Self::Running => 1,
Self::Stopping => 2, Self::Paused => 2,
Self::Stopped => 3, Self::Stopping => 3,
Self::Failed => 4, Self::Stopped => 4,
Self::Failed => 5,
} }
} }
@@ -252,13 +282,35 @@ impl InternalWorkerSessionStatus {
match value { match value {
0 => Self::Idle, 0 => Self::Idle,
1 => Self::Running, 1 => Self::Running,
2 => Self::Stopping, 2 => Self::Paused,
3 => Self::Stopped, 3 => Self::Stopping,
4 => Self::Stopped,
_ => Self::Failed, _ => Self::Failed,
} }
} }
} }
fn classify_internal_turn_result(
result: Result<WorkerRunResult, WorkerError>,
) -> (InternalWorkerSessionStatus, Option<String>) {
match result {
Ok(WorkerRunResult::Finished) => (InternalWorkerSessionStatus::Idle, None),
Ok(WorkerRunResult::Paused) => (InternalWorkerSessionStatus::Paused, None),
Ok(WorkerRunResult::LimitReached) => (
InternalWorkerSessionStatus::Stopped,
Some("internal Worker reached its turn limit".to_string()),
),
Ok(WorkerRunResult::Interrupted { message, .. }) => {
(InternalWorkerSessionStatus::Stopped, Some(message))
}
Ok(WorkerRunResult::RolledBack) => (
InternalWorkerSessionStatus::Stopped,
Some("internal Worker run was cancelled before AI output".to_string()),
),
Err(error) => (InternalWorkerSessionStatus::Failed, Some(error.to_string())),
}
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub(crate) enum InternalWorkerSessionError { pub(crate) enum InternalWorkerSessionError {
#[error("failed to build internal Worker session: {message}")] #[error("failed to build internal Worker session: {message}")]
@@ -353,10 +405,11 @@ impl InternalWorkerSessionHandle {
entries, entries,
status: match self.status() { status: match self.status() {
InternalWorkerSessionStatus::Running => WorkerStatus::Running, InternalWorkerSessionStatus::Running => WorkerStatus::Running,
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
InternalWorkerSessionStatus::Idle => WorkerStatus::Idle, InternalWorkerSessionStatus::Idle => WorkerStatus::Idle,
InternalWorkerSessionStatus::Stopping InternalWorkerSessionStatus::Stopping
| InternalWorkerSessionStatus::Stopped | InternalWorkerSessionStatus::Stopped
| InternalWorkerSessionStatus::Failed => WorkerStatus::Paused, | InternalWorkerSessionStatus::Failed => WorkerStatus::Stopped,
}, },
error: self.last_error.lock().unwrap().clone(), error: self.last_error.lock().unwrap().clone(),
in_flight, in_flight,
@@ -388,6 +441,7 @@ impl InternalWorkerSessionHandle {
.map_err( .map_err(
|current| match InternalWorkerSessionStatus::decode(current) { |current| match InternalWorkerSessionStatus::decode(current) {
InternalWorkerSessionStatus::Running InternalWorkerSessionStatus::Running
| InternalWorkerSessionStatus::Paused
| InternalWorkerSessionStatus::Stopping => InternalWorkerSessionError::Busy, | InternalWorkerSessionStatus::Stopping => InternalWorkerSessionError::Busy,
InternalWorkerSessionStatus::Stopped | InternalWorkerSessionStatus::Failed => { InternalWorkerSessionStatus::Stopped | InternalWorkerSessionStatus::Failed => {
InternalWorkerSessionError::Stopped InternalWorkerSessionError::Stopped
@@ -494,7 +548,9 @@ pub(crate) async fn spawn_internal_worker_session(
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>)); let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
let usage_slot = last_usage.clone(); let usage_slot = last_usage.clone();
let mut engine = Engine::new(client).system_prompt(system_prompt); let mut engine =
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
.system_prompt(system_prompt);
engine.on_usage(move |usage| { engine.on_usage(move |usage| {
if let Ok(mut slot) = usage_slot.lock() { if let Ok(mut slot) = usage_slot.lock() {
*slot = Some(usage.clone()); *slot = Some(usage.clone());
@@ -591,7 +647,9 @@ pub(crate) fn prepare_internal_worker_from_spec(
manifest.compaction = None; manifest.compaction = None;
manifest.memory = None; manifest.memory = None;
let mut engine = Engine::new(client).system_prompt(system_prompt); let mut engine =
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
.system_prompt(system_prompt);
engine.set_cache_key(cache_key); engine.set_cache_key(cache_key);
engine.set_max_turns(max_turns); engine.set_max_turns(max_turns);
if let Some(configure) = engine_configurator { if let Some(configure) = engine_configurator {
@@ -733,13 +791,7 @@ pub(crate) async fn prepare_internal_worker_session(
loop { loop {
tokio::select! { tokio::select! {
result = &mut run => { result = &mut run => {
let (turn_status, error) = match result { let (turn_status, error) = classify_internal_turn_result(result);
Ok(_) => (InternalWorkerSessionStatus::Idle, None),
Err(error) => (
InternalWorkerSessionStatus::Failed,
Some(error.to_string()),
),
};
actor_in_flight.clear(); actor_in_flight.clear();
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release); status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
if let Some(message) = error { if let Some(message) = error {
@@ -748,11 +800,20 @@ pub(crate) async fn prepare_internal_worker_session(
code: protocol::ErrorCode::Internal, code: protocol::ErrorCode::Internal,
message, message,
}); });
} else {
let _ = event_tx.send(Event::Status {
status: WorkerStatus::Idle,
});
} }
let protocol_status = match turn_status {
InternalWorkerSessionStatus::Idle => WorkerStatus::Idle,
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
InternalWorkerSessionStatus::Stopped
| InternalWorkerSessionStatus::Failed => WorkerStatus::Stopped,
InternalWorkerSessionStatus::Running
| InternalWorkerSessionStatus::Stopping => {
unreachable!("run completion cannot remain active")
}
};
let _ = event_tx.send(Event::Status {
status: protocol_status,
});
if let Some(callback) = &on_turn_end { if let Some(callback) = &on_turn_end {
callback(turn_status); callback(turn_status);
} }
@@ -766,7 +827,7 @@ pub(crate) async fn prepare_internal_worker_session(
let _ = (&mut run).await; let _ = (&mut run).await;
actor_in_flight.clear(); actor_in_flight.clear();
status.store(InternalWorkerSessionStatus::Stopped.encode(), std::sync::atomic::Ordering::Release); status.store(InternalWorkerSessionStatus::Stopped.encode(), std::sync::atomic::Ordering::Release);
let _ = event_tx.send(Event::Status { status: WorkerStatus::Paused }); let _ = event_tx.send(Event::Status { status: WorkerStatus::Stopped });
let _ = event_tx.send(Event::Shutdown); let _ = event_tx.send(Event::Shutdown);
state_changed.notify_waiters(); state_changed.notify_waiters();
let _ = done.send(()); let _ = done.send(());
@@ -792,7 +853,7 @@ pub(crate) async fn prepare_internal_worker_session(
std::sync::atomic::Ordering::Release, std::sync::atomic::Ordering::Release,
); );
let _ = event_tx.send(Event::Status { let _ = event_tx.send(Event::Status {
status: WorkerStatus::Paused, status: WorkerStatus::Stopped,
}); });
let _ = event_tx.send(Event::Shutdown); let _ = event_tx.send(Event::Shutdown);
state_changed.notify_waiters(); state_changed.notify_waiters();
@@ -1102,6 +1163,26 @@ mod tests {
} }
} }
#[derive(Clone)]
struct FailingClient;
#[async_trait]
impl LlmClient for FailingClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
_request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
Err(ClientError::Config(
"intentional internal failure".to_string(),
))
}
}
#[derive(Clone)] #[derive(Clone)]
struct CancelBeforeAiClient { struct CancelBeforeAiClient {
calls: Arc<AtomicUsize>, calls: Arc<AtomicUsize>,
@@ -1215,6 +1296,77 @@ permission = "write"
assert_eq!(result.identity.kind, "test"); assert_eq!(result.identity.kind, "test");
} }
#[test]
fn internal_turn_result_mapping_is_exhaustive() {
let cases = [
(
WorkerRunResult::Finished,
InternalWorkerSessionStatus::Idle,
false,
),
(
WorkerRunResult::Paused,
InternalWorkerSessionStatus::Paused,
false,
),
(
WorkerRunResult::LimitReached,
InternalWorkerSessionStatus::Stopped,
true,
),
(
WorkerRunResult::Interrupted {
code: protocol::ErrorCode::Internal,
message: "cancelled".to_string(),
},
InternalWorkerSessionStatus::Stopped,
true,
),
(
WorkerRunResult::RolledBack,
InternalWorkerSessionStatus::Stopped,
true,
),
];
for (result, expected_status, expects_error) in cases {
let (status, error) = classify_internal_turn_result(Ok(result));
assert_eq!(status, expected_status);
assert_eq!(error.is_some(), expects_error);
}
let (status, error) = classify_internal_turn_result(Err(WorkerError::Engine(
EngineError::Aborted("fatal".to_string()),
)));
assert_eq!(status, InternalWorkerSessionStatus::Failed);
assert!(error.is_some_and(|message| message.contains("fatal")));
}
#[tokio::test]
async fn fatal_internal_run_transitions_to_stopped_protocol_status() {
let calls = Arc::new(AtomicUsize::new(0));
let mut internal_spec = spec(calls, &[]);
internal_spec.client = Box::new(FailingClient);
let handle = spawn_internal_worker_session(internal_spec)
.await
.expect("spawn failing Internal Worker session");
assert_eq!(
handle.wait_until_idle().await,
InternalWorkerSessionStatus::Stopped
);
assert_eq!(handle.status(), InternalWorkerSessionStatus::Stopped);
assert_eq!(handle.protocol_snapshot().status, WorkerStatus::Stopped);
assert!(
handle
.last_error
.lock()
.unwrap()
.as_ref()
.is_some_and(|message| message.contains("intentional internal failure"))
);
}
#[tokio::test] #[tokio::test]
async fn session_accepts_follow_up_turns_and_stops_without_runtime_registration() { async fn session_accepts_follow_up_turns_and_stops_without_runtime_registration() {
let calls = Arc::new(AtomicUsize::new(0)); let calls = Arc::new(AtomicUsize::new(0));
+13 -2
View File
@@ -13,7 +13,7 @@
#[cfg(test)] #[cfg(test)]
use crate::prompt::catalog::PromptCatalog; use crate::prompt::catalog::PromptCatalog;
use agen::Item; use agen::{Item, ToolResultDisposition};
/// Build synthetic `Item::ToolResult` items for every unanswered /// Build synthetic `Item::ToolResult` items for every unanswered
/// `Item::ToolCall` in `history`, preserving order. /// `Item::ToolCall` in `history`, preserving order.
@@ -28,7 +28,16 @@ pub(crate) fn orphan_tool_result_closures(history: &[Item], summary: &str) -> Ve
for item in history { for item in history {
if let Item::ToolCall { call_id, .. } = item { if let Item::ToolCall { call_id, .. } = item {
if !answered.contains(call_id.as_str()) { if !answered.contains(call_id.as_str()) {
out.push(Item::tool_result(call_id.clone(), summary)); out.push(Item::tool_result_item_with_disposition_and_attachments(
call_id.clone(),
summary,
Some(
"Execution ended before completion could be confirmed. Completion and side effects are unknown."
.to_string(),
),
ToolResultDisposition::OutcomeUnknown,
Vec::new(),
));
} }
} }
} }
@@ -77,10 +86,12 @@ mod tests {
Item::ToolResult { Item::ToolResult {
call_id, call_id,
summary: got, summary: got,
disposition,
.. ..
} => { } => {
assert_eq!(call_id, "c1"); assert_eq!(call_id, "c1");
assert_eq!(got, &summary); assert_eq!(got, &summary);
assert_eq!(*disposition, ToolResultDisposition::OutcomeUnknown);
} }
other => panic!("expected ToolResult, got {other:?}"), other => panic!("expected ToolResult, got {other:?}"),
} }
+39 -2
View File
@@ -8,6 +8,7 @@
//! decisions (continue / skip / abort / pause). //! decisions (continue / skip / abort / pause).
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -33,7 +34,9 @@ use crate::hook::{
}; };
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance}; use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
use crate::prompt::catalog::PromptCatalog; use crate::prompt::catalog::PromptCatalog;
use crate::session_history::SessionHistoryMetadata;
use crate::worker::SystemItemCommitter; use crate::worker::SystemItemCommitter;
use agen::HistoryEntry;
use agen::token_counter::total_tokens; use agen::token_counter::total_tokens;
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`. /// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
@@ -73,6 +76,7 @@ pub(crate) struct WorkerInterceptor {
/// worker. `None` in tests / `Worker::new` paths where no writer is /// worker. `None` in tests / `Worker::new` paths where no writer is
/// attached. /// attached.
log_writer: Option<Arc<dyn SystemItemCommitter>>, log_writer: Option<Arc<dyn SystemItemCommitter>>,
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
/// Next turn index assigned by `on_prompt_submit`. /// Next turn index assigned by `on_prompt_submit`.
next_turn_index: AtomicUsize, next_turn_index: AtomicUsize,
/// Tool calls observed in the current turn (reset on each new prompt). /// Tool calls observed in the current turn (reset on each new prompt).
@@ -80,6 +84,7 @@ pub(crate) struct WorkerInterceptor {
} }
impl WorkerInterceptor { impl WorkerInterceptor {
#[cfg(test)]
pub(crate) fn new( pub(crate) fn new(
registry: Arc<HookRegistry>, registry: Arc<HookRegistry>,
compact_state: Option<Arc<CompactState>>, compact_state: Option<Arc<CompactState>>,
@@ -88,6 +93,28 @@ impl WorkerInterceptor {
pending_attachments: Arc<Mutex<Vec<SystemItem>>>, pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
prompts: Arc<ArcSwap<PromptCatalog>>, prompts: Arc<ArcSwap<PromptCatalog>>,
log_writer: Option<Arc<dyn SystemItemCommitter>>, log_writer: Option<Arc<dyn SystemItemCommitter>>,
) -> Self {
Self::new_with_history_queue(
registry,
compact_state,
usage_history,
pending_notifies,
pending_attachments,
prompts,
log_writer,
Arc::new(Mutex::new(VecDeque::new())),
)
}
pub(crate) fn new_with_history_queue(
registry: Arc<HookRegistry>,
compact_state: Option<Arc<CompactState>>,
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
pending_notifies: NotifyBuffer,
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
prompts: Arc<ArcSwap<PromptCatalog>>,
log_writer: Option<Arc<dyn SystemItemCommitter>>,
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
) -> Self { ) -> Self {
Self { Self {
registry, registry,
@@ -99,6 +126,7 @@ impl WorkerInterceptor {
prompts, prompts,
prompt_workspace_id: None, prompt_workspace_id: None,
log_writer, log_writer,
pending_committed_history,
next_turn_index: AtomicUsize::new(0), next_turn_index: AtomicUsize::new(0),
tool_calls_this_turn: AtomicUsize::new(0), tool_calls_this_turn: AtomicUsize::new(0),
} }
@@ -125,7 +153,11 @@ impl WorkerInterceptor {
return Ok(()); return Ok(());
}; };
for item in items { for item in items {
writer.commit_system_item(item.clone())?; let entry = writer.commit_system_item(item.clone())?;
self.pending_committed_history
.lock()
.expect("pending committed history poisoned")
.push_back(entry);
} }
Ok(()) Ok(())
} }
@@ -507,7 +539,12 @@ mod tests {
&self, &self,
entry: session_store::LogEntry, entry: session_store::LogEntry,
) -> Result<(), session_store::StoreError> { ) -> Result<(), session_store::StoreError> {
if let session_store::LogEntry::SystemItem { item, .. } = entry { let item = match entry {
session_store::LogEntry::SystemItem { item, .. } => Some(item),
session_store::LogEntry::AnnotatedSystemItem { entry, .. } => Some(entry.item),
_ => None,
};
if let Some(item) = item {
self.committed self.committed
.lock() .lock()
.expect("committed system-item list poisoned") .expect("committed system-item list poisoned")
+8 -2
View File
@@ -29,15 +29,21 @@ pub fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtoco
pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> { pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
match entry { match entry {
LogEntry::SegmentStart { .. } => { entry @ (LogEntry::SegmentStart { .. } | LogEntry::AnnotatedSegmentStart { .. }) => {
let value = serde_json::to_value(&entry).expect("LogEntry is Serialize"); let value = serde_json::to_value(&entry).expect("LogEntry is Serialize");
Some(Event::SegmentRotated { entry: value }) Some(Event::SegmentRotated { entry: value })
} }
LogEntry::UserInput { segments, .. } => Some(Event::UserMessage { segments }), LogEntry::UserInput { segments, .. } | LogEntry::AnnotatedUserInput { segments, .. } => {
Some(Event::UserMessage { segments })
}
LogEntry::SystemItem { item, .. } => { LogEntry::SystemItem { item, .. } => {
let value = serde_json::to_value(&item).expect("SystemItem is Serialize"); let value = serde_json::to_value(&item).expect("SystemItem is Serialize");
Some(Event::SystemItem { item: value }) Some(Event::SystemItem { item: value })
} }
LogEntry::AnnotatedSystemItem { entry, .. } => {
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
Some(Event::SystemItem { item: value })
}
LogEntry::Invoke { trigger, .. } => Some(Event::InvokeStart { kind: trigger }), LogEntry::Invoke { trigger, .. } => Some(Event::InvokeStart { kind: trigger }),
other => { other => {
// `SegmentLogSink::is_live_relevant` keeps non-live-relevant // `SegmentLogSink::is_live_relevant` keeps non-live-relevant
+8 -2
View File
@@ -12,6 +12,7 @@ pub mod prompt;
pub mod runtime; pub mod runtime;
pub mod segment_log_sink; pub mod segment_log_sink;
mod session_capture; mod session_capture;
mod session_history;
pub mod shared_state; pub mod shared_state;
mod shutdown_after_idle; mod shutdown_after_idle;
pub mod skill; pub mod skill;
@@ -33,14 +34,19 @@ pub use manifest::{
}; };
pub use model_client::{ProviderError, build_client}; pub use model_client::{ProviderError, build_client};
pub use prompt::catalog::{ pub use prompt::catalog::{
CatalogError, EffectivePromptCatalog, PromptCatalog, WorkerPrompt, WorkspacePromptProjection, CatalogError, EffectivePromptCatalog, OrchestratorQueueAttentionContext,
prompt_schema_source, OrchestratorQueueAttentionPrompt, OrchestratorQueueAttentionTicket, PromptCatalog,
WorkerPrompt, WorkspacePromptProjection, prompt_schema_source,
}; };
pub use prompt::source::PromptCatalogSource; pub use prompt::source::PromptCatalogSource;
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus}; pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
pub use runtime::dir::RuntimeDir; pub use runtime::dir::RuntimeDir;
pub use segment_log_sink::SegmentLogSink; pub use segment_log_sink::SegmentLogSink;
pub use session_history::{
SessionHistoryDerivation, SessionHistoryEntryId, SessionHistoryMetadata,
WorkerHistoryProvenance, WorkerSubjectSnapshot,
};
pub use shared_state::WorkerSharedState; pub use shared_state::WorkerSharedState;
pub use worker::{ pub use worker::{
LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError, LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError,
+1 -1
View File
@@ -34,7 +34,7 @@ impl PermissionHook {
} }
} }
impl<C: LlmClient, St: Store> Worker<C, St> { impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
pub(crate) fn apply_permissions_from_manifest(&mut self) { pub(crate) fn apply_permissions_from_manifest(&mut self) {
let Some(permissions) = self.manifest().permissions.clone() else { let Some(permissions) = self.manifest().permissions.clone() else {
return; return;
+155 -3
View File
@@ -102,7 +102,6 @@ pub enum WorkerPrompt {
AgentsMdSection, AgentsMdSection,
ResidentMemorySummarySection, ResidentMemorySummarySection,
WorkerOrchestrationGuidanceSection, WorkerOrchestrationGuidanceSection,
TicketEventCompanionNotice,
SubWorkerSpawnToolDescription, SubWorkerSpawnToolDescription,
} }
@@ -122,7 +121,6 @@ impl WorkerPrompt {
Self::WorkerOrchestrationGuidanceSection => { Self::WorkerOrchestrationGuidanceSection => {
"internal.worker_orchestration_guidance_section" "internal.worker_orchestration_guidance_section"
} }
Self::TicketEventCompanionNotice => "worker.ticket_event_companion_notice",
Self::SubWorkerSpawnToolDescription => "internal.sub_worker_spawn_tool_description", Self::SubWorkerSpawnToolDescription => "internal.sub_worker_spawn_tool_description",
} }
} }
@@ -139,13 +137,97 @@ impl WorkerPrompt {
WorkerPrompt::AgentsMdSection, WorkerPrompt::AgentsMdSection,
WorkerPrompt::ResidentMemorySummarySection, WorkerPrompt::ResidentMemorySummarySection,
WorkerPrompt::WorkerOrchestrationGuidanceSection, WorkerPrompt::WorkerOrchestrationGuidanceSection,
WorkerPrompt::TicketEventCompanionNotice,
WorkerPrompt::SubWorkerSpawnToolDescription, WorkerPrompt::SubWorkerSpawnToolDescription,
]; ];
} }
/// Model-visible queued Ticket projection shared by Server and TUI backlog attention paths.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrchestratorQueueAttentionTicket {
resource_key: String,
title: String,
}
impl OrchestratorQueueAttentionTicket {
pub fn new(
resource_key: impl Into<String>,
title: impl Into<String>,
) -> Result<Self, CatalogError> {
let resource_key = resource_key.into();
if !is_ticket_resource_key(&resource_key) {
return Err(CatalogError::InvalidQueueAttentionResourceKey);
}
Ok(Self {
resource_key,
title: bounded_queue_attention_text(&title.into(), 240),
})
}
}
/// Shared model-visible context for every Orchestrator backlog attention renderer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrchestratorQueueAttentionContext {
tickets: Vec<OrchestratorQueueAttentionTicket>,
separator: &'static str,
omitted_ticket_count: usize,
}
impl OrchestratorQueueAttentionContext {
pub const MAX_TICKETS: usize = 20;
pub fn new(tickets: Vec<OrchestratorQueueAttentionTicket>) -> Self {
let omitted_ticket_count = tickets.len().saturating_sub(Self::MAX_TICKETS);
Self {
tickets: tickets.into_iter().take(Self::MAX_TICKETS).collect(),
separator: "",
omitted_ticket_count,
}
}
}
/// Prompt-catalog entries that must share the same backlog-attention body contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrchestratorQueueAttentionPrompt {
Server,
Tui,
}
impl OrchestratorQueueAttentionPrompt {
fn key(self) -> &'static str {
match self {
Self::Server => "internal.workspace_orchestrator_queue_attention",
Self::Tui => "panel.orchestrator_idle_queue_notice",
}
}
}
fn is_ticket_resource_key(input: &str) -> bool {
input.len() <= 32
&& input.strip_prefix("T-").is_some_and(|suffix| {
!suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())
})
}
fn bounded_queue_attention_text(input: &str, max_chars: usize) -> String {
let mut output = String::new();
for (index, character) in input.chars().enumerate() {
if index == max_chars {
output.push('…');
break;
}
output.push(if character.is_control() {
' '
} else {
character
});
}
output
}
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum CatalogError { pub enum CatalogError {
#[error("queued Ticket resource key is missing or invalid")]
InvalidQueueAttentionResourceKey,
#[error("failed to build builtin Prompt source tree: {0}")] #[error("failed to build builtin Prompt source tree: {0}")]
BuiltinTree(String), BuiltinTree(String),
#[error("failed to evaluate builtin Prompt source tree: {0}")] #[error("failed to evaluate builtin Prompt source tree: {0}")]
@@ -322,6 +404,14 @@ impl PromptCatalog {
self.render_name(key, Value::from_serialize(context)) self.render_name(key, Value::from_serialize(context))
} }
pub fn orchestrator_queue_attention(
&self,
prompt: OrchestratorQueueAttentionPrompt,
context: &OrchestratorQueueAttentionContext,
) -> Result<String, CatalogError> {
self.render_serializable(prompt.key(), context)
}
pub fn render_name(&self, key: &str, ctx: Value) -> Result<String, CatalogError> { pub fn render_name(&self, key: &str, ctx: Value) -> Result<String, CatalogError> {
let template = self let template = self
.env .env
@@ -593,6 +683,12 @@ mod tests {
fn builtin_dcdl_catalog_loads() { fn builtin_dcdl_catalog_loads() {
let catalog = PromptCatalog::builtins_only().unwrap(); let catalog = PromptCatalog::builtins_only().unwrap();
assert!(!catalog.projection.templates.is_empty()); assert!(!catalog.projection.templates.is_empty());
assert!(
!catalog
.projection
.templates
.contains_key("worker.ticket_event_companion_notice")
);
} }
#[test] #[test]
@@ -650,6 +746,62 @@ mod tests {
assert!(reviewer.contains("target-only movement does not invalidate approval")); assert!(reviewer.contains("target-only movement does not invalidate approval"));
} }
#[test]
fn queue_attention_prompts_share_sanitized_contract_and_true_truncation() {
let catalog = PromptCatalog::builtins_only().unwrap();
let tickets = (1..=OrchestratorQueueAttentionContext::MAX_TICKETS + 1)
.map(|index| {
OrchestratorQueueAttentionTicket::new(
format!("T-{index}"),
format!("Ticket {index}\nwith control\u{7}"),
)
.unwrap()
})
.collect();
let context = OrchestratorQueueAttentionContext::new(tickets);
let server = catalog
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context)
.unwrap();
let tui = catalog
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Tui, &context)
.unwrap();
assert_eq!(server, tui);
assert!(server.starts_with("Queued Tickets require attention:"));
assert!(server.contains("- T-1 — Ticket 1 with control "));
assert!(!server.contains("T-21"));
assert!(server.contains("were omitted from this notice: 1"));
assert!(server.contains("Re-query current Ticket authority"));
assert!(server.contains("Reread the current Ticket state before acting"));
for secret in [
"workspace_id",
"Workspace:",
"runtime_id",
"worker_id",
"bounded",
] {
assert!(!server.contains(secret), "leaked {secret}: {server}");
}
}
#[test]
fn queue_attention_prompt_omits_truncation_text_for_complete_list() {
let catalog = PromptCatalog::builtins_only().unwrap();
let context = OrchestratorQueueAttentionContext::new(vec![
OrchestratorQueueAttentionTicket::new("T-541", "Attention contract").unwrap(),
]);
let rendered = catalog
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context)
.unwrap();
assert!(rendered.contains("- T-541 — Attention contract"));
assert!(!rendered.contains("omitted"));
assert!(matches!(
OrchestratorQueueAttentionTicket::new("opaque-id", "must fail"),
Err(CatalogError::InvalidQueueAttentionResourceKey)
));
}
#[test] #[test]
fn graph_rejects_dynamic_legacy_missing_and_cycles() { fn graph_rejects_dynamic_legacy_missing_and_cycles() {
let invalid = BTreeMap::from([ let invalid = BTreeMap::from([
+3
View File
@@ -121,8 +121,11 @@ impl SegmentLogSink {
matches!( matches!(
entry, entry,
LogEntry::SegmentStart { .. } LogEntry::SegmentStart { .. }
| LogEntry::AnnotatedSegmentStart { .. }
| LogEntry::UserInput { .. } | LogEntry::UserInput { .. }
| LogEntry::AnnotatedUserInput { .. }
| LogEntry::SystemItem { .. } | LogEntry::SystemItem { .. }
| LogEntry::AnnotatedSystemItem { .. }
| LogEntry::Invoke { .. } | LogEntry::Invoke { .. }
) )
} }
+144 -20
View File
@@ -6,7 +6,8 @@
use std::sync::Arc; use std::sync::Arc;
use agen::{Item, Role}; use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
use agen::{HistoryEntry, Item, Role};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
const DEFAULT_SEARCH_LIMIT: usize = 20; const DEFAULT_SEARCH_LIMIT: usize = 20;
@@ -21,14 +22,21 @@ const OVERVIEW_ANCHOR_STRIDE: usize = 8;
pub(crate) struct SessionEntryRef(String); pub(crate) struct SessionEntryRef(String);
impl SessionEntryRef { impl SessionEntryRef {
pub(crate) fn new(source_index: usize) -> Self { pub(crate) fn from_history_entry_id(entry_id: &crate::SessionHistoryEntryId) -> Self {
Self(format!("E{source_index:08}")) Self(format!("E{}", entry_id.0))
} }
pub(crate) fn parse(value: &str) -> Option<Self> { pub(crate) fn parse(value: &str) -> Option<Self> {
let reference = Self(value.to_string()); let suffix = value.strip_prefix('E')?;
reference.source_index()?; if suffix.is_empty()
Some(reference) || suffix.len() > 64
|| !suffix
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return None;
}
Some(Self(value.to_string()))
} }
pub(crate) fn as_str(&self) -> &str { pub(crate) fn as_str(&self) -> &str {
@@ -97,6 +105,7 @@ impl ToolPart {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct OverviewItem { pub(crate) struct OverviewItem {
pub id: SessionEntryRef, pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub entry_range: [u64; 2], pub entry_range: [u64; 2],
pub kind: ReferenceKind, pub kind: ReferenceKind,
pub label: String, pub label: String,
@@ -107,6 +116,7 @@ pub(crate) struct OverviewItem {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct ReferenceEntry { pub(crate) struct ReferenceEntry {
pub id: SessionEntryRef, pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub entry_range: [u64; 2], pub entry_range: [u64; 2],
pub kind: ReferenceKind, pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>, pub tool_part: Option<ToolPart>,
@@ -132,6 +142,7 @@ pub(crate) struct SearchOptions {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct SearchHit { pub(crate) struct SearchHit {
pub id: SessionEntryRef, pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub kind: ReferenceKind, pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>, pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>, pub tool_name: Option<String>,
@@ -177,6 +188,7 @@ impl Default for ReadOptions {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct ReadEntry { pub(crate) struct ReadEntry {
pub id: SessionEntryRef, pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub kind: ReferenceKind, pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>, pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>, pub tool_name: Option<String>,
@@ -195,6 +207,7 @@ pub(crate) struct ReadResult {
pub(crate) struct SessionEntryEvidence { pub(crate) struct SessionEntryEvidence {
pub segment_id: String, pub segment_id: String,
pub entry_ref: SessionEntryRef, pub entry_ref: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub entry_range: [u64; 2], pub entry_range: [u64; 2],
pub kind: ReferenceKind, pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>, pub tool_part: Option<ToolPart>,
@@ -206,26 +219,42 @@ pub(crate) struct SessionEntryEvidence {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct SessionCapture { pub(crate) struct SessionCapture {
segment_id: String, segment_id: String,
items: Arc<Vec<Item>>, entries: Arc<Vec<HistoryEntry<SessionHistoryMetadata>>>,
overview: Vec<OverviewItem>, overview: Vec<OverviewItem>,
index: Vec<ReferenceEntry>, index: Vec<ReferenceEntry>,
} }
impl SessionCapture { impl SessionCapture {
pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self { pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self {
let entries = items
.into_iter()
.enumerate()
.map(|(index, item)| {
let mut metadata = SessionHistoryMetadata::legacy_unknown();
metadata.entry_id =
session_store::LoggedSessionHistoryEntryId(format!("{index:08}"));
HistoryEntry::new(item, metadata)
})
.collect();
Self::from_history_entries(segment_id, entries)
}
pub(crate) fn from_history_entries(
segment_id: impl Into<String>,
entries: Vec<HistoryEntry<SessionHistoryMetadata>>,
) -> Self {
let segment_id = segment_id.into(); let segment_id = segment_id.into();
let items = Arc::new(items); let entries = Arc::new(entries);
let mut overview = Vec::new(); let mut overview = Vec::new();
let mut index = Vec::new(); let mut index = Vec::new();
for (idx, item) in items.iter().enumerate() { for (idx, entry) in entries.iter().enumerate() {
let item = &entry.item;
let entry_range = [idx as u64, idx as u64]; let entry_range = [idx as u64, idx as u64];
match item { match item {
Item::Message { role, content, .. } => { Item::Message { role, content, .. } => {
let kind = match role { let Some(kind) = message_reference_kind(&entry.annotation.origin, role) else {
Role::User => ReferenceKind::User, continue;
Role::Assistant => ReferenceKind::Assistant,
Role::System => continue,
}; };
let text = content let text = content
.iter() .iter()
@@ -234,9 +263,10 @@ impl SessionCapture {
.join(""); .join("");
let label = format!("{} message", kind.as_str()); let label = format!("{} message", kind.as_str());
let summary = truncate_chars(&text, 240); let summary = truncate_chars(&text, 240);
let id = SessionEntryRef::new(idx); let id = SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id);
index.push(ReferenceEntry { index.push(ReferenceEntry {
id: id.clone(), id: id.clone(),
origin: entry.annotation.origin.clone(),
entry_range, entry_range,
kind, kind,
tool_part: None, tool_part: None,
@@ -248,6 +278,7 @@ impl SessionCapture {
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) { if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
overview.push(OverviewItem { overview.push(OverviewItem {
id: id.clone(), id: id.clone(),
origin: entry.annotation.origin.clone(),
entry_range, entry_range,
kind, kind,
label, label,
@@ -261,7 +292,8 @@ impl SessionCapture {
} => { } => {
let text = format!("{name}\n{arguments}"); let text = format!("{name}\n{arguments}");
index.push(ReferenceEntry { index.push(ReferenceEntry {
id: SessionEntryRef::new(idx), id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
origin: entry.annotation.origin.clone(),
entry_range, entry_range,
kind: ReferenceKind::Tool, kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Input), tool_part: Some(ToolPart::Input),
@@ -287,7 +319,8 @@ impl SessionCapture {
content.as_deref().unwrap_or_default(), content.as_deref().unwrap_or_default(),
); );
index.push(ReferenceEntry { index.push(ReferenceEntry {
id: SessionEntryRef::new(idx), id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
origin: entry.annotation.origin.clone(),
entry_range, entry_range,
kind: ReferenceKind::Tool, kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Output), tool_part: Some(ToolPart::Output),
@@ -327,7 +360,7 @@ impl SessionCapture {
Self { Self {
segment_id, segment_id,
items, entries,
overview, overview,
index, index,
} }
@@ -337,6 +370,14 @@ impl SessionCapture {
&self.overview &self.overview
} }
pub(crate) fn source_index_for_ref(&self, reference: &SessionEntryRef) -> Option<u64> {
self.index
.iter()
.find(|entry| entry.id == *reference)
.map(|entry| entry.entry_range[0])
.or_else(|| reference.source_index())
}
pub(crate) fn search(&self, options: &SearchOptions) -> Vec<SearchHit> { pub(crate) fn search(&self, options: &SearchOptions) -> Vec<SearchHit> {
let query = options.query.trim().to_lowercase(); let query = options.query.trim().to_lowercase();
let limit = options let limit = options
@@ -347,12 +388,12 @@ impl SessionCapture {
let min_entry_index = options let min_entry_index = options
.from .from
.as_ref() .as_ref()
.and_then(SessionEntryRef::source_index) .and_then(|reference| self.source_index_for_ref(reference))
.unwrap_or_else(|| options.min_entry_index.unwrap_or(0)); .unwrap_or_else(|| options.min_entry_index.unwrap_or(0));
let max_entry_index = options let max_entry_index = options
.through .through
.as_ref() .as_ref()
.and_then(SessionEntryRef::source_index) .and_then(|reference| self.source_index_for_ref(reference))
.unwrap_or(u64::MAX); .unwrap_or(u64::MAX);
let mut skipped = 0usize; let mut skipped = 0usize;
let mut hits = Vec::new(); let mut hits = Vec::new();
@@ -391,6 +432,7 @@ impl SessionCapture {
} }
hits.push(SearchHit { hits.push(SearchHit {
id: entry.id.clone(), id: entry.id.clone(),
origin: entry.origin.clone(),
kind: entry.kind, kind: entry.kind,
tool_part: entry.tool_part, tool_part: entry.tool_part,
tool_name: entry.tool_name.clone(), tool_name: entry.tool_name.clone(),
@@ -442,13 +484,18 @@ impl SessionCapture {
} }
} }
} }
let Some(item) = self.items.get(entry.entry_range[0] as usize) else { let Some(item) = self
.entries
.get(entry.entry_range[0] as usize)
.map(|entry| &entry.item)
else {
continue; continue;
}; };
let text = render_item(item, entry, options.detail, max_bytes.saturating_sub(bytes)); let text = render_item(item, entry, options.detail, max_bytes.saturating_sub(bytes));
bytes = bytes.saturating_add(text.len()); bytes = bytes.saturating_add(text.len());
entries.push(ReadEntry { entries.push(ReadEntry {
id: entry.id.clone(), id: entry.id.clone(),
origin: entry.origin.clone(),
kind: entry.kind, kind: entry.kind,
tool_part: entry.tool_part, tool_part: entry.tool_part,
tool_name: entry.tool_name.clone(), tool_name: entry.tool_name.clone(),
@@ -485,6 +532,7 @@ impl SessionCapture {
Some(SessionEntryEvidence { Some(SessionEntryEvidence {
segment_id: self.segment_id.clone(), segment_id: self.segment_id.clone(),
entry_ref: entry.id.clone(), entry_ref: entry.id.clone(),
origin: entry.origin.clone(),
entry_range: entry.entry_range, entry_range: entry.entry_range,
kind: entry.kind, kind: entry.kind,
tool_part: entry.tool_part, tool_part: entry.tool_part,
@@ -495,6 +543,28 @@ impl SessionCapture {
} }
} }
fn message_reference_kind(
origin: &WorkerHistoryProvenance,
provider_role: &Role,
) -> Option<ReferenceKind> {
match origin {
WorkerHistoryProvenance::HumanInput { .. }
| WorkerHistoryProvenance::WorkerInput { .. } => Some(ReferenceKind::User),
WorkerHistoryProvenance::ModelOutput { .. } => Some(ReferenceKind::Assistant),
WorkerHistoryProvenance::ToolOutput { .. } => Some(ReferenceKind::Tool),
WorkerHistoryProvenance::LegacyUnknown => match provider_role {
Role::User => Some(ReferenceKind::User),
Role::Assistant => Some(ReferenceKind::Assistant),
Role::System => None,
},
// Flow/backend/system content remains out of the observation surface
// even when represented with a provider user/system role.
WorkerHistoryProvenance::FlowInstruction { .. }
| WorkerHistoryProvenance::BackendInstruction { .. }
| WorkerHistoryProvenance::DerivedSummary => None,
}
}
fn render_item( fn render_item(
item: &Item, item: &Item,
entry: &ReferenceEntry, entry: &ReferenceEntry,
@@ -563,6 +633,60 @@ fn truncate_chars(text: &str, max_chars: usize) -> String {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn flow_user_role_is_excluded_while_explicit_human_origin_remains_evidence() {
let entries = vec![
crate::session_history::history_entry(
Item::user_message("trusted flow instruction"),
WorkerHistoryProvenance::FlowInstruction {
selector: "builtin:coder-review".into(),
definition_id: "coder-review".into(),
definition_revision: 3,
instance_id: "instance".into(),
state_id: "implement".into(),
},
),
crate::session_history::history_entry(
Item::user_message("remember my preference"),
WorkerHistoryProvenance::HumanInput {
account_id: "account-1".into(),
},
),
];
let capture = SessionCapture::from_history_entries("segment", entries);
let overview = capture.overview();
assert_eq!(overview.len(), 1);
assert!(matches!(
overview[0].origin,
WorkerHistoryProvenance::HumanInput { .. }
));
let evidence = capture.evidence_for(overview[0].id.as_str()).unwrap();
assert!(evidence.excerpt.ends_with("remember my preference"));
assert!(matches!(
evidence.origin,
WorkerHistoryProvenance::HumanInput { .. }
));
}
#[test]
fn stable_logical_ref_survives_retention_and_restore_projection() {
let retained = crate::session_history::history_entry(
Item::assistant_message("retained"),
WorkerHistoryProvenance::ModelOutput {
worker: crate::session_history::worker_subject(Default::default()),
},
);
let expected_ref = SessionEntryRef::from_history_entry_id(&retained.annotation.entry_id);
let before = SessionCapture::from_history_entries("old", vec![retained.clone()]);
let after = SessionCapture::from_history_entries("new", vec![retained]);
assert_eq!(before.overview()[0].id, expected_ref);
assert_eq!(after.overview()[0].id, expected_ref);
assert_eq!(
after.evidence_for(expected_ref.as_str()).unwrap().entry_ref,
expected_ref
);
}
#[test] #[test]
fn overview_contains_user_and_assistant_only() { fn overview_contains_user_and_assistant_only() {
let view = SessionCapture::new( let view = SessionCapture::new(
+219
View File
@@ -0,0 +1,219 @@
//! Restore-authoritative metadata for model-visible Worker history.
//!
//! Agen transports this annotation without interpreting it. Session Log v2
//! stores each item and metadata in one typed record; legacy records are
//! retained only as explicit `LegacyUnknown` entries.
use agen::{HistoryEntry, Item};
use protocol::Segment;
use session_store::{
LogEntry, LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedWorkerSubject, SegmentId,
SessionId,
};
pub type SessionHistoryEntryId = LoggedSessionHistoryEntryId;
pub type SessionHistoryMetadata = LoggedSessionHistoryMetadata;
pub type WorkerHistoryProvenance = LoggedSessionHistoryOrigin;
pub type SessionHistoryDerivation = LoggedHistoryDerivation;
pub type WorkerSubjectSnapshot = LoggedWorkerSubject;
pub(crate) fn worker_subject(session_id: SessionId) -> WorkerSubjectSnapshot {
WorkerSubjectSnapshot {
workspace_id: None,
runtime_id: None,
worker_id: session_id.to_string(),
}
}
pub(crate) fn metadata(
origin: WorkerHistoryProvenance,
derivation: Option<SessionHistoryDerivation>,
) -> SessionHistoryMetadata {
SessionHistoryMetadata {
entry_id: SessionHistoryEntryId::new(),
origin,
derivation,
}
}
pub(crate) fn history_entry(
item: Item,
origin: WorkerHistoryProvenance,
) -> HistoryEntry<SessionHistoryMetadata> {
HistoryEntry::new(item, metadata(origin, None))
}
pub(crate) fn to_logged_history_entry(
entry: &HistoryEntry<SessionHistoryMetadata>,
) -> LoggedHistoryEntry {
LoggedHistoryEntry {
item: entry.item.clone().into(),
metadata: entry.annotation.clone(),
}
}
fn legacy_entry(item: Item) -> HistoryEntry<SessionHistoryMetadata> {
HistoryEntry::new(item, SessionHistoryMetadata::legacy_unknown())
}
fn from_logged(entry: &LoggedHistoryEntry) -> HistoryEntry<SessionHistoryMetadata> {
HistoryEntry::new(Item::from(entry.item.clone()), entry.metadata.clone())
}
/// Rebuild typed Worker history directly from the append-only Session Log.
/// Missing legacy metadata is never inferred from role or plaintext.
pub(crate) fn restore_history_entries(
_session_id: SessionId,
_segment_id: SegmentId,
entries: &[LogEntry],
) -> Result<Vec<HistoryEntry<SessionHistoryMetadata>>, String> {
let mut history = Vec::new();
for entry in entries {
match entry {
LogEntry::AnnotatedSegmentStart { history: seed, .. } => {
history = seed.iter().map(from_logged).collect();
}
LogEntry::SegmentStart { history: seed, .. } => {
history = seed
.iter()
.cloned()
.map(Item::from)
.map(legacy_entry)
.collect();
}
LogEntry::AnnotatedUserInput { history: input, .. } => {
history.extend(input.iter().map(from_logged))
}
LogEntry::UserInput { segments, .. } => history.push(legacy_entry(Item::user_message(
Segment::flatten_to_text(segments),
))),
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => history.push(from_logged(entry)),
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
history.push(legacy_entry(Item::from(item.clone())));
}
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
entry.item.to_history_item(),
entry.metadata.clone(),
)),
LogEntry::SystemItem { item, .. } => {
history.push(legacy_entry(item.to_history_item()));
}
_ => {}
}
}
Ok(history)
}
#[cfg(test)]
mod tests {
use super::*;
use agen::llm_client::RequestConfig;
use session_store::LogEntry;
#[test]
fn legacy_user_role_is_not_inferred_as_human_authority() {
let entries = vec![LogEntry::UserInput {
ts: 1,
segments: vec![Segment::text("legacy")],
extensions: Vec::new(),
}];
let restored =
restore_history_entries(SessionId::now_v7(), SegmentId::now_v7(), &entries).unwrap();
assert!(matches!(
restored[0].annotation.origin,
WorkerHistoryProvenance::LegacyUnknown
));
}
#[test]
fn typed_flow_and_unknown_caller_input_round_trip_without_role_inference() {
let session_id = SessionId::now_v7();
let projected = vec![
history_entry(
Item::user_message("flow instructions"),
WorkerHistoryProvenance::FlowInstruction {
selector: "builtin:coder-review".to_string(),
definition_id: "coder-review".to_string(),
definition_revision: 7,
instance_id: "flow-instance".to_string(),
state_id: "implement".to_string(),
},
),
history_entry(
Item::user_message("implement"),
WorkerHistoryProvenance::LegacyUnknown,
),
];
let entries = vec![
LogEntry::AnnotatedSegmentStart {
ts: 0,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: Vec::new(),
forked_from: None,
compacted_from: None,
},
LogEntry::AnnotatedUserInput {
ts: 1,
segments: vec![
Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
Segment::text("implement"),
],
extensions: Vec::new(),
history: projected.iter().map(to_logged_history_entry).collect(),
},
];
let restored = restore_history_entries(session_id, SegmentId::now_v7(), &entries).unwrap();
assert_eq!(restored, projected);
}
#[test]
fn annotated_restore_preserves_logical_ids_across_reboot() {
let session_id = SessionId::now_v7();
let entry = history_entry(
Item::assistant_message("persisted"),
WorkerHistoryProvenance::ModelOutput {
worker: worker_subject(session_id),
},
);
let log = vec![LogEntry::AnnotatedSegmentStart {
ts: 0,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: vec![to_logged_history_entry(&entry)],
forked_from: None,
compacted_from: None,
}];
let first = restore_history_entries(session_id, SegmentId::now_v7(), &log).unwrap();
let second = restore_history_entries(session_id, SegmentId::now_v7(), &log).unwrap();
assert_eq!(first[0].annotation.entry_id, entry.annotation.entry_id);
assert_eq!(second[0].annotation.entry_id, entry.annotation.entry_id);
}
#[test]
fn compacted_derivation_uses_stable_logical_entry_ids() {
let source = history_entry(
Item::user_message("source"),
WorkerHistoryProvenance::LegacyUnknown,
);
let summary = HistoryEntry::new(
Item::system_message("summary"),
metadata(
WorkerHistoryProvenance::DerivedSummary,
Some(SessionHistoryDerivation {
sources: vec![source.annotation.entry_id.clone()],
}),
),
);
assert_eq!(
summary.annotation.derivation.unwrap().sources,
vec![source.annotation.entry_id]
);
}
}
+9 -6
View File
@@ -499,7 +499,10 @@ impl Tool for SubWorkerSpawnTool {
InternalWorkerVisibility::ParentClient, InternalWorkerVisibility::ParentClient,
Some(child_registry.clone()), Some(child_registry.clone()),
Some(Arc::new(move |status| { Some(Arc::new(move |status| {
if status == InternalWorkerSessionStatus::Failed { if matches!(
status,
InternalWorkerSessionStatus::Failed | InternalWorkerSessionStatus::Stopped
) {
if let Some(registry) = registry.upgrade() { if let Some(registry) = registry.upgrade() {
if let Err(error) = registry.reclaim_internal_scope(&child_name) { if let Err(error) = registry.reclaim_internal_scope(&child_name) {
tracing::warn!( tracing::warn!(
@@ -1249,7 +1252,7 @@ extract_threshold = 4000
) )
.await .await
.unwrap(); .unwrap();
assert!(first_capture.items.iter().any(|item| { assert!(first_capture.entries.iter().map(|entry| &entry.item).any(|item| {
matches!(item, Item::Message { role: Role::Assistant, content, .. } if content.iter().any(|part| matches!(part, ContentPart::Text { text } if text.contains("reviewed")))) matches!(item, Item::Message { role: Role::Assistant, content, .. } if content.iter().any(|part| matches!(part, ContentPart::Text { text } if text.contains("reviewed"))))
})); }));
@@ -1271,7 +1274,7 @@ extract_threshold = 4000
) )
.await .await
.unwrap(); .unwrap();
assert!(latest_capture.items.len() > first_capture.items.len()); assert!(latest_capture.entries.len() > first_capture.entries.len());
fail_requests.store(true, Ordering::SeqCst); fail_requests.store(true, Ordering::SeqCst);
send.execute( send.execute(
@@ -1282,16 +1285,16 @@ extract_threshold = 4000
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
record.session.wait_until_idle().await, record.session.wait_until_idle().await,
InternalWorkerSessionStatus::Failed InternalWorkerSessionStatus::Stopped
); );
assert_eq!(calls.load(Ordering::SeqCst), 3); assert_eq!(calls.load(Ordering::SeqCst), 3);
assert!( assert!(
spawner_scope.snapshot().is_writable(&workspace_root), spawner_scope.snapshot().is_writable(&workspace_root),
"Failed terminal child must release its delegated Workdir session" "Stopped terminal child must release its delegated Workdir session"
); );
assert!( assert!(
!record.workdir_delegation.is_active(), !record.workdir_delegation.is_active(),
"failed child must revoke cloned scoped sessions" "stopped child must revoke cloned scoped sessions"
); );
assert!(registry.get_internal("reviewer-child").is_some()); assert!(registry.get_internal("reviewer-child").is_some());
+1190 -241
View File
File diff suppressed because it is too large Load Diff
+22 -4
View File
@@ -163,7 +163,8 @@ async fn make_worker_with_manifest(
let scope = worker::Scope::writable(&pwd).unwrap(); let scope = worker::Scope::writable(&pwd).unwrap();
std::mem::forget(pwd_tmp); std::mem::forget(pwd_tmp);
let worker = Engine::new(client); let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new( let mut worker = Worker::new(
manifest, manifest,
worker, worker,
@@ -204,7 +205,14 @@ fn system_texts_in_sink_session_start(
) -> Vec<String> { ) -> Vec<String> {
let (entries, _rx) = worker.sink().subscribe_with_snapshot(); let (entries, _rx) = worker.sink().subscribe_with_snapshot();
for entry in entries.into_iter().rev() { for entry in entries.into_iter().rev() {
if let session_store::LogEntry::SegmentStart { history, .. } = entry { let history = match entry {
session_store::LogEntry::AnnotatedSegmentStart { history, .. } => history
.into_iter()
.map(|entry| entry.item)
.collect::<Vec<_>>(),
session_store::LogEntry::SegmentStart { history, .. } => history,
_ => continue,
};
return history return history
.into_iter() .into_iter()
.filter_map(|logged| { .filter_map(|logged| {
@@ -226,7 +234,6 @@ fn system_texts_in_sink_session_start(
}) })
.collect(); .collect();
} }
}
Vec::new() Vec::new()
} }
@@ -337,7 +344,12 @@ permission = "write"
// New segment records forked_from pointing at the source. // New segment records forked_from pointing at the source.
let new_entries = store.read_all(session_id, new_segment_id).unwrap(); let new_entries = store.read_all(session_id, new_segment_id).unwrap();
match &new_entries[0] { match &new_entries[0] {
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
session_id: seg_session,
forked_from: Some(origin),
..
}
| LogEntry::SegmentStart {
session_id: seg_session, session_id: seg_session,
forked_from: Some(origin), forked_from: Some(origin),
.. ..
@@ -587,6 +599,9 @@ model_id = "test-model"
max_tokens = 100 max_tokens = 100
[memory] [memory]
workspace_id = "test-workspace"
settings_revision = 1
language = "English"
extract_threshold = 1 extract_threshold = 1
[compaction] [compaction]
@@ -749,6 +764,9 @@ model_id = "test-model"
max_tokens = 100 max_tokens = 100
[memory] [memory]
workspace_id = "test-workspace"
settings_revision = 1
language = "English"
extract_threshold = 1 extract_threshold = 1
[[scope.allow]] [[scope.allow]]
+72 -29
View File
@@ -32,16 +32,29 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
let mut items = Vec::new(); let mut items = Vec::new();
for entry in entries { for entry in entries {
match entry { match entry {
LogEntry::AnnotatedSegmentStart { history, .. } => {
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
}
LogEntry::SegmentStart { history, .. } => { LogEntry::SegmentStart { history, .. } => {
items.extend(history.into_iter().map(Item::from)); items.extend(history.into_iter().map(Item::from));
} }
LogEntry::AnnotatedUserInput { history, .. } => {
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
}
LogEntry::UserInput { segments, .. } => { LogEntry::UserInput { segments, .. } => {
let text = protocol::Segment::flatten_to_text(&segments); let text = protocol::Segment::flatten_to_text(&segments);
items.push(Item::user_message(text)); items.push(Item::user_message(text));
} }
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => {
items.push(Item::from(entry.item));
}
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => { LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
items.push(Item::from(item)); items.push(Item::from(item));
} }
LogEntry::AnnotatedSystemItem { entry, .. } => {
items.push(entry.item.to_history_item());
}
LogEntry::SystemItem { item, .. } => { LogEntry::SystemItem { item, .. } => {
items.push(item.to_history_item()); items.push(item.to_history_item());
} }
@@ -51,6 +64,14 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
items items
} }
fn system_item(entry: &LogEntry) -> Option<&session_store::SystemItem> {
match entry {
LogEntry::AnnotatedSystemItem { entry, .. } => Some(&entry.item),
LogEntry::SystemItem { item, .. } => Some(item),
_ => None,
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mock LLM Client // Mock LLM Client
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -192,7 +213,8 @@ async fn make_worker_with_pwd_and_manifest(
let scope = manifest::Scope::writable(&pwd).unwrap(); let scope = manifest::Scope::writable(&pwd).unwrap();
std::mem::forget(pwd_tmp); std::mem::forget(pwd_tmp);
let worker = Engine::new(client); let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let authority = WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()); let authority = WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone());
let worker = Worker::new( let worker = Worker::new(
manifest, manifest,
@@ -784,13 +806,30 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
let client = MockClient::sequential(vec![MockResponse::Hang(simple_text_events())]); let client = MockClient::sequential(vec![MockResponse::Hang(simple_text_events())]);
let worker = make_worker(client).await; let worker = make_worker(client).await;
let handle = spawn_controller(worker).await; let handle = spawn_controller(worker).await;
let mut events = handle.subscribe();
handle handle
.send(Method::run_text("hello in-flight")) .send(Method::run_text("hello in-flight"))
.await .await
.unwrap(); .unwrap();
wait_for_status(&handle, WorkerStatus::Running).await; tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
if matches!(
events.recv().await,
Ok(Event::Status {
status: WorkerStatus::Running,
})
) {
break;
}
}
})
.await
.expect("running status event");
// The Running event is the in-flight visibility fence: the committed
// annotated input must already be available to an immediately attaching
// subscriber rather than racing behind this status transition.
let stream = tokio::net::UnixStream::connect(handle.runtime_dir.socket_path()) let stream = tokio::net::UnixStream::connect(handle.runtime_dir.socket_path())
.await .await
.unwrap(); .unwrap();
@@ -804,10 +843,12 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
// Walk the entries, find a `LogEntry::UserInput` and // Walk the entries, find a `LogEntry::UserInput` and
// confirm its segments flatten to our submitted text. // confirm its segments flatten to our submitted text.
let mut found = false; let mut found = false;
for value in entries { for value in &entries {
let entry: session_store::LogEntry = let entry: session_store::LogEntry =
serde_json::from_value(value).expect("LogEntry deserialise"); serde_json::from_value(value.clone()).expect("LogEntry deserialise");
if let session_store::LogEntry::UserInput { segments, .. } = entry { if let session_store::LogEntry::UserInput { segments, .. }
| session_store::LogEntry::AnnotatedUserInput { segments, .. } = entry
{
let text = protocol::Segment::flatten_to_text(&segments); let text = protocol::Segment::flatten_to_text(&segments);
if text == "hello in-flight" { if text == "hello in-flight" {
found = true; found = true;
@@ -815,7 +856,10 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
} }
} }
} }
assert!(found, "snapshot must carry the in-flight UserInput entry"); assert!(
found,
"snapshot must carry the in-flight UserInput entry: {entries:?}"
);
return; return;
} }
Event::Alert(_) => continue, Event::Alert(_) => continue,
@@ -1086,7 +1130,7 @@ async fn run_with_paste_segment_inlines_content_and_emits_typed_user_message() {
_ => {} _ => {}
}, },
entry = entry_rx.recv() => match entry { entry = entry_rx.recv() => match entry {
Ok(session_store::LogEntry::UserInput { segments, .. }) => { Ok(session_store::LogEntry::UserInput { segments, .. } | session_store::LogEntry::AnnotatedUserInput { segments, .. }) => {
user_input_segments = Some(segments); user_input_segments = Some(segments);
if saw_turn_end { if saw_turn_end {
break; break;
@@ -1317,11 +1361,8 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
let (entries, _) = handle.sink.subscribe_with_snapshot(); let (entries, _) = handle.sink.subscribe_with_snapshot();
let saw_notify_in_mirror = entries.iter().any(|e| { let saw_notify_in_mirror = entries.iter().any(|e| {
matches!( matches!(
e, system_item(e),
session_store::LogEntry::SystemItem { Some(session_store::SystemItem::Notification { message, .. }) if message == "turn finished"
item: session_store::SystemItem::Notification { message, .. },
..
} if message == "turn finished"
) )
}); });
assert!( assert!(
@@ -1463,14 +1504,11 @@ async fn worker_event_turn_ended_while_idle_auto_starts_turn_and_injects_system_
let (entries, _) = handle.sink.subscribe_with_snapshot(); let (entries, _) = handle.sink.subscribe_with_snapshot();
let saw_worker_event_in_mirror = entries.iter().any(|e| { let saw_worker_event_in_mirror = entries.iter().any(|e| {
matches!( matches!(
e, system_item(e),
session_store::LogEntry::SystemItem { Some(session_store::SystemItem::WorkerEvent {
item: session_store::SystemItem::WorkerEvent {
event: protocol::WorkerEvent::TurnEnded { worker_name }, event: protocol::WorkerEvent::TurnEnded { worker_name },
.. ..
}, }) if worker_name == "child"
..
} if worker_name == "child"
) )
}); });
assert!( assert!(
@@ -1552,14 +1590,11 @@ async fn worker_event_scope_sub_delegated_while_idle_stays_control_plane_only()
let (entries, _) = handle.sink.subscribe_with_snapshot(); let (entries, _) = handle.sink.subscribe_with_snapshot();
let saw_scope_event_in_mirror = entries.iter().any(|entry| { let saw_scope_event_in_mirror = entries.iter().any(|entry| {
matches!( matches!(
entry, system_item(entry),
session_store::LogEntry::SystemItem { Some(session_store::SystemItem::WorkerEvent {
item: session_store::SystemItem::WorkerEvent {
event: protocol::WorkerEvent::ScopeSubDelegated { .. }, event: protocol::WorkerEvent::ScopeSubDelegated { .. },
.. ..
}, })
..
}
) )
}); });
assert!( assert!(
@@ -2134,9 +2169,13 @@ async fn paused_then_run_closes_orphan_tool_use_for_next_request() {
for item in items { for item in items {
match item { match item {
agen::Item::ToolResult { agen::Item::ToolResult {
call_id, summary, .. call_id,
summary,
disposition,
..
} if call_id == "call_orphan" => { } if call_id == "call_orphan" => {
assert_eq!(summary, "[Interrupted by user]"); assert_eq!(summary, "Tool execution outcome unknown");
assert_eq!(*disposition, agen::ToolResultDisposition::OutcomeUnknown);
saw_synthetic_tool_result = true; saw_synthetic_tool_result = true;
} }
agen::Item::Message { role, content, .. } if *role == agen::Role::System => { agen::Item::Message { role, content, .. } if *role == agen::Role::System => {
@@ -2327,8 +2366,11 @@ async fn paused_cancel_abandons_resume_and_next_input_is_fresh_run() {
assert!( assert!(
items.iter().any(|item| matches!( items.iter().any(|item| matches!(
item, item,
agen::Item::ToolResult { call_id, summary, .. } agen::Item::ToolResult {
if call_id == "call_cancelled" && summary == "[Interrupted by user]" call_id,
disposition: agen::ToolResultDisposition::OutcomeUnknown,
..
} if call_id == "call_cancelled"
)), )),
"paused cancel should close orphan tool_use before future requests: {items:?}" "paused cancel should close orphan tool_use before future requests: {items:?}"
); );
@@ -2373,7 +2415,8 @@ async fn snapshot_contains_user_input(handle: &WorkerHandle, needle: &str) -> bo
let entry: session_store::LogEntry = let entry: session_store::LogEntry =
serde_json::from_value(value).expect("LogEntry deserialise"); serde_json::from_value(value).expect("LogEntry deserialise");
match entry { match entry {
session_store::LogEntry::UserInput { segments, .. } => { session_store::LogEntry::UserInput { segments, .. }
| session_store::LogEntry::AnnotatedUserInput { segments, .. } => {
protocol::Segment::flatten_to_text(&segments).contains(needle) protocol::Segment::flatten_to_text(&segments).contains(needle)
} }
_ => false, _ => false,
+6 -3
View File
@@ -188,7 +188,8 @@ async fn make_worker(
let pwd = pwd_tmp.path().to_path_buf(); let pwd = pwd_tmp.path().to_path_buf();
let scope = worker::Scope::writable(&pwd).unwrap(); let scope = worker::Scope::writable(&pwd).unwrap();
let mut worker = Engine::new(client); let mut worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
worker.register_tool(big_content_tool_definition(tool_name)); worker.register_tool(big_content_tool_definition(tool_name));
let worker = Worker::new( let worker = Worker::new(
@@ -460,7 +461,8 @@ async fn metric_write_failure_emits_warn_alert_and_does_not_abort_run() {
// protected token budget covers the only user message). That is enough to drive // protected token budget covers the only user message). That is enough to drive
// the failure path: at least one metric attempts to write. // the failure path: at least one metric attempts to write.
let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]); let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]);
let worker = Engine::new(client); let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new( let mut worker = Worker::new(
manifest, manifest,
worker, worker,
@@ -536,7 +538,8 @@ permission = "write"
let pwd_tmp = tempfile::tempdir().unwrap(); let pwd_tmp = tempfile::tempdir().unwrap();
let pwd = pwd_tmp.path().to_path_buf(); let pwd = pwd_tmp.path().to_path_buf();
let scope = worker::Scope::writable(&pwd).unwrap(); let scope = worker::Scope::writable(&pwd).unwrap();
let worker = Engine::new(client); let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new( let mut worker = Worker::new(
manifest, manifest,
worker, worker,
@@ -130,7 +130,8 @@ async fn make_worker_with_body(
EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap(); EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap();
let loader = PromptCatalogSource::builtins_only().with_effective_catalog(projection); let loader = PromptCatalogSource::builtins_only().with_effective_catalog(projection);
let worker = Engine::new(client); let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new( let mut worker = Worker::new(
manifest, manifest,
worker, worker,
+3 -19
View File
@@ -247,24 +247,6 @@ pub struct RuntimeSourceSummary {
pub note: String, pub note: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeCapabilitySummary {
pub can_list_hosts: bool,
pub can_list_workers: bool,
pub can_get_worker: bool,
pub can_spawn_worker: bool,
pub can_stop_worker: bool,
pub has_workspace_fs: bool,
pub has_shell: bool,
pub has_git: bool,
pub supports_worktrees: bool,
pub supports_backend_internal_tools: bool,
pub workspace_scope: String,
pub max_workers: usize,
pub os: String,
pub arch: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeSummary { pub struct RuntimeSummary {
pub runtime_id: String, pub runtime_id: String,
@@ -274,7 +256,9 @@ pub struct RuntimeSummary {
pub source: RuntimeSourceSummary, pub source: RuntimeSourceSummary,
#[serde(default)] #[serde(default)]
pub host_ids: Vec<String>, pub host_ids: Vec<String>,
pub capabilities: RuntimeCapabilitySummary, pub worker_creation_available: bool,
pub os: String,
pub arch: String,
#[serde(default)] #[serde(default)]
pub diagnostics: Vec<Diagnostic>, pub diagnostics: Vec<Diagnostic>,
} }
+1
View File
@@ -48,6 +48,7 @@ tracing.workspace = true
ts-rs = { version = "12.0.1", optional = true } ts-rs = { version = "12.0.1", optional = true }
url.workspace = true url.workspace = true
uuid = { workspace = true, features = ["v7"] } uuid = { workspace = true, features = ["v7"] }
zeroize.workspace = true
webauthn-rs = { workspace = true } webauthn-rs = { workspace = true }
[dev-dependencies] [dev-dependencies]
+46 -11
View File
@@ -22,7 +22,7 @@ use crate::records::{
TicketEvidenceEvent, TicketEvidenceSummary, TicketListPageRequest, TicketMergeRequestSummary, TicketEvidenceEvent, TicketEvidenceSummary, TicketListPageRequest, TicketMergeRequestSummary,
TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketRelationView, TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketRelationView,
TicketRoleAssignmentSummary, TicketShowRequest, TicketSummary, TicketSummaryPage, TicketRoleAssignmentSummary, TicketShowRequest, TicketSummary, TicketSummaryPage,
summarize_body, truncate_body, validate_project_id, summarize_body, truncate_body,
}; };
use crate::store::{ use crate::store::{
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord, ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
@@ -633,7 +633,15 @@ impl SqliteWorkspaceAuthority {
predicates.push(format!("o.updated_at<{value}")); predicates.push(format!("o.updated_at<{value}"));
} }
if let Some(value) = &query.linked_ticket_id { if let Some(value) = &query.linked_ticket_id {
let value = bind(SqlValue::Text(value.clone())); let resolved = self
.store
.resolve_resource_reference(
&self.workspace_id,
WorkspaceResourceKind::Ticket,
value,
)?
.ok_or_else(|| invalid_objective_error("linked Ticket was not found"))?;
let value = bind(SqlValue::Text(resolved));
predicates.push(format!("EXISTS (SELECT 1 FROM objective_ticket_links link WHERE link.workspace_id=o.workspace_id AND link.objective_id=o.objective_id AND link.ticket_id={value})")); predicates.push(format!("EXISTS (SELECT 1 FROM objective_ticket_links link WHERE link.workspace_id=o.workspace_id AND link.objective_id=o.objective_id AND link.ticket_id={value})"));
} }
let relevance_rank = if let Some(text) = let relevance_rank = if let Some(text) =
@@ -1237,6 +1245,10 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
.into_iter() .into_iter()
.map(|link| link.ticket_id) .map(|link| link.ticket_id)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let linked_ticket_keys = linked_tickets
.iter()
.map(|ticket_id| self.resource_key(WorkspaceResourceKind::Ticket, ticket_id))
.collect::<Result<Vec<_>>>()?;
let body_md = record.body_md.clone(); let body_md = record.body_md.clone();
let objective = ObjectiveSummary { let objective = ObjectiveSummary {
resource_key: self resource_key: self
@@ -1253,6 +1265,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
items.push(objective_query_item( items.push(objective_query_item(
objective, objective,
linked_tickets, linked_tickets,
linked_ticket_keys,
query.query.as_deref(), query.query.as_deref(),
&body_md, &body_md,
)); ));
@@ -1339,9 +1352,19 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
fn create_objective(&self, input: ObjectiveCreateInput) -> Result<ObjectiveDetail> { fn create_objective(&self, input: ObjectiveCreateInput) -> Result<ObjectiveDetail> {
validate_objective_title(&input.title)?; validate_objective_title(&input.title)?;
validate_objective_state(&input.state)?; validate_objective_state(&input.state)?;
for ticket_id in &input.linked_tickets { let linked_tickets = input
validate_project_id(ticket_id)?; .linked_tickets
} .iter()
.map(|ticket_reference| {
self.store
.resolve_resource_reference(
&self.workspace_id,
WorkspaceResourceKind::Ticket,
ticket_reference,
)?
.ok_or_else(|| invalid_objective_error("linked Ticket was not found"))
})
.collect::<Result<Vec<_>>>()?;
let now = now_rfc3339(); let now = now_rfc3339();
let objective_id = allocate_record_id( let objective_id = allocate_record_id(
unix_epoch_millis_now().map_err(|err| { unix_epoch_millis_now().map_err(|err| {
@@ -1367,8 +1390,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
updated_at: now.clone(), updated_at: now.clone(),
}; };
self.store.upsert_objective(&record)?; self.store.upsert_objective(&record)?;
let links = input let links = linked_tickets
.linked_tickets
.into_iter() .into_iter()
.map(|ticket_id| ObjectiveTicketLinkRecord { .map(|ticket_id| ObjectiveTicketLinkRecord {
workspace_id: self.workspace_id.clone(), workspace_id: self.workspace_id.clone(),
@@ -2283,12 +2305,18 @@ fn ticket_query_item(
.iter() .iter()
.map(|objective| objective.id.clone()) .map(|objective| objective.id.clone())
.collect(), .collect(),
linked_objective_keys: detail
.linked_objectives
.iter()
.map(|objective| objective.resource_key.clone())
.collect(),
relation_count: detail.relations.outgoing.len() + detail.relations.incoming.len(), relation_count: detail.relations.outgoing.len() + detail.relations.incoming.len(),
blocker_count: detail.relations.blockers.len(), blocker_count: detail.relations.blockers.len(),
unresolved_blocker_count: detail.relations.blockers.len(), unresolved_blocker_count: detail.relations.blockers.len(),
unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes), unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes),
evidence: detail.evidence.clone(), evidence: detail.evidence.clone(),
merge_request: detail.merge_request.clone(), merge_request: detail.merge_request.clone(),
current_coder: detail.current_coder.clone(),
} }
} }
@@ -2399,6 +2427,7 @@ fn ticket_item_after_cursor(
fn objective_query_item( fn objective_query_item(
objective: ObjectiveSummary, objective: ObjectiveSummary,
linked_tickets: Vec<String>, linked_tickets: Vec<String>,
linked_ticket_keys: Vec<String>,
text: Option<&str>, text: Option<&str>,
body_md: &str, body_md: &str,
) -> ObjectiveQueryItem { ) -> ObjectiveQueryItem {
@@ -2426,6 +2455,7 @@ fn objective_query_item(
snippet, snippet,
linked_ticket_count: linked_tickets.len(), linked_ticket_count: linked_tickets.len(),
linked_tickets, linked_tickets,
linked_ticket_keys,
} }
} }
@@ -3308,16 +3338,21 @@ VALUES ('workspace-test', 'ticket', 4);
assert!(!objective.revision.is_empty()); assert!(!objective.revision.is_empty());
assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2"); assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2");
assert_eq!(objective.linked_ticket_summaries[0].state, "ready"); assert_eq!(objective.linked_ticket_summaries[0].state, "ready");
let linked_ticket_key = objective.linked_ticket_summaries[0].resource_key.clone();
let objective_query = authority let objective_query = authority
.query_objectives(ObjectiveQueryRequest { .query_objectives(ObjectiveQueryRequest {
query: Some("Control plane".to_string()), query: Some("Control plane".to_string()),
linked_ticket_id: Some("00000000001J2".to_string()), linked_ticket_id: Some(linked_ticket_key.clone()),
limit: Some(1), limit: Some(1),
..ObjectiveQueryRequest::default() ..ObjectiveQueryRequest::default()
}) })
.unwrap(); .unwrap();
assert_eq!(objective_query.items.len(), 1); assert_eq!(objective_query.items.len(), 1);
assert_eq!(objective_query.items[0].linked_ticket_count, 1); assert_eq!(objective_query.items[0].linked_ticket_count, 1);
assert_eq!(
objective_query.items[0].linked_ticket_keys,
vec![linked_ticket_key]
);
assert_eq!(objective_query.page.limit, 1); assert_eq!(objective_query.page.limit, 1);
let body_query = authority let body_query = authority
.query_objectives(ObjectiveQueryRequest { .query_objectives(ObjectiveQueryRequest {
@@ -3410,7 +3445,7 @@ VALUES ('workspace-test', 'ticket', 3);
title: "Create Objective".to_string(), title: "Create Objective".to_string(),
body_md: "Alpha body".to_string(), body_md: "Alpha body".to_string(),
state: "active".to_string(), state: "active".to_string(),
linked_tickets: vec!["00000000001J2".to_string()], linked_tickets: vec!["T-1".to_string()],
}) })
.unwrap(); .unwrap();
assert_eq!(created.title, "Create Objective"); assert_eq!(created.title, "Create Objective");
@@ -3436,14 +3471,14 @@ VALUES ('workspace-test', 'ticket', 3);
assert_eq!(state.state, "paused"); assert_eq!(state.state, "paused");
assert_eq!( assert_eq!(
authority authority
.link_objective_ticket(&created.id, "00000000001J3") .link_objective_ticket(&created.id, "T-2")
.unwrap() .unwrap()
.linked_tickets, .linked_tickets,
vec!["00000000001J2", "00000000001J3"] vec!["00000000001J2", "00000000001J3"]
); );
assert_eq!( assert_eq!(
authority authority
.unlink_objective_ticket(&created.id, "00000000001J2") .unlink_objective_ticket(&created.id, "T-1")
.unwrap() .unwrap()
.linked_tickets, .linked_tickets,
vec!["00000000001J3"] vec!["00000000001J3"]
+213 -593
View File
@@ -3,53 +3,52 @@ use std::path::{Path, PathBuf};
use std::{fs, io}; use std::{fs, io};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use url::Url;
use crate::hosts::RemoteRuntimeConfig; use crate::hosts::RemoteRuntimeConfig;
use crate::identity::WorkspaceIdentity; use crate::identity::WorkspaceIdentity;
use crate::repositories::ConfiguredRepository;
use crate::server::{AuthConfig, ServerConfig}; use crate::server::{AuthConfig, ServerConfig};
use crate::{Error, Result}; use crate::{Error, Result};
pub const WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH: &str = ".yoi/workspace-backend.local.toml";
pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml"; pub const BACKEND_RUNTIMES_CONFIG_FILE_NAME: &str = "runtimes.toml";
pub const WORKSPACE_BACKEND_CONFIG_TEMPLATE: &str = pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
include_str!("../../../resources/workspace-backend.default.toml");
const DEFAULT_LISTEN: &str = "127.0.0.1:8787"; const DEFAULT_LISTEN: &str = "127.0.0.1:8787";
const DEFAULT_FRONTEND_URL: &str = "http://127.0.0.1:5173"; const DEFAULT_BROWSER_PUBLIC_URL: &str = "http://localhost:5173";
const DEFAULT_AUTH_PUBLIC_BASE_URL: &str = "http://localhost:8787";
const DEFAULT_AUTH_RP_ID: &str = "localhost";
const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session"; const DEFAULT_AUTH_COOKIE_NAME: &str = "yoi_workspace_session";
const DEFAULT_MAX_RECORDS: usize = 200; const DEFAULT_MAX_RECORDS: usize = 200;
fn default_auth_rp_id() -> String { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
DEFAULT_AUTH_RP_ID.to_string()
}
fn default_auth_origin() -> String {
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
}
fn default_auth_public_base_url() -> String {
DEFAULT_AUTH_PUBLIC_BASE_URL.to_string()
}
fn default_auth_cookie_name() -> String {
DEFAULT_AUTH_COOKIE_NAME.to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceBackendConfigFile { pub struct ServerHostConfigFile {
#[serde(default)] #[serde(default)]
pub server: WorkspaceBackendServerConfig, pub browser: ServerBrowserConfig,
#[serde(default)] }
pub data: WorkspaceBackendDataConfig,
#[serde(default)] impl Default for ServerHostConfigFile {
pub limits: WorkspaceBackendLimitsConfig, fn default() -> Self {
#[serde(default)] Self {
pub auth: WorkspaceBackendAuthConfig, browser: ServerBrowserConfig::default(),
#[serde(default)] }
pub repositories: Vec<WorkspaceRepositoryConfigFile>, }
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ServerBrowserConfig {
#[serde(default = "default_browser_public_url")]
pub public_url: String,
}
impl Default for ServerBrowserConfig {
fn default() -> Self {
Self {
public_url: default_browser_public_url(),
}
}
}
fn default_browser_public_url() -> String {
DEFAULT_BROWSER_PUBLIC_URL.to_string()
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
@@ -59,71 +58,6 @@ pub struct BackendRuntimesConfigFile {
pub runtimes: WorkspaceBackendRuntimesConfig, pub runtimes: WorkspaceBackendRuntimesConfig,
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendServerConfig {
#[serde(default)]
pub listen: Option<String>,
#[serde(default)]
pub frontend_url: Option<String>,
#[serde(default)]
pub static_assets_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendDataConfig {
#[serde(default)]
pub root: Option<PathBuf>,
#[serde(default)]
pub workspace_database_path: Option<PathBuf>,
#[serde(default)]
pub embedded_runtime_store_root: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendLimitsConfig {
#[serde(default)]
pub max_records: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceBackendAuthConfig {
#[serde(default = "default_auth_rp_id")]
pub rp_id: String,
#[serde(default = "default_auth_origin")]
pub origin: String,
#[serde(default = "default_auth_public_base_url")]
pub public_base_url: String,
#[serde(default = "default_auth_cookie_name")]
pub cookie_name: String,
}
impl Default for WorkspaceBackendAuthConfig {
fn default() -> Self {
Self {
rp_id: default_auth_rp_id(),
origin: default_auth_origin(),
public_base_url: default_auth_public_base_url(),
cookie_name: default_auth_cookie_name(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceRepositoryConfigFile {
pub id: String,
pub provider: String,
pub uri: String,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub default_selector: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WorkspaceBackendRuntimesConfig { pub struct WorkspaceBackendRuntimesConfig {
@@ -142,61 +76,6 @@ pub struct RemoteRuntimeConfigFile {
pub token_ref: Option<String>, pub token_ref: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigDiff {
pub differs: bool,
pub text: String,
}
impl ConfigDiff {
fn new(default: &str, local: &str) -> Self {
if default == local {
return Self {
differs: false,
text: "workspace backend local config matches the packaged default\n".to_string(),
};
}
let mut text = String::from("--- packaged default\n+++ workspace local\n");
let default_lines = default.lines().collect::<Vec<_>>();
let local_lines = local.lines().collect::<Vec<_>>();
let max = default_lines.len().max(local_lines.len());
for index in 0..max {
match (default_lines.get(index), local_lines.get(index)) {
(Some(left), Some(right)) if left == right => {
text.push(' ');
text.push_str(left);
text.push('\n');
}
(Some(left), Some(right)) => {
text.push('-');
text.push_str(left);
text.push('\n');
text.push('+');
text.push_str(right);
text.push('\n');
}
(Some(left), None) => {
text.push('-');
text.push_str(left);
text.push('\n');
}
(None, Some(right)) => {
text.push('+');
text.push_str(right);
text.push('\n');
}
(None, None) => {}
}
}
Self {
differs: true,
text,
}
}
}
#[derive(Clone)] #[derive(Clone)]
pub struct ResolvedWorkspaceBackendConfig { pub struct ResolvedWorkspaceBackendConfig {
pub server: ServerConfig, pub server: ServerConfig,
@@ -204,6 +83,47 @@ pub struct ResolvedWorkspaceBackendConfig {
pub database_path: PathBuf, pub database_path: PathBuf,
} }
impl ServerHostConfigFile {
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
config_dir.as_ref().join(SERVER_HOST_CONFIG_FILE_NAME)
}
pub fn default_path() -> Option<PathBuf> {
manifest::paths::config_dir().map(Self::path_for_config_dir)
}
pub fn load_default() -> Result<Self> {
let Some(path) = Self::default_path() else {
return Ok(Self::default());
};
match fs::read_to_string(&path) {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let raw = fs::read_to_string(path).map_err(|error| {
Error::Config(format!(
"failed to read Server host config `{}`: {error}",
path.display()
))
})?;
Self::parse_str(&raw, path)
}
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
toml::from_str(raw).map_err(|error| {
Error::Config(format!(
"failed to parse Server host config `{}`: {error}",
path.as_ref().display()
))
})
}
}
impl BackendRuntimesConfigFile { impl BackendRuntimesConfigFile {
pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf { pub fn path_for_config_dir(config_dir: impl AsRef<Path>) -> PathBuf {
config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME) config_dir.as_ref().join(BACKEND_RUNTIMES_CONFIG_FILE_NAME)
@@ -272,151 +192,22 @@ impl BackendRuntimesConfigFile {
} }
} }
impl WorkspaceBackendConfigFile { impl ResolvedWorkspaceBackendConfig {
pub fn path_for_workspace(workspace_root: impl AsRef<Path>) -> PathBuf { pub fn local_dev(
workspace_root
.as_ref()
.join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH)
}
pub fn ensure_local_config_for_workspace(workspace_root: impl AsRef<Path>) -> Result<()> {
let path = Self::path_for_workspace(workspace_root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut file) => {
use std::io::Write;
file.write_all(WORKSPACE_BACKEND_CONFIG_TEMPLATE.as_bytes())?;
file.sync_all()?;
Ok(())
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => Ok(()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn local_config_diff_for_workspace(workspace_root: impl AsRef<Path>) -> Result<ConfigDiff> {
let workspace_root = workspace_root.as_ref();
let path = Self::path_for_workspace(workspace_root);
match fs::read_to_string(&path) {
Ok(local) => Ok(ConfigDiff::new(WORKSPACE_BACKEND_CONFIG_TEMPLATE, &local)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Err(Error::Config(format!(
"workspace backend local config `{}` does not exist; run `yoi-server init --workspace {}` first",
path.display(),
workspace_root.display()
))),
Err(error) => Err(Error::Io(error)),
}
}
pub fn load_for_workspace(workspace_root: impl AsRef<Path>) -> Result<Self> {
let path = Self::path_for_workspace(workspace_root);
match fs::read_to_string(&path) {
Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(error) => Err(Error::Io(error)),
}
}
pub fn write_for_workspace(&self, workspace_root: impl AsRef<Path>) -> Result<()> {
let path = Self::path_for_workspace(workspace_root);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let raw = toml::to_string_pretty(self).map_err(|error| {
Error::Config(format!(
"failed to serialize workspace backend config: {error}"
))
})?;
fs::write(path, raw)?;
Ok(())
}
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
toml::from_str(raw).map_err(|error| {
Error::Config(format!(
"failed to parse workspace backend config `{}`: {error}",
path.as_ref().display()
))
})
}
pub fn resolve(
&self,
workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity,
) -> Result<ResolvedWorkspaceBackendConfig> {
self.resolve_with_runtime_config(
workspace_root,
identity,
&BackendRuntimesConfigFile::default(),
)
}
pub fn resolve_with_runtime_config(
&self,
workspace_root: impl AsRef<Path>, workspace_root: impl AsRef<Path>,
identity: WorkspaceIdentity, identity: WorkspaceIdentity,
host_config: &ServerHostConfigFile,
runtime_config: &BackendRuntimesConfigFile, runtime_config: &BackendRuntimesConfigFile,
) -> Result<ResolvedWorkspaceBackendConfig> { ) -> Result<Self> {
let workspace_root = workspace_root.as_ref(); let workspace_root = workspace_root.as_ref();
let data_root = self let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id);
.data let database_path = ServerConfig::default_server_database_path();
.root let (browser_public_url, browser_rp_id) =
.as_ref() resolve_browser_public_url(&host_config.browser.public_url)?;
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(|| {
ServerConfig::default_workspace_backend_data_root(&identity.workspace_id)
});
let database_path = self
.data
.workspace_database_path
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(ServerConfig::default_server_database_path);
let embedded_runtime_store_root = self
.data
.embedded_runtime_store_root
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path))
.unwrap_or_else(|| data_root.join("embedded-runtime"));
let listen = self
.server
.listen
.as_deref()
.unwrap_or(DEFAULT_LISTEN)
.parse::<SocketAddr>()
.map_err(|_| {
Error::Config(format!(
"invalid workspace backend server.listen `{}`",
self.server.listen.as_deref().unwrap_or(DEFAULT_LISTEN)
))
})?;
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity); let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
server.database_path = database_path.clone(); server.database_path = database_path.clone();
server.frontend_url = self server.embedded_runtime_store_root = data_root.join("embedded-runtime");
.server server.max_records = DEFAULT_MAX_RECORDS;
.frontend_url
.clone()
.unwrap_or_else(|| DEFAULT_FRONTEND_URL.to_string());
server.static_assets_dir = self
.server
.static_assets_dir
.as_ref()
.map(|path| resolve_workspace_path(workspace_root, path));
server.embedded_runtime_store_root = embedded_runtime_store_root;
server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS);
server.repositories = self
.repositories
.iter()
.map(|repository| resolve_repository(workspace_root, repository))
.collect::<Result<Vec<_>>>()?;
server.remote_runtime_sources = runtime_config server.remote_runtime_sources = runtime_config
.runtimes .runtimes
.remote .remote
@@ -424,13 +215,16 @@ impl WorkspaceBackendConfigFile {
.map(resolve_remote_runtime) .map(resolve_remote_runtime)
.collect::<Result<Vec<_>>>()?; .collect::<Result<Vec<_>>>()?;
server.auth = AuthConfig::Passkey { server.auth = AuthConfig::Passkey {
rp_id: self.auth.rp_id.trim().to_string(), rp_id: browser_rp_id,
origin: self.auth.origin.trim().to_string(), origin: browser_public_url.clone(),
public_base_url: self.auth.public_base_url.trim().to_string(), public_base_url: browser_public_url,
cookie_name: self.auth.cookie_name.trim().to_string(), cookie_name: DEFAULT_AUTH_COOKIE_NAME.to_string(),
}; };
let listen = DEFAULT_LISTEN.parse::<SocketAddr>().map_err(|error| {
Error::Config(format!("invalid built-in Server listen address: {error}"))
})?;
Ok(ResolvedWorkspaceBackendConfig { Ok(Self {
server, server,
listen, listen,
database_path, database_path,
@@ -439,18 +233,6 @@ impl WorkspaceBackendConfigFile {
} }
impl ResolvedWorkspaceBackendConfig { impl ResolvedWorkspaceBackendConfig {
pub fn with_database_path(mut self, path: impl Into<PathBuf>) -> Self {
let path = path.into();
self.database_path = path.clone();
self.server.database_path = path;
self
}
pub fn with_static_assets_dir(mut self, path: Option<PathBuf>) -> Self {
self.server.static_assets_dir = path;
self
}
pub fn with_backend_base_url(mut self, base_url: impl Into<String>) -> Self { pub fn with_backend_base_url(mut self, base_url: impl Into<String>) -> Self {
self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string()); self.server.backend_base_url = Some(base_url.into().trim_end_matches('/').to_string());
self self
@@ -462,33 +244,6 @@ impl ResolvedWorkspaceBackendConfig {
} }
} }
fn resolve_repository(
workspace_root: &Path,
config: &WorkspaceRepositoryConfigFile,
) -> Result<ConfiguredRepository> {
let id = normalize_required_string("repository id", &config.id)?;
validate_repository_id(&id)?;
let provider =
normalize_required_string("repository provider", &config.provider)?.to_ascii_lowercase();
let uri = normalize_required_string("repository uri", &config.uri)?;
let (source, path) = resolve_repository_source(workspace_root, &id, &uri)?;
let display_name = normalize_optional_string(config.display_name.as_deref());
let default_selector = normalize_optional_string(config.default_selector.as_deref());
Ok(ConfiguredRepository {
id,
provider,
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
source,
source_revision: 1,
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
observed_at: None,
path,
display_name,
default_selector,
})
}
fn normalize_required_string(field: &str, value: &str) -> Result<String> { fn normalize_required_string(field: &str, value: &str) -> Result<String> {
let trimmed = value.trim(); let trimmed = value.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@@ -497,73 +252,12 @@ fn normalize_required_string(field: &str, value: &str) -> Result<String> {
Ok(trimmed.to_string()) Ok(trimmed.to_string())
} }
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn validate_repository_id(id: &str) -> Result<()> {
if id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.'))
{
Ok(())
} else {
Err(Error::Config(format!(
"repository id `{id}` must contain only ASCII letters, digits, `_`, `-`, or `.`"
)))
}
}
fn resolve_repository_source(
workspace_root: &Path,
id: &str,
uri: &str,
) -> Result<(workspace_api::RepositorySource, Option<PathBuf>)> {
match crate::repository_source::parse_repository_source(uri) {
Ok(source) => {
let path = match source.kind {
workspace_api::RepositorySourceKind::LocalPath => Some(PathBuf::from(&source.uri)),
workspace_api::RepositorySourceKind::File => url::Url::parse(&source.uri)
.ok()
.and_then(|uri| uri.to_file_path().ok()),
workspace_api::RepositorySourceKind::Ssh
| workspace_api::RepositorySourceKind::Http
| workspace_api::RepositorySourceKind::Https => None,
workspace_api::RepositorySourceKind::Invalid => {
return Err(Error::Config(format!(
"repository `{id}` has an invalid source"
)));
}
};
Ok((source, path))
}
Err(_) if !Path::new(uri).is_absolute() && !uri.contains("://") => {
let path = resolve_workspace_path(workspace_root, Path::new(uri));
let source = workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::LocalPath,
uri: path.to_string_lossy().into_owned(),
};
Ok((source, Some(path)))
}
Err(error) => Err(Error::Config(format!(
"repository `{id}` has an invalid source: {error}"
))),
}
}
pub(crate) fn resolve_remote_runtime( pub(crate) fn resolve_remote_runtime(
config: &RemoteRuntimeConfigFile, config: &RemoteRuntimeConfigFile,
) -> Result<RemoteRuntimeConfig> { ) -> Result<RemoteRuntimeConfig> {
if let Some(token_ref) = config.token_ref.as_deref() { if let Some(token_ref) = config.token_ref.as_deref() {
return Err(Error::Config(format!( return Err(Error::Config(format!(
"remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for workspace backend config yet", "remote runtime `{}` uses token_ref `{token_ref}`, but secret ref resolution is not implemented for Backend runtime settings yet",
config.id config.id
))); )));
} }
@@ -578,12 +272,34 @@ pub(crate) fn resolve_remote_runtime(
)) ))
} }
fn resolve_workspace_path(workspace_root: &Path, path: &Path) -> PathBuf { fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
if path.is_absolute() { let value = normalize_required_string("browser.public_url", value)?;
path.to_path_buf() let url = Url::parse(&value).map_err(|error| {
} else { Error::Config(format!(
workspace_root.join(path) "browser.public_url must be an absolute http(s) URL: {error}"
))
})?;
if !matches!(url.scheme(), "http" | "https") {
return Err(Error::Config(
"browser.public_url must use the http or https scheme".to_string(),
));
} }
if !url.username().is_empty() || url.password().is_some() {
return Err(Error::Config(
"browser.public_url must not contain user information".to_string(),
));
}
if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
return Err(Error::Config(
"browser.public_url must contain only an origin without a path, query, or fragment"
.to_string(),
));
}
let rp_id = url
.host_str()
.ok_or_else(|| Error::Config("browser.public_url must contain a host".to_string()))?
.to_string();
Ok((url.origin().ascii_serialization(), rp_id))
} }
#[cfg(test)] #[cfg(test)]
@@ -598,14 +314,33 @@ mod tests {
} }
} }
#[test] fn resolved_with_runtimes(
fn missing_config_path_uses_defaults() { runtimes: &BackendRuntimesConfigFile,
) -> ResolvedWorkspaceBackendConfig {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::load_for_workspace(dir.path()).unwrap(); ResolvedWorkspaceBackendConfig::local_dev(
let resolved = config.resolve(dir.path(), identity()).unwrap(); dir.path(),
identity(),
&ServerHostConfigFile::default(),
runtimes,
)
.unwrap()
}
#[test]
fn default_settings_resolve_without_a_repository_file() {
let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default());
assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap()); assert_eq!(resolved.listen, "127.0.0.1:8787".parse().unwrap());
assert_eq!(resolved.server.frontend_url, DEFAULT_FRONTEND_URL); let AuthConfig::Passkey {
rp_id,
origin,
public_base_url,
..
} = &resolved.server.auth;
assert_eq!(rp_id, "localhost");
assert_eq!(origin, DEFAULT_BROWSER_PUBLIC_URL);
assert_eq!(public_base_url, DEFAULT_BROWSER_PUBLIC_URL);
assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS); assert_eq!(resolved.server.max_records, DEFAULT_MAX_RECORDS);
assert!(resolved.database_path.ends_with("server.db")); assert!(resolved.database_path.ends_with("server.db"));
assert!( assert!(
@@ -618,12 +353,8 @@ mod tests {
#[test] #[test]
fn backend_base_url_is_explicit_and_normalized() { fn backend_base_url_is_explicit_and_normalized() {
let dir = tempfile::tempdir().unwrap();
let listen = "127.0.0.1:48787".parse().unwrap(); let listen = "127.0.0.1:48787".parse().unwrap();
let resolved = WorkspaceBackendConfigFile::load_for_workspace(dir.path()) let resolved = resolved_with_runtimes(&BackendRuntimesConfigFile::default())
.unwrap()
.resolve(dir.path(), identity())
.unwrap()
.with_listen(listen) .with_listen(listen)
.with_backend_base_url("http://127.0.0.1:48787/"); .with_backend_base_url("http://127.0.0.1:48787/");
@@ -635,171 +366,83 @@ mod tests {
} }
#[test] #[test]
fn rejects_unknown_fields() { fn browser_public_url_from_host_config_drives_all_browser_auth_settings() {
let error = WorkspaceBackendConfigFile::parse_str("[server]\nunknown = true\n", "test") let host_config = ServerHostConfigFile::parse_str(
.unwrap_err(); "[browser]\npublic_url = \"https://Yoi.Example:443/\"\n",
"server.toml",
)
.unwrap();
let resolved = ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(),
identity(),
&host_config,
&BackendRuntimesConfigFile::default(),
)
.unwrap();
let AuthConfig::Passkey {
rp_id,
origin,
public_base_url,
..
} = &resolved.server.auth;
assert_eq!(rp_id, "yoi.example");
assert_eq!(origin, "https://yoi.example");
assert_eq!(public_base_url, "https://yoi.example");
}
#[test]
fn browser_public_url_rejects_non_origin_urls() {
for value in [
"https://example.test/path",
"https://example.test?query=true",
"file:///tmp/web",
] {
let host_config = ServerHostConfigFile {
browser: ServerBrowserConfig {
public_url: value.to_string(),
},
};
let result = ResolvedWorkspaceBackendConfig::local_dev(
tempfile::tempdir().unwrap().path(),
identity(),
&host_config,
&BackendRuntimesConfigFile::default(),
);
let error = match result {
Ok(_) => panic!("expected {value} to be rejected"),
Err(error) => error,
};
assert!( assert!(
error.to_string().contains("unknown field"), error.to_string().contains("browser.public_url"),
"unexpected error: {error}" "unexpected error for {value}: {error}"
); );
} }
}
#[test] #[test]
fn resolves_relative_paths_against_workspace_root() { fn server_host_config_loads_only_from_the_explicit_host_path() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str( let path = ServerHostConfigFile::path_for_config_dir(dir.path());
r#"
[server]
static_assets_dir = "web/build"
[data]
root = ".yoi/backend-data"
workspace_database_path = ".yoi/custom.db"
embedded_runtime_store_root = ".yoi/runtime-store"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert_eq!(
resolved.server.static_assets_dir,
Some(dir.path().join("web/build"))
);
assert_eq!(resolved.database_path, dir.path().join(".yoi/custom.db"));
assert_eq!(
resolved.server.embedded_runtime_store_root,
dir.path().join(".yoi/runtime-store")
);
}
#[test]
fn absolute_paths_are_preserved() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[data]
workspace_database_path = "/tmp/yoi-workspace.db"
embedded_runtime_store_root = "/tmp/yoi-runtime"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert_eq!(
resolved.database_path,
PathBuf::from("/tmp/yoi-workspace.db")
);
assert_eq!(
resolved.server.embedded_runtime_store_root,
PathBuf::from("/tmp/yoi-runtime")
);
}
#[test]
fn data_root_derives_runtime_store_path_only() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[data]
root = ".local-data"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
assert!(resolved.database_path.ends_with("server.db"));
assert_eq!(
resolved.server.embedded_runtime_store_root,
dir.path().join(".local-data/embedded-runtime")
);
}
#[test]
fn copies_local_config_without_overwriting() {
let dir = tempfile::tempdir().unwrap();
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
let path = WorkspaceBackendConfigFile::path_for_workspace(dir.path());
let raw = fs::read_to_string(&path).unwrap();
assert_eq!(raw, WORKSPACE_BACKEND_CONFIG_TEMPLATE);
WorkspaceBackendConfigFile::parse_str(&raw, &path).unwrap();
fs::write(&path, "# custom local config\n").unwrap();
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
assert_eq!(
fs::read_to_string(&path).unwrap(),
"# custom local config\n"
);
}
#[test]
fn local_config_diff_reports_match_and_difference() {
let dir = tempfile::tempdir().unwrap();
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
let matched =
WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
assert!(!matched.differs);
fs::write( fs::write(
WorkspaceBackendConfigFile::path_for_workspace(dir.path()), &path,
"[server]\nlisten = \"127.0.0.1:9999\"\n", "[browser]\npublic_url = \"https://deploy.example.test\"\n",
) )
.unwrap(); .unwrap();
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(dir.path()).unwrap();
assert!(diff.differs); let loaded = ServerHostConfigFile::load_from_path(&path).unwrap();
assert!(diff.text.contains("+++ workspace local")); assert_eq!(loaded.browser.public_url, "https://deploy.example.test");
assert!(diff.text.contains("127.0.0.1:9999")); assert_eq!(path, dir.path().join("server.toml"));
} }
#[test] #[test]
fn resolves_repository_uri_relative_to_workspace_root() { fn explicit_missing_server_host_config_fails_closed() {
let dir = tempfile::tempdir().unwrap(); let error = ServerHostConfigFile::load_from_path("/missing/yoi/server.toml").unwrap_err();
let config = WorkspaceBackendConfigFile::parse_str( assert!(
r#" error
[[repositories]] .to_string()
id = "main" .contains("failed to read Server host config")
provider = "git"
uri = "."
display_name = "Main"
default_selector = "HEAD"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
let repository = resolved.server.repositories.first().unwrap();
assert_eq!(repository.id, "main");
assert_eq!(repository.provider, "git");
assert_eq!(repository.path.as_deref(), Some(dir.path()));
assert_eq!(repository.display_name.as_deref(), Some("Main"));
assert_eq!(repository.default_selector.as_deref(), Some("HEAD"));
}
#[test]
fn remote_repository_source_is_preserved_without_a_local_path() {
let dir = tempfile::tempdir().unwrap();
let config = WorkspaceBackendConfigFile::parse_str(
r#"
[[repositories]]
id = "main"
provider = "git"
uri = "https://example.com/org/repo.git"
"#,
"test",
)
.unwrap();
let resolved = config.resolve(dir.path(), identity()).unwrap();
let repository = &resolved.server.repositories[0];
assert_eq!(
repository.source.kind,
workspace_api::RepositorySourceKind::Https
); );
assert_eq!(repository.source.uri, "https://example.com/org/repo.git");
assert!(repository.path.is_none());
} }
#[test] #[test]
@@ -824,28 +467,8 @@ uri = "https://example.com/org/repo.git"
); );
} }
#[test]
fn workspace_backend_config_rejects_runtime_entries() {
let error = WorkspaceBackendConfigFile::parse_str(
r#"
[[runtimes.remote]]
id = "arc"
endpoint = "http://legacy.example.test"
display_name = "legacy arc"
"#,
"test",
)
.unwrap_err();
assert!(
error.to_string().contains("unknown field `runtimes`"),
"unexpected error: {error}"
);
}
#[test] #[test]
fn backend_runtimes_config_is_the_only_runtime_source() { fn backend_runtimes_config_is_the_only_runtime_source() {
let dir = tempfile::tempdir().unwrap();
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
let runtime_config = BackendRuntimesConfigFile::parse_str( let runtime_config = BackendRuntimesConfigFile::parse_str(
r#" r#"
[[runtimes.remote]] [[runtimes.remote]]
@@ -856,9 +479,7 @@ display_name = "xdg arc"
"runtimes.toml", "runtimes.toml",
) )
.unwrap(); .unwrap();
let resolved = workspace_config let resolved = resolved_with_runtimes(&runtime_config);
.resolve_with_runtime_config(dir.path(), identity(), &runtime_config)
.unwrap();
assert_eq!(resolved.server.remote_runtime_sources.len(), 1); assert_eq!(resolved.server.remote_runtime_sources.len(), 1);
assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc"); assert_eq!(resolved.server.remote_runtime_sources[0].runtime_id, "arc");
assert_eq!( assert_eq!(
@@ -887,8 +508,6 @@ token = "secret"
#[test] #[test]
fn token_ref_fails_closed_until_secret_resolution_exists() { fn token_ref_fails_closed_until_secret_resolution_exists() {
let dir = tempfile::tempdir().unwrap();
let workspace_config = WorkspaceBackendConfigFile::parse_str("", "test").unwrap();
let runtime_config = BackendRuntimesConfigFile::parse_str( let runtime_config = BackendRuntimesConfigFile::parse_str(
r#" r#"
[[runtimes.remote]] [[runtimes.remote]]
@@ -899,9 +518,10 @@ token_ref = "local:remote-token"
"runtimes.toml", "runtimes.toml",
) )
.unwrap(); .unwrap();
let error = match workspace_config.resolve_with_runtime_config( let error = match ResolvedWorkspaceBackendConfig::local_dev(
dir.path(), tempfile::tempdir().unwrap().path(),
identity(), identity(),
&ServerHostConfigFile::default(),
&runtime_config, &runtime_config,
) { ) {
Ok(_) => panic!("token_ref should fail closed until secret resolution exists"), Ok(_) => panic!("token_ref should fail closed until secret resolution exists"),
+94 -140
View File
@@ -25,8 +25,9 @@ use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
use worker_runtime::catalog::{ use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef, ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail, ProfileSourceArchiveSource, WorkerDetail as EmbeddedWorkerDetail,
WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim, WorkingDirectoryRequest, WorkerStatus as EmbeddedWorkerStatus, WorkingDirectoryClaim,
WorkingDirectoryStatus, WorkingDirectorySummary, WorkspaceApiRef, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkingDirectorySummary, WorkspaceApiRef,
}; };
use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary}; use worker_runtime::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
#[cfg(test)] #[cfg(test)]
@@ -39,11 +40,11 @@ use worker_runtime::execution::WorkerExecutionRunState;
use worker_runtime::fs_store::FsRuntimeStoreOptions; use worker_runtime::fs_store::FsRuntimeStoreOptions;
use worker_runtime::http_server::{ use worker_runtime::http_server::{
RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest, RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest,
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerCompletionsRequest, RuntimeHttpErrorResponse, RuntimeHttpRepositoryAccessResponse, RuntimeHttpSummaryResponse,
RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse, RuntimeHttpWorkerCompletionsRequest, RuntimeHttpWorkerCompletionsResponse,
RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerDeleteResponse, RuntimeHttpWorkerInputResponse,
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkerLifecycleRequest, RuntimeHttpWorkerLifecycleResponse,
RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse, RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse, RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse,
}; };
@@ -177,26 +178,6 @@ impl RuntimeSourceSummary {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeCapabilitySummary {
pub can_list_hosts: bool,
pub can_list_workers: bool,
pub can_get_worker: bool,
pub can_spawn_worker: bool,
pub can_stop_worker: bool,
pub has_workspace_fs: bool,
pub has_shell: bool,
pub has_git: bool,
pub supports_worktrees: bool,
pub supports_backend_internal_tools: bool,
pub workspace_scope: String,
pub max_workers: usize,
pub os: String,
pub arch: String,
}
pub type HostCapabilitySummary = RuntimeCapabilitySummary;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeSummary { pub struct RuntimeSummary {
pub runtime_id: String, pub runtime_id: String,
@@ -205,7 +186,9 @@ pub struct RuntimeSummary {
pub status: String, pub status: String,
pub source: RuntimeSourceSummary, pub source: RuntimeSourceSummary,
pub host_ids: Vec<String>, pub host_ids: Vec<String>,
pub capabilities: RuntimeCapabilitySummary, pub worker_creation_available: bool,
pub os: String,
pub arch: String,
pub diagnostics: Vec<RuntimeDiagnostic>, pub diagnostics: Vec<RuntimeDiagnostic>,
} }
@@ -218,7 +201,8 @@ pub struct HostSummary {
pub status: String, pub status: String,
pub observed_at: String, pub observed_at: String,
pub last_seen_at: Option<String>, pub last_seen_at: Option<String>,
pub capabilities: HostCapabilitySummary, pub os: String,
pub arch: String,
pub diagnostics: Vec<RuntimeDiagnostic>, pub diagnostics: Vec<RuntimeDiagnostic>,
} }
@@ -310,27 +294,6 @@ impl From<RuntimeSourceSummary> for workspace_api::RuntimeSourceSummary {
} }
} }
impl From<RuntimeCapabilitySummary> for workspace_api::RuntimeCapabilitySummary {
fn from(capabilities: RuntimeCapabilitySummary) -> Self {
Self {
can_list_hosts: capabilities.can_list_hosts,
can_list_workers: capabilities.can_list_workers,
can_get_worker: capabilities.can_get_worker,
can_spawn_worker: capabilities.can_spawn_worker,
can_stop_worker: capabilities.can_stop_worker,
has_workspace_fs: capabilities.has_workspace_fs,
has_shell: capabilities.has_shell,
has_git: capabilities.has_git,
supports_worktrees: capabilities.supports_worktrees,
supports_backend_internal_tools: capabilities.supports_backend_internal_tools,
workspace_scope: capabilities.workspace_scope,
max_workers: capabilities.max_workers,
os: capabilities.os,
arch: capabilities.arch,
}
}
}
impl From<RuntimeSummary> for workspace_api::RuntimeSummary { impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
fn from(runtime: RuntimeSummary) -> Self { fn from(runtime: RuntimeSummary) -> Self {
Self { Self {
@@ -340,7 +303,9 @@ impl From<RuntimeSummary> for workspace_api::RuntimeSummary {
status: runtime.status, status: runtime.status,
source: runtime.source.into(), source: runtime.source.into(),
host_ids: runtime.host_ids, host_ids: runtime.host_ids,
capabilities: runtime.capabilities.into(), worker_creation_available: runtime.worker_creation_available,
os: runtime.os,
arch: runtime.arch,
diagnostics: runtime.diagnostics.into_iter().map(Into::into).collect(), diagnostics: runtime.diagnostics.into_iter().map(Into::into).collect(),
} }
} }
@@ -854,6 +819,16 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
} }
} }
fn authorize_working_directory_repository_access(
&self,
_request: WorkingDirectoryRepositoryAccessRequest,
) -> std::result::Result<(), Error> {
Err(Error::InvalidInput(
"Runtime does not support working directory Repository access authorization"
.to_string(),
))
}
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> { fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
RuntimeList::new(Vec::new(), Vec::new()) RuntimeList::new(Vec::new(), Vec::new())
} }
@@ -1427,6 +1402,23 @@ impl RuntimeRegistry {
Ok(runtime.create_working_directory(request)) Ok(runtime.create_working_directory(request))
} }
pub fn authorize_working_directory_repository_access(
&self,
runtime_id: &str,
request: WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), RuntimeRegistryError> {
validate_backend_identifier("runtime_id", runtime_id)?;
validate_backend_identifier("working_directory_id", &request.working_directory_id)?;
let runtime = self.runtime(runtime_id)?;
runtime
.authorize_working_directory_repository_access(request)
.map_err(|error| RuntimeRegistryError::RuntimeOperationFailed {
runtime_id: runtime_id.to_string(),
code: "working_directory_repository_access_failed".to_string(),
message: error.to_string(),
})
}
pub fn list_working_directories( pub fn list_working_directories(
&self, &self,
runtime_id: &str, runtime_id: &str,
@@ -1890,7 +1882,9 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
status: "unavailable".to_string(), status: "unavailable".to_string(),
source: RuntimeSourceSummary::embedded_worker_runtime(), source: RuntimeSourceSummary::embedded_worker_runtime(),
host_ids: Vec::new(), host_ids: Vec::new(),
capabilities: embedded_runtime_capabilities(limit, false, false), worker_creation_available: false,
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
diagnostics, diagnostics,
}; };
} }
@@ -1910,7 +1904,9 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
} else { } else {
vec![self.host_id.clone()] vec![self.host_id.clone()]
}, },
capabilities: embedded_runtime_capabilities(limit, true, self.execution_enabled), worker_creation_available: true,
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
diagnostics, diagnostics,
} }
} }
@@ -1928,7 +1924,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
status: "available".to_string(), status: "available".to_string(),
observed_at: Utc::now().to_rfc3339(), observed_at: Utc::now().to_rfc3339(),
last_seen_at: None, last_seen_at: None,
capabilities: embedded_runtime_capabilities(limit, true, self.execution_enabled), os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
diagnostics: vec![diagnostic( diagnostics: vec![diagnostic(
"embedded_runtime_host_boundary", "embedded_runtime_host_boundary",
DiagnosticSeverity::Info, DiagnosticSeverity::Info,
@@ -2576,7 +2573,9 @@ pub struct RemoteRuntimeConfig {
pub base_url: String, pub base_url: String,
pub bearer_token: Option<String>, pub bearer_token: Option<String>,
pub auth: Option<RemoteRuntimeAuthConfig>, pub auth: Option<RemoteRuntimeAuthConfig>,
pub cached_capabilities: RuntimeCapabilitySummary, pub cached_worker_creation_available: bool,
pub cached_os: String,
pub cached_arch: String,
pub cached_status: String, pub cached_status: String,
pub timeout: Duration, pub timeout: Duration,
} }
@@ -2598,7 +2597,12 @@ impl std::fmt::Debug for RemoteRuntimeConfig {
&self.bearer_token.as_ref().map(|_| "<redacted>"), &self.bearer_token.as_ref().map(|_| "<redacted>"),
) )
.field("auth", &self.auth.as_ref().map(|_| "<capability-signer>")) .field("auth", &self.auth.as_ref().map(|_| "<capability-signer>"))
.field("cached_capabilities", &self.cached_capabilities) .field(
"cached_worker_creation_available",
&self.cached_worker_creation_available,
)
.field("cached_os", &self.cached_os)
.field("cached_arch", &self.cached_arch)
.field("cached_status", &self.cached_status) .field("cached_status", &self.cached_status)
.field("timeout", &self.timeout) .field("timeout", &self.timeout)
.finish() .finish()
@@ -2619,9 +2623,9 @@ impl RemoteRuntimeConfig {
base_url: base_url.into(), base_url: base_url.into(),
bearer_token, bearer_token,
auth: None, auth: None,
cached_capabilities: remote_runtime_capabilities( cached_worker_creation_available: false,
200, false, false, "unknown", "unknown", cached_os: "unknown".to_string(),
), cached_arch: "unknown".to_string(),
cached_status: "configured".to_string(), cached_status: "configured".to_string(),
timeout: Duration::from_secs(10), timeout: Duration::from_secs(10),
} }
@@ -2632,11 +2636,6 @@ impl RemoteRuntimeConfig {
self self
} }
pub fn with_cached_capabilities(mut self, capabilities: RuntimeCapabilitySummary) -> Self {
self.cached_capabilities = capabilities;
self
}
pub fn with_auth(mut self, auth: RemoteRuntimeAuthConfig) -> Self { pub fn with_auth(mut self, auth: RemoteRuntimeAuthConfig) -> Self {
self.auth = Some(auth); self.auth = Some(auth);
self self
@@ -2708,7 +2707,9 @@ pub struct RemoteWorkerRuntime {
workspace_id: String, workspace_id: String,
bearer_token: Option<String>, bearer_token: Option<String>,
auth: Option<RemoteRuntimeAuthConfig>, auth: Option<RemoteRuntimeAuthConfig>,
cached_capabilities: RuntimeCapabilitySummary, cached_worker_creation_available: bool,
cached_os: String,
cached_arch: String,
cached_status: String, cached_status: String,
host_id: String, host_id: String,
resource_broker: BackendResourceBroker, resource_broker: BackendResourceBroker,
@@ -2768,7 +2769,9 @@ impl RemoteWorkerRuntime {
workspace_id, workspace_id,
bearer_token: config.bearer_token, bearer_token: config.bearer_token,
auth: config.auth, auth: config.auth,
cached_capabilities: config.cached_capabilities, cached_worker_creation_available: config.cached_worker_creation_available,
cached_os: config.cached_os,
cached_arch: config.cached_arch,
cached_status: config.cached_status, cached_status: config.cached_status,
resource_broker: BackendResourceBroker::default(), resource_broker: BackendResourceBroker::default(),
http, http,
@@ -3045,13 +3048,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} else { } else {
vec![self.host_id.clone()] vec![self.host_id.clone()]
}, },
capabilities: remote_runtime_capabilities( worker_creation_available: response.runtime.worker_creation_available,
limit, os: response.runtime.os,
true, arch: response.runtime.arch,
response.runtime.worker_creation_available,
response.runtime.os,
response.runtime.arch,
),
diagnostics: Vec::new(), diagnostics: Vec::new(),
}, },
Err(diagnostic) => RuntimeSummary { Err(diagnostic) => RuntimeSummary {
@@ -3065,7 +3064,9 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} else { } else {
vec![self.host_id.clone()] vec![self.host_id.clone()]
}, },
capabilities: self.cached_capabilities.clone(), worker_creation_available: self.cached_worker_creation_available,
os: self.cached_os.clone(),
arch: self.cached_arch.clone(),
diagnostics: vec![diagnostic], diagnostics: vec![diagnostic],
}, },
} }
@@ -3084,7 +3085,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
status: "configured".to_string(), status: "configured".to_string(),
observed_at: Utc::now().to_rfc3339(), observed_at: Utc::now().to_rfc3339(),
last_seen_at: None, last_seen_at: None,
capabilities: remote_runtime_capabilities(limit, true, false, "unknown", "unknown"), os: self.cached_os.clone(),
arch: self.cached_arch.clone(),
diagnostics: Vec::new(), diagnostics: Vec::new(),
}], }],
Vec::new(), Vec::new(),
@@ -3205,6 +3207,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} }
} }
fn authorize_working_directory_repository_access(
&self,
request: WorkingDirectoryRepositoryAccessRequest,
) -> std::result::Result<(), Error> {
self.post_json::<_, RuntimeHttpRepositoryAccessResponse>(
"/v1/working-directories/repository-access",
&request,
)
.map(|_| ())
.map_err(|diagnostic| Error::RegistryInconsistency(diagnostic.message))
}
fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> { fn list_working_directories(&self) -> RuntimeList<WorkingDirectoryStatus> {
match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") { match self.get_json::<RuntimeHttpWorkingDirectoriesResponse>("/v1/working-directories") {
Ok(response) => RuntimeList::new(response.working_directories, Vec::new()), Ok(response) => RuntimeList::new(response.working_directories, Vec::new()),
@@ -3553,29 +3567,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
} }
} }
fn embedded_runtime_capabilities(
limit: usize,
available: bool,
execution_enabled: bool,
) -> RuntimeCapabilitySummary {
RuntimeCapabilitySummary {
can_list_hosts: true,
can_list_workers: available,
can_get_worker: available,
can_spawn_worker: available,
can_stop_worker: available && execution_enabled,
has_workspace_fs: false,
has_shell: false,
has_git: false,
supports_worktrees: false,
supports_backend_internal_tools: true,
workspace_scope: "backend_internal".to_string(),
max_workers: limit,
os: std::env::consts::OS.to_string(),
arch: std::env::consts::ARCH.to_string(),
}
}
fn embedded_runtime_status_label(status: RuntimeStatus) -> &'static str { fn embedded_runtime_status_label(status: RuntimeStatus) -> &'static str {
match status { match status {
RuntimeStatus::Running => "running", RuntimeStatus::Running => "running",
@@ -4122,31 +4113,6 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
encoded encoded
} }
fn remote_runtime_capabilities(
limit: usize,
available: bool,
worker_creation_available: bool,
os: impl Into<String>,
arch: impl Into<String>,
) -> RuntimeCapabilitySummary {
RuntimeCapabilitySummary {
can_list_hosts: true,
can_list_workers: available,
can_get_worker: available,
can_spawn_worker: available && worker_creation_available,
can_stop_worker: available,
has_workspace_fs: false,
has_shell: false,
has_git: false,
supports_worktrees: false,
supports_backend_internal_tools: false,
workspace_scope: "remote_runtime_backend_private".to_string(),
max_workers: limit,
os: os.into(),
arch: arch.into(),
}
}
fn remote_reqwest_diagnostic(runtime_id: &str, err: reqwest::Error) -> RuntimeDiagnostic { fn remote_reqwest_diagnostic(runtime_id: &str, err: reqwest::Error) -> RuntimeDiagnostic {
if err.is_timeout() { if err.is_timeout() {
diagnostic( diagnostic(
@@ -4460,7 +4426,7 @@ mod tests {
let handle = bundle.profile_source_archive_handle.as_ref().unwrap(); let handle = bundle.profile_source_archive_handle.as_ref().unwrap();
assert!(bundle.profile_source_archive.is_none()); assert!(bundle.profile_source_archive.is_none());
let response = broker let response = broker
.fetch_profile_source_archive(worker_runtime::resource::BackendResourceFetchRequest { .fetch_resource(worker_runtime::resource::BackendResourceFetchRequest {
handle: handle.clone(), handle: handle.clone(),
runtime_id: runtime_id.to_string(), runtime_id: runtime_id.to_string(),
worker_id: None, worker_id: None,
@@ -4800,22 +4766,9 @@ mod tests {
status: "available".to_string(), status: "available".to_string(),
source: RuntimeSourceSummary::embedded_worker_runtime_reserved(), source: RuntimeSourceSummary::embedded_worker_runtime_reserved(),
host_ids: vec![self.host_id.clone()], host_ids: vec![self.host_id.clone()],
capabilities: RuntimeCapabilitySummary { worker_creation_available: false,
can_list_hosts: true,
can_list_workers: true,
can_get_worker: true,
can_spawn_worker: false,
can_stop_worker: false,
has_workspace_fs: false,
has_shell: false,
has_git: false,
supports_worktrees: false,
supports_backend_internal_tools: false,
workspace_scope: "none".to_string(),
max_workers: self.workers.len(),
os: "test".to_string(), os: "test".to_string(),
arch: "test".to_string(), arch: "test".to_string(),
},
diagnostics: Vec::new(), diagnostics: Vec::new(),
} }
} }
@@ -4830,7 +4783,8 @@ mod tests {
status: "available".to_string(), status: "available".to_string(),
observed_at: "unknown".to_string(), observed_at: "unknown".to_string(),
last_seen_at: None, last_seen_at: None,
capabilities: self.runtime_summary(1).capabilities, os: "test".to_string(),
arch: "test".to_string(),
diagnostics: Vec::new(), diagnostics: Vec::new(),
}], }],
Vec::new(), Vec::new(),
@@ -5234,7 +5188,7 @@ mod tests {
RuntimeSourceKind::EmbeddedWorkerRuntime RuntimeSourceKind::EmbeddedWorkerRuntime
); );
assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active); assert_eq!(embedded_summary.source.status, RuntimeSourceStatus::Active);
assert!(embedded_summary.capabilities.can_spawn_worker); assert!(embedded_summary.worker_creation_available);
let spawned = registry let spawned = registry
.spawn_worker( .spawn_worker(
+2 -3
View File
@@ -46,8 +46,7 @@ impl WorkspaceIdentity {
Ok(raw) => Self::parse_str(&raw, &path), Ok(raw) => Self::parse_str(&raw, &path),
Err(error) if error.kind() == ErrorKind::NotFound => { Err(error) if error.kind() == ErrorKind::NotFound => {
Err(Error::WorkspaceIdentity(format!( Err(Error::WorkspaceIdentity(format!(
"workspace is not initialized at {}; run `yoi-server init --workspace {}` first", "workspace identity is missing at {}; register the Workspace through the Server before using repository-local client routing",
workspace_root.as_ref().display(),
workspace_root.as_ref().display() workspace_root.as_ref().display()
))) )))
} }
@@ -219,7 +218,7 @@ mod tests {
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err(); let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
assert!( assert!(
error.to_string().contains("workspace is not initialized"), error.to_string().contains("workspace identity is missing"),
"unexpected error: {error}" "unexpected error: {error}"
); );
assert!(!WorkspaceIdentity::path(&workspace_root).exists()); assert!(!WorkspaceIdentity::path(&workspace_root).exists());
+1 -5
View File
@@ -39,11 +39,7 @@ pub use authority::{
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource, ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
WorkspaceAuthority, WorkspaceAuthority,
}; };
pub use config::{ pub use config::{BackendRuntimesConfigFile, ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
BackendRuntimesConfigFile, ConfigDiff, ResolvedWorkspaceBackendConfig,
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WorkspaceBackendConfigFile,
};
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity}; pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary}; pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
pub use repositories::{ pub use repositories::{
+42 -235
View File
@@ -11,17 +11,13 @@ use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig}; use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord}; use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord};
use yoi_workspace_server::{ use yoi_workspace_server::{
BackendRuntimesConfigFile, ControlPlaneStore, InitialRepositoryIntent, ServerConfig, BackendRuntimesConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig,
WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceCatalogService, ServerHostConfigFile, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
WorkspaceCreateRequest, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
}; };
#[derive(Debug)] #[derive(Debug)]
enum Command { enum Command {
Serve(ServeOptions), Serve(ServeOptions),
Init(InitOptions),
ConfigDefault,
ConfigDiff(WorkspacePathOptions),
Identity(Vec<String>), Identity(Vec<String>),
TrustRuntime(Vec<String>), TrustRuntime(Vec<String>),
MigrateDryRun { database: Option<PathBuf> }, MigrateDryRun { database: Option<PathBuf> },
@@ -32,16 +28,7 @@ enum Command {
#[derive(Debug)] #[derive(Debug)]
struct ServeOptions { struct ServeOptions {
listen: Option<SocketAddr>, listen: Option<SocketAddr>,
} config: Option<PathBuf>,
#[derive(Debug)]
struct InitOptions {
workspace: PathBuf,
}
#[derive(Debug)]
struct WorkspacePathOptions {
workspace: PathBuf,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -82,9 +69,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>(); let args = std::env::args().skip(1).collect::<Vec<_>>();
match parse_command(&args)? { match parse_command(&args)? {
Command::Serve(options) => run_serve(options).await, Command::Serve(options) => run_serve(options).await,
Command::Init(options) => run_init(options).await,
Command::ConfigDefault => run_config_default(),
Command::ConfigDiff(options) => run_config_diff(options),
Command::Identity(args) => run_identity_command(args), Command::Identity(args) => run_identity_command(args),
Command::TrustRuntime(args) => run_trust_runtime_command(args), Command::TrustRuntime(args) => run_trust_runtime_command(args),
Command::MigrateDryRun { database } => { Command::MigrateDryRun { database } => {
@@ -110,14 +94,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
}; };
match command.as_str() { match command.as_str() {
"init" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_init_help();
return Ok(Command::Help);
}
Ok(Command::Init(parse_init_options(rest)?))
}
"config" => parse_config_command(rest),
"identity" => Ok(Command::Identity(rest.to_vec())), "identity" => Ok(Command::Identity(rest.to_vec())),
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())), "trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
"migrate" => parse_migrate_command(rest), "migrate" => parse_migrate_command(rest),
@@ -134,61 +110,11 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Help) Ok(Command::Help)
} }
other => Err(CliError(format!( other => Err(CliError(format!(
"unknown command `{other}`; expected `init`, `config`, `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`" "unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
))), ))),
} }
} }
async fn run_init(options: InitOptions) -> Result<(), Box<dyn std::error::Error>> {
run_init_with_database_path(options, ServerConfig::default_server_database_path()).await
}
async fn run_init_with_database_path(
options: InitOptions,
database_path: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
let identity = WorkspaceIdentity::load_or_init(&options.workspace)?;
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(&options.workspace)?;
if let Some(parent) = database_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
let service = WorkspaceCatalogService::new(store);
service.create_with_workspace_id(
WorkspaceCreateRequest {
operation_key: format!("cli-init:{}", identity.workspace_id),
display_name: identity.display_name.clone(),
repository: InitialRepositoryIntent {
uri: options.workspace.display().to_string(),
display_name: Some("Main repository".to_string()),
default_ref: Some("HEAD".to_string()),
},
},
None,
Some(identity.workspace_id.clone()),
)?;
eprintln!(
"yoi-server: initialized workspace `{}` ({}) in server DB `{}`",
options.workspace.display(),
identity.workspace_id,
database_path.display()
);
Ok(())
}
fn run_config_default() -> Result<(), Box<dyn std::error::Error>> {
print!("{WORKSPACE_BACKEND_CONFIG_TEMPLATE}");
Ok(())
}
fn run_config_diff(options: WorkspacePathOptions) -> Result<(), Box<dyn std::error::Error>> {
let diff = WorkspaceBackendConfigFile::local_config_diff_for_workspace(&options.workspace)?;
print!("{}", diff.text);
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct ServerIdentityFile { struct ServerIdentityFile {
identity: RuntimeIdentityMaterial, identity: RuntimeIdentityMaterial,
@@ -626,10 +552,15 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
.to_path_buf(), .to_path_buf(),
) )
}; };
let host_config = match options.config.as_ref() {
Some(path) => ServerHostConfigFile::load_from_path(path)?,
None => ServerHostConfigFile::load_default()?,
};
let runtime_config = BackendRuntimesConfigFile::load_default()?; let runtime_config = BackendRuntimesConfigFile::load_default()?;
let mut resolved = WorkspaceBackendConfigFile::default().resolve_with_runtime_config( let mut resolved = ResolvedWorkspaceBackendConfig::local_dev(
&workspace_root, &workspace_root,
identity, identity,
&host_config,
&runtime_config, &runtime_config,
)?; )?;
resolved.database_path = database_path.clone(); resolved.database_path = database_path.clone();
@@ -638,7 +569,6 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
if let Some(listen) = options.listen { if let Some(listen) = options.listen {
resolved = resolved.with_listen(listen); resolved = resolved.with_listen(listen);
} }
resolved.server.allow_local_workspace_bootstrap = resolved.listen.ip().is_loopback();
let listener = TcpListener::bind(resolved.listen).await?; let listener = TcpListener::bind(resolved.listen).await?;
let local_addr = listener.local_addr()?; let local_addr = listener.local_addr()?;
@@ -722,41 +652,6 @@ fn infer_workspace_root_from_repositories(
)) ))
} }
fn parse_config_command(args: &[String]) -> Result<Command, CliError> {
let Some((subcommand, rest)) = args.split_first() else {
print_config_help();
return Ok(Command::Help);
};
match subcommand.as_str() {
"default" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_config_help();
return Ok(Command::Help);
}
if !rest.is_empty() {
return Err(CliError(
"config default does not accept options".to_string(),
));
}
Ok(Command::ConfigDefault)
}
"diff" => {
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
print_config_help();
return Ok(Command::Help);
}
Ok(Command::ConfigDiff(parse_workspace_path_options(rest)?))
}
"--help" | "-h" => {
print_config_help();
Ok(Command::Help)
}
other => Err(CliError(format!(
"unknown config subcommand `{other}`; expected `default` or `diff`"
))),
}
}
fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> { fn parse_migrate_command(args: &[String]) -> Result<Command, CliError> {
let mut dry_run = false; let mut dry_run = false;
let mut database = None; let mut database = None;
@@ -840,57 +735,9 @@ fn parse_skill_workspace_options(args: &[String]) -> Result<SkillWorkspaceOption
Ok(SkillWorkspaceOptions { workspace_id }) Ok(SkillWorkspaceOptions { workspace_id })
} }
fn parse_workspace_path_options(args: &[String]) -> Result<WorkspacePathOptions, CliError> {
let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--workspace" => {
let value = iter
.next()
.ok_or_else(|| CliError("--workspace requires a path".to_string()))?;
workspace = PathBuf::from(value);
}
value if value.starts_with("--workspace=") => {
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
}
other => return Err(CliError(format!("unknown workspace option `{other}`"))),
}
}
let workspace = workspace
.canonicalize()
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
Ok(WorkspacePathOptions { workspace })
}
fn parse_init_options(args: &[String]) -> Result<InitOptions, CliError> {
let mut workspace = std::env::current_dir()
.map_err(|error| CliError(format!("failed to read current dir: {error}")))?;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--workspace" => {
let value = iter
.next()
.ok_or_else(|| CliError("--workspace requires a path".to_string()))?;
workspace = PathBuf::from(value);
}
value if value.starts_with("--workspace=") => {
workspace = PathBuf::from(value_after_equals(arg, "--workspace")?);
}
other => return Err(CliError(format!("unknown init option `{other}`"))),
}
}
let workspace = workspace
.canonicalize()
.map_err(|error| CliError(format!("failed to canonicalize workspace: {error}")))?;
Ok(InitOptions { workspace })
}
fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> { fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
let mut listen = None; let mut listen = None;
let mut config = None;
let mut index = 0; let mut index = 0;
while index < args.len() { while index < args.len() {
@@ -906,6 +753,16 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
_ if arg.starts_with("--listen=") => { _ if arg.starts_with("--listen=") => {
listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?); listen = Some(parse_listen(value_after_equals(arg, "--listen")?)?);
} }
"--config" => {
index += 1;
let value = args
.get(index)
.ok_or_else(|| CliError("--config requires a path".to_string()))?;
config = Some(PathBuf::from(value));
}
_ if arg.starts_with("--config=") => {
config = Some(PathBuf::from(value_after_equals(arg, "--config")?));
}
_ if arg.starts_with('-') => { _ if arg.starts_with('-') => {
return Err(CliError(format!("unknown serve option `{arg}`"))); return Err(CliError(format!("unknown serve option `{arg}`")));
} }
@@ -918,7 +775,7 @@ fn parse_serve_options(args: &[String]) -> Result<ServeOptions, CliError> {
index += 1; index += 1;
} }
Ok(ServeOptions { listen }) Ok(ServeOptions { listen, config })
} }
fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> { fn value_after_equals<'a>(arg: &'a str, flag: &str) -> Result<&'a str, CliError> {
@@ -940,23 +797,11 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() { fn print_help() {
println!( println!(
"yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config <COMMAND> [OPTIONS]\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>] "yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id <RUNTIME_ID>\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
); );
} }
fn print_init_help() {
println!(
"yoi-server init\n\nUsage:\n yoi-server init [OPTIONS]\n\nDescription:\n Initializes a Workspace identity, copies the packaged Backend config template to .yoi/workspace-backend.local.toml, and registers the Workspace in the Yoi server DB.\n\nOptions:\n --workspace <PATH> Workspace root to initialize (defaults to cwd)\n -h, --help Print help"
);
}
fn print_config_help() {
println!(
"yoi-server config\n\nUsage:\n yoi-server config default\n yoi-server config diff [OPTIONS]\n\nDescription:\n Prints the packaged Workspace Backend config template or compares it with the workspace-local config.\n\nOptions for diff:\n --workspace <PATH> Workspace root (defaults to cwd)\n -h, --help Print help"
);
}
fn print_skills_help() { fn print_skills_help() {
println!( println!(
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help" "yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
@@ -966,24 +811,23 @@ fn print_skills_help() {
fn print_serve_help() { fn print_serve_help() {
println!( println!(
"yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>] "yoi-server serve\n\nUsage:\n yoi-server migrate --dry-run [--database <PATH>]
yoi-server serve [OPTIONS]\n\nDescription:\n Serves the Workspace recorded in the Yoi server DB. Workspace records are stored in the XDG/Yoi data directory, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n -h, --help Print help" yoi-server serve [OPTIONS]\n\nDescription:\n Serves Workspaces recorded in the Yoi server DB. Host-level deployment settings are loaded from the explicit --config path or the canonical XDG yoi/server.toml path, and runtime sources are loaded from XDG runtimes.toml.\n\nOptions:\n --listen <ADDR> Listen address (default 127.0.0.1:8787)\n --config <PATH> Host-level Server config path\n -h, --help Print help"
); );
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use yoi_workspace_server::{
WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH, WORKSPACE_BACKEND_CONFIG_TEMPLATE,
WORKSPACE_IDENTITY_RELATIVE_PATH,
};
#[test] #[test]
fn parse_init_defaults_workspace_to_cwd_or_flag() { fn removed_repository_local_commands_are_rejected() {
let temp = tempfile::tempdir().unwrap(); for command in ["init", "config"] {
let args = vec!["--workspace".to_string(), temp.path().display().to_string()]; let error = parse_command(&[command.to_string()]).unwrap_err();
let options = parse_init_options(&args).unwrap(); assert!(
assert_eq!(options.workspace, temp.path().canonicalize().unwrap()); error.to_string().contains("unknown command"),
"unexpected error for {command}: {error}"
);
}
} }
#[test] #[test]
@@ -1023,10 +867,18 @@ mod tests {
} }
#[test] #[test]
fn parse_serve_accepts_listen_only() { fn parse_serve_accepts_listen_and_host_config() {
let args = vec!["--listen".to_string(), "127.0.0.1:0".to_string()]; let args = vec![
"--listen".to_string(),
"127.0.0.1:0".to_string(),
"--config=/etc/yoi/server.toml".to_string(),
];
let options = parse_serve_options(&args).unwrap(); let options = parse_serve_options(&args).unwrap();
assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap()); assert_eq!(options.listen.unwrap(), "127.0.0.1:0".parse().unwrap());
assert_eq!(
options.config.unwrap(),
PathBuf::from("/etc/yoi/server.toml")
);
} }
#[test] #[test]
@@ -1081,49 +933,4 @@ mod tests {
); );
ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap(); ensure_trusted_runtime_replace_allowed(&store, "runtime-a", true).unwrap();
} }
#[tokio::test]
async fn init_creates_identity_local_config_and_server_records() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("data").join("server").join("server.db");
std::fs::create_dir(temp.path().join(".git")).unwrap();
run_init_with_database_path(
InitOptions {
workspace: temp.path().canonicalize().unwrap(),
},
database_path.clone(),
)
.await
.unwrap();
assert!(temp.path().join(WORKSPACE_IDENTITY_RELATIVE_PATH).exists());
let local_config_path = temp.path().join(WORKSPACE_BACKEND_CONFIG_RELATIVE_PATH);
assert!(local_config_path.exists());
assert_eq!(
std::fs::read_to_string(local_config_path).unwrap(),
WORKSPACE_BACKEND_CONFIG_TEMPLATE
);
assert!(
!temp
.path()
.join(".yoi/workspace-backend.default.toml")
.exists()
);
assert!(!temp.path().join(".yoi/workspace.db").exists());
assert!(!temp.path().join(".yoi/embedded-runtime").exists());
assert!(database_path.exists());
let store = SqliteWorkspaceStore::open(&database_path).unwrap();
let workspaces = store.list_workspaces().unwrap();
assert_eq!(workspaces.len(), 1);
let repositories = store
.list_repositories(&workspaces[0].workspace_id)
.unwrap();
assert_eq!(repositories.len(), 1);
assert_eq!(repositories[0].repository_id, "main");
assert_eq!(
repositories[0].source.uri,
temp.path().canonicalize().unwrap().display().to_string()
);
}
} }
+5 -7
View File
@@ -1,12 +1,9 @@
use project_record::validate_record_id;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub use workspace_api::{ pub use workspace_api::{
ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkedTicketSummary, ObjectiveResourceSummary, ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkedTicketSummary, ObjectiveResourceSummary,
ObjectiveSummary, QueryPage, ObjectiveSummary, QueryPage,
}; };
use crate::{Error, Result};
const SUMMARY_BODY_LIMIT: usize = 240; const SUMMARY_BODY_LIMIT: usize = 240;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -387,12 +384,16 @@ pub struct TicketQueryItem {
pub snippet: Option<String>, pub snippet: Option<String>,
pub matching_event: Option<TicketEvidenceEvent>, pub matching_event: Option<TicketEvidenceEvent>,
pub linked_objective_ids: Vec<String>, pub linked_objective_ids: Vec<String>,
#[ts(skip)]
pub linked_objective_keys: Vec<String>,
pub relation_count: usize, pub relation_count: usize,
pub blocker_count: usize, pub blocker_count: usize,
pub unresolved_blocker_count: usize, pub unresolved_blocker_count: usize,
pub unresolved_review_count: usize, pub unresolved_review_count: usize,
pub evidence: TicketEvidenceSummary, pub evidence: TicketEvidenceSummary,
pub merge_request: Option<TicketMergeRequestSummary>, pub merge_request: Option<TicketMergeRequestSummary>,
#[ts(skip)]
pub current_coder: Option<TicketAssignmentSummary>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -435,6 +436,7 @@ pub struct ObjectiveQueryItem {
pub snippet: Option<String>, pub snippet: Option<String>,
pub linked_ticket_count: usize, pub linked_ticket_count: usize,
pub linked_tickets: Vec<String>, pub linked_tickets: Vec<String>,
pub linked_ticket_keys: Vec<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -521,10 +523,6 @@ mod typescript_tests {
} }
} }
pub(crate) fn validate_project_id(id: &str) -> Result<()> {
validate_record_id(id).map_err(|_| Error::InvalidRecordId(id.to_string()))
}
pub(crate) fn summarize_body(body: &str) -> String { pub(crate) fn summarize_body(body: &str) -> String {
let summary = body let summary = body
.lines() .lines()
@@ -11,7 +11,7 @@ use ring::rand::{SecureRandom, SystemRandom};
use rusqlite::{OptionalExtension, TransactionBehavior, params}; use rusqlite::{OptionalExtension, TransactionBehavior, params};
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use ssh_key::{Algorithm, HashAlg, PrivateKey, PublicKey}; use ssh_key::{Algorithm, HashAlg, LineEnding, PrivateKey, PublicKey};
use workspace_api::{ use workspace_api::{
CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest, CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest,
DeleteRepositorySshHostTrustRequest, PutRepositorySshHostTrustRequest, RepositoryAccessMode, DeleteRepositorySshHostTrustRequest, PutRepositorySshHostTrustRequest, RepositoryAccessMode,
@@ -59,7 +59,6 @@ impl WorkspaceConfigSchemaProvider for RepositoryAccessConfigSchemaProvider {
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct VirtualWorkspaceConfig { struct VirtualWorkspaceConfig {
#[serde(default)] #[serde(default)]
repository_access: BTreeMap<String, VirtualRepositoryAccess>, repository_access: BTreeMap<String, VirtualRepositoryAccess>,
@@ -219,6 +218,16 @@ fn project_repository_access_evaluation(
}) })
} }
#[derive(Clone)]
pub struct LeasedRepositorySshAccess {
pub credential_id: String,
pub credential_revision: u64,
pub host_trust_id: String,
pub host_trust_revision: u64,
pub private_key: zeroize::Zeroizing<String>,
pub known_hosts_entry: String,
}
#[derive(Clone)] #[derive(Clone)]
pub struct RepositorySecretService { pub struct RepositorySecretService {
store: Arc<SqliteWorkspaceStore>, store: Arc<SqliteWorkspaceStore>,
@@ -829,6 +838,184 @@ impl RepositorySecretService {
}) })
} }
pub fn lease_ssh_materialization_access(
&self,
workspace_id: &str,
binding: &RepositorySshAccessBinding,
) -> Result<LeasedRepositorySshAccess> {
let credential = self
.get_credential(workspace_id, &binding.credential_id, &[])?
.ok_or_else(|| {
Error::InvalidInput(format!(
"unknown Repository SSH credential `{}`",
binding.credential_id
))
})?;
if credential.status != "active" {
return Err(Error::InvalidInput(format!(
"Repository SSH credential `{}` is not active",
binding.credential_id
)));
}
let host_trust = self
.get_host_trust(workspace_id, &binding.host_trust_id, &[])?
.ok_or_else(|| {
Error::InvalidInput(format!(
"unknown Repository SSH host trust `{}`",
binding.host_trust_id
))
})?;
self.lease_ssh_materialization_access_revision(
workspace_id,
&binding.credential_id,
credential.current_revision,
&binding.host_trust_id,
host_trust.current_revision,
)
}
pub fn lease_ssh_materialization_access_revision(
&self,
workspace_id: &str,
credential_id: &str,
credential_revision: u64,
host_trust_id: &str,
host_trust_revision: u64,
) -> Result<LeasedRepositorySshAccess> {
let (private_key, passphrase, hostname, port, host_key) = self.store.with_conn(|conn| {
let private_key = read_sealed_secret(
conn,
workspace_id,
credential_id,
credential_revision,
"private_key",
)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Repository SSH credential `{credential_id}` revision {credential_revision} is unavailable"
))
})?;
let passphrase = read_sealed_secret(
conn,
workspace_id,
credential_id,
credential_revision,
"passphrase",
)?;
let (hostname, port, host_key) = conn
.query_row(
r#"SELECT h.hostname, h.port, v.host_key
FROM repository_ssh_host_trusts h
JOIN repository_ssh_host_trust_revisions v
ON v.workspace_id = h.workspace_id
AND v.host_trust_id = h.host_trust_id
WHERE h.workspace_id = ?1 AND h.host_trust_id = ?2
AND v.revision = ?3"#,
params![workspace_id, host_trust_id, host_trust_revision as i64],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1)? as u16,
row.get::<_, String>(2)?,
))
},
)
.optional()?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Repository SSH host trust `{host_trust_id}` revision {host_trust_revision} is unavailable"
))
})?;
Ok((private_key, passphrase, hostname, port, host_key))
})?;
let private_key = self.unseal(
workspace_id,
credential_id,
credential_revision,
"private_key",
private_key,
)?;
let passphrase = passphrase
.map(|secret| {
self.unseal(
workspace_id,
credential_id,
credential_revision,
"passphrase",
secret,
)
})
.transpose()?;
let private_key =
zeroize::Zeroizing::new(String::from_utf8(private_key).map_err(|_| {
Error::Store("Repository SSH private key plaintext is invalid".to_string())
})?);
let passphrase = passphrase
.map(|value| {
String::from_utf8(value)
.map(zeroize::Zeroizing::new)
.map_err(|_| {
Error::Store("Repository SSH passphrase plaintext is invalid".to_string())
})
})
.transpose()?;
let key = PrivateKey::from_openssh(private_key.as_str()).map_err(|_| {
Error::Store("Repository SSH private key plaintext is invalid".to_string())
})?;
let key = if key.is_encrypted() {
key.decrypt(passphrase.as_deref().ok_or_else(|| {
Error::Store("Repository SSH passphrase revision is unavailable".to_string())
})?)
.map_err(|_| Error::Store("Repository SSH private key decryption failed".to_string()))?
} else {
key
};
let private_key = key
.to_openssh(LineEnding::LF)
.map_err(|_| Error::Store("Repository SSH private key encoding failed".to_string()))?;
let host = if port == 22 {
hostname
} else {
format!("[{hostname}]:{port}")
};
Ok(LeasedRepositorySshAccess {
credential_id: credential_id.to_string(),
credential_revision,
host_trust_id: host_trust_id.to_string(),
host_trust_revision,
private_key,
known_hosts_entry: format!("{host} {host_key}\n"),
})
}
fn unseal(
&self,
workspace_id: &str,
credential_id: &str,
revision: u64,
purpose: &str,
secret: SealedSecret,
) -> Result<Vec<u8>> {
let master_key = self.master_key.as_ref().ok_or_else(|| {
Error::Store("Repository secret encryption authority is unavailable".to_string())
})?;
let unbound = UnboundKey::new(&AES_256_GCM, master_key.as_slice())
.map_err(|_| Error::Store("Repository secret encryption key is invalid".to_string()))?;
let key = LessSafeKey::new(unbound);
let mut plaintext = secret.ciphertext;
let aad = secret_aad(workspace_id, credential_id, revision, purpose);
let plaintext_len = key
.open_in_place(
Nonce::assume_unique_for_key(secret.nonce),
Aad::from(aad.as_bytes()),
&mut plaintext,
)
.map_err(|_| Error::Store("Repository secret decryption failed".to_string()))?
.len();
plaintext.truncate(plaintext_len);
Ok(plaintext)
}
fn seal( fn seal(
&self, &self,
workspace_id: &str, workspace_id: &str,
@@ -968,6 +1155,45 @@ fn insert_secret(
Ok(()) Ok(())
} }
fn read_sealed_secret(
conn: &rusqlite::Connection,
workspace_id: &str,
credential_id: &str,
revision: u64,
purpose: &str,
) -> Result<Option<SealedSecret>> {
let row = conn
.query_row(
r#"SELECT encryption_algorithm, nonce, ciphertext
FROM server_secret_versions
WHERE workspace_id = ?1 AND secret_id = ?2
AND revision = ?3 AND purpose = ?4"#,
params![workspace_id, credential_id, revision, purpose],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Vec<u8>>(1)?,
row.get::<_, Vec<u8>>(2)?,
))
},
)
.optional()?;
let Some((algorithm, nonce, ciphertext)) = row else {
return Ok(None);
};
if algorithm != "aes-256-gcm-v1" || nonce.len() != NONCE_BYTES {
return Err(Error::RegistryInconsistency(
"Repository secret envelope is invalid".to_string(),
));
}
let mut nonce_bytes = [0u8; NONCE_BYTES];
nonce_bytes.copy_from_slice(&nonce);
Ok(Some(SealedSecret {
nonce: nonce_bytes,
ciphertext,
}))
}
fn replay_credential_operation( fn replay_credential_operation(
tx: &rusqlite::Transaction<'_>, tx: &rusqlite::Transaction<'_>,
workspace_id: &str, workspace_id: &str,
@@ -1691,6 +1917,29 @@ mod tests {
projection.bindings[0].access, projection.bindings[0].access,
RepositoryAccessMode::ReadOnly RepositoryAccessMode::ReadOnly
); );
let lease = service
.lease_ssh_materialization_access("workspace-a", &projection.bindings[0])
.unwrap();
assert_eq!(lease.credential_revision, 1);
assert_eq!(lease.host_trust_revision, 1);
assert!(lease.private_key.contains("BEGIN OPENSSH PRIVATE KEY"));
assert!(
lease
.known_hosts_entry
.starts_with("example.test ssh-ed25519 ")
);
let exact = service
.lease_ssh_materialization_access_revision(
"workspace-a",
"deploy",
lease.credential_revision,
"example",
lease.host_trust_revision,
)
.unwrap();
assert_eq!(exact.credential_revision, lease.credential_revision);
assert_eq!(exact.host_trust_revision, lease.host_trust_revision);
assert_eq!(exact.known_hosts_entry, lease.known_hosts_entry);
let unknown = config_state( let unknown = config_state(
r#"{ r#"{
+189 -23
View File
@@ -9,7 +9,8 @@ use worker_runtime::resource::{
BackendResourceClient, BackendResourceError, BackendResourceFetchRequest, BackendResourceClient, BackendResourceError, BackendResourceFetchRequest,
BackendResourceFetchResponse, BackendResourceHandle, BackendResourceKind, BackendResourceFetchResponse, BackendResourceHandle, BackendResourceKind,
BackendResourceOperation, DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES, BackendResourceOperation, DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES,
PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE, ResourceRedactionPolicy, DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES, PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE,
REPOSITORY_SSH_ACCESS_CONTENT_TYPE, RepositorySshAccessSecret, ResourceRedactionPolicy,
}; };
#[derive(Clone, Default)] #[derive(Clone, Default)]
@@ -29,7 +30,34 @@ struct StoredResource {
runtime_id: Option<String>, runtime_id: Option<String>,
worker: Option<RuntimeWorkerRef>, worker: Option<RuntimeWorkerRef>,
handle: BackendResourceHandle, handle: BackendResourceHandle,
archive: ProfileSourceArchive, bytes: Vec<u8>,
archive: Option<ProfileSourceArchive>,
one_shot: bool,
}
impl StoredResource {
fn byte_len(&self) -> usize {
self.archive
.as_ref()
.map(|archive| archive.content.len())
.unwrap_or_else(|| self.bytes.len())
}
fn take_bytes(&mut self) -> Vec<u8> {
self.archive
.as_mut()
.map(|archive| std::mem::take(&mut archive.content))
.unwrap_or_else(|| std::mem::take(&mut self.bytes))
}
}
impl Drop for StoredResource {
fn drop(&mut self) {
self.bytes.fill(0);
if let Some(archive) = self.archive.as_mut() {
archive.content.fill(0);
}
}
} }
impl BackendResourceBroker { impl BackendResourceBroker {
@@ -73,7 +101,9 @@ impl BackendResourceBroker {
runtime_id, runtime_id,
worker, worker,
handle: handle.clone(), handle: handle.clone(),
archive, bytes: Vec::new(),
archive: Some(archive),
one_shot: false,
}; };
if let Ok(mut resources) = self.resources.lock() { if let Ok(mut resources) = self.resources.lock() {
resources.insert(nonce, stored); resources.insert(nonce, stored);
@@ -81,6 +111,84 @@ impl BackendResourceBroker {
handle handle
} }
pub fn issue_repository_ssh_access_handle(
&self,
workspace_id: impl Into<String>,
runtime_id: &str,
resource_id: impl Into<String>,
revision: impl Into<String>,
expires_at_unix_seconds: i64,
secret: RepositorySshAccessSecret,
) -> Result<BackendResourceHandle, BackendResourceError> {
let bytes =
serde_json::to_vec(&secret).map_err(|error| BackendResourceError::InvalidResponse {
message: error.to_string(),
})?;
if bytes.len() as u64 > DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES {
return Err(BackendResourceError::Oversized {
max_bytes: DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
actual_bytes: bytes.len() as u64,
});
}
let workspace_id = workspace_id.into();
let resource_id = resource_id.into();
let revision = revision.into();
let nonce = Uuid::now_v7().to_string();
let handle = BackendResourceHandle {
kind: BackendResourceKind::RepositorySshAccess,
workspace_id,
scope_id: Some("repository-ssh-access".to_string()),
runtime_id: Some(runtime_id.to_string()),
worker_id: None,
resource_id,
digest: format!("opaque:{nonce}"),
operation: BackendResourceOperation::FetchOnce,
expires_at_unix_seconds,
nonce: nonce.clone(),
revision,
generation: None,
max_bytes: DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
content_type: REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
redaction: ResourceRedactionPolicy::RuntimeInternalOnly,
audit_correlation_id: format!("repository-ssh-access-{nonce}"),
profile_source_graph: None,
};
let stored = StoredResource {
runtime_id: Some(runtime_id.to_string()),
worker: None,
handle: handle.clone(),
bytes,
archive: None,
one_shot: true,
};
let resource_key = nonce.clone();
self.resources
.lock()
.map_err(|_| BackendResourceError::Transport {
message: "resource broker lock poisoned".to_string(),
})?
.insert(resource_key.clone(), stored);
if expires_at_unix_seconds != i64::MAX {
let resources = self.resources.clone();
std::thread::spawn(move || {
let now = Utc::now().timestamp();
if expires_at_unix_seconds > now {
std::thread::sleep(std::time::Duration::from_secs(
(expires_at_unix_seconds - now) as u64,
));
}
if let Ok(mut resources) = resources.lock()
&& resources
.get(&resource_key)
.is_some_and(|stored| stored.handle.nonce == resource_key)
{
resources.remove(&resource_key);
}
});
}
Ok(handle)
}
pub fn profile_source_archive( pub fn profile_source_archive(
&self, &self,
digest: &str, digest: &str,
@@ -90,20 +198,21 @@ impl BackendResourceBroker {
.ok()? .ok()?
.values() .values()
.find(|resource| resource.handle.digest == digest) .find(|resource| resource.handle.digest == digest)
.map(|resource| resource.archive.clone()) .and_then(|resource| resource.archive.clone())
} }
pub fn fetch_profile_source_archive( pub fn fetch_resource(
&self, &self,
request: BackendResourceFetchRequest, request: BackendResourceFetchRequest,
) -> Result<BackendResourceFetchResponse, BackendResourceError> { ) -> Result<BackendResourceFetchResponse, BackendResourceError> {
verify_handle_shape(&request.handle)?; verify_handle_shape(&request.handle)?;
let stored = self let mut resources = self
.resources .resources
.lock() .lock()
.map_err(|_| BackendResourceError::Transport { .map_err(|_| BackendResourceError::Transport {
message: "resource broker lock poisoned".to_string(), message: "resource broker lock poisoned".to_string(),
})? })?;
let mut stored = resources
.get(&request.handle.nonce) .get(&request.handle.nonce)
.cloned() .cloned()
.ok_or(BackendResourceError::MissingResource)?; .ok_or(BackendResourceError::MissingResource)?;
@@ -111,7 +220,7 @@ impl BackendResourceBroker {
if stored.handle.expires_at_unix_seconds < Utc::now().timestamp() { if stored.handle.expires_at_unix_seconds < Utc::now().timestamp() {
return Err(BackendResourceError::Expired); return Err(BackendResourceError::Expired);
} }
let actual_bytes = stored.archive.content.len() as u64; let actual_bytes = stored.byte_len() as u64;
if actual_bytes > stored.handle.max_bytes { if actual_bytes > stored.handle.max_bytes {
return Err(BackendResourceError::Oversized { return Err(BackendResourceError::Oversized {
max_bytes: stored.handle.max_bytes, max_bytes: stored.handle.max_bytes,
@@ -139,12 +248,15 @@ impl BackendResourceBroker {
}); });
} }
} }
if stored.one_shot {
resources.remove(&request.handle.nonce);
}
Ok(BackendResourceFetchResponse { Ok(BackendResourceFetchResponse {
kind: BackendResourceKind::ProfileSourceArchive, kind: stored.handle.kind.clone(),
resource_id: stored.archive.reference.id, resource_id: stored.handle.resource_id.clone(),
digest: stored.archive.reference.digest, digest: stored.handle.digest.clone(),
content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), content_type: stored.handle.content_type.clone(),
bytes: stored.archive.content, bytes: stored.take_bytes(),
audit_correlation_id: request.audit_correlation_id, audit_correlation_id: request.audit_correlation_id,
}) })
} }
@@ -156,14 +268,13 @@ impl BackendResourceClient for BackendResourceBroker {
&self, &self,
request: BackendResourceFetchRequest, request: BackendResourceFetchRequest,
) -> Result<BackendResourceFetchResponse, BackendResourceError> { ) -> Result<BackendResourceFetchResponse, BackendResourceError> {
self.fetch_profile_source_archive(request) self.fetch_resource(request)
} }
} }
fn verify_handle_shape(handle: &BackendResourceHandle) -> Result<(), BackendResourceError> { fn verify_handle_shape(handle: &BackendResourceHandle) -> Result<(), BackendResourceError> {
if handle.kind != BackendResourceKind::ProfileSourceArchive { match handle.kind {
return Err(BackendResourceError::UnsupportedKind); BackendResourceKind::ProfileSourceArchive => {
}
if handle.operation != BackendResourceOperation::FetchArchive { if handle.operation != BackendResourceOperation::FetchArchive {
return Err(BackendResourceError::Unauthorized { return Err(BackendResourceError::Unauthorized {
message: "resource handle operation is not fetch_archive".to_string(), message: "resource handle operation is not fetch_archive".to_string(),
@@ -175,6 +286,21 @@ fn verify_handle_shape(handle: &BackendResourceHandle) -> Result<(), BackendReso
actual: handle.content_type.clone(), actual: handle.content_type.clone(),
}); });
} }
}
BackendResourceKind::RepositorySshAccess => {
if handle.operation != BackendResourceOperation::FetchOnce {
return Err(BackendResourceError::Unauthorized {
message: "resource handle operation is not fetch_once".to_string(),
});
}
if handle.content_type != REPOSITORY_SSH_ACCESS_CONTENT_TYPE {
return Err(BackendResourceError::ContentTypeMismatch {
expected: REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
actual: handle.content_type.clone(),
});
}
}
}
Ok(()) Ok(())
} }
@@ -249,7 +375,7 @@ mod tests {
archive(), archive(),
); );
let response = broker let response = broker
.fetch_profile_source_archive(BackendResourceFetchRequest { .fetch_resource(BackendResourceFetchRequest {
handle: handle.clone(), handle: handle.clone(),
runtime_id: runtime_id.to_string(), runtime_id: runtime_id.to_string(),
worker_id: None, worker_id: None,
@@ -260,6 +386,46 @@ mod tests {
assert_eq!(response.content_type, PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE); assert_eq!(response.content_type, PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE);
} }
#[test]
fn repository_ssh_access_resource_is_runtime_bound_and_one_shot() {
let broker = BackendResourceBroker::default();
let handle = broker
.issue_repository_ssh_access_handle(
"workspace-test",
"runtime-test",
"repository-access-test",
"1",
i64::MAX,
RepositorySshAccessSecret {
private_key: "private-key-bytes".to_string(),
known_hosts_entry: "known-hosts-entry".to_string(),
},
)
.unwrap();
let unauthorized = broker
.fetch_resource(request(handle.clone(), "runtime-other", None))
.unwrap_err();
assert!(matches!(
unauthorized,
BackendResourceError::Unauthorized { .. }
));
let response = broker
.fetch_resource(request(handle.clone(), "runtime-test", None))
.unwrap();
assert_eq!(response.kind, BackendResourceKind::RepositorySshAccess);
assert_eq!(response.content_type, REPOSITORY_SSH_ACCESS_CONTENT_TYPE);
let debug = format!("{response:?}");
assert!(!debug.contains("private-key-bytes"));
assert!(debug.contains("REDACTED"));
let secret: RepositorySshAccessSecret = serde_json::from_slice(&response.bytes).unwrap();
assert_eq!(secret.private_key, "private-key-bytes");
assert!(matches!(
broker.fetch_resource(request(handle, "runtime-test", None)),
Err(BackendResourceError::MissingResource)
));
}
#[test] #[test]
fn broker_rejects_runtime_mismatch() { fn broker_rejects_runtime_mismatch() {
let broker = BackendResourceBroker::default(); let broker = BackendResourceBroker::default();
@@ -270,7 +436,7 @@ mod tests {
archive(), archive(),
); );
let err = broker let err = broker
.fetch_profile_source_archive(request(handle, "runtime-b", None)) .fetch_resource(request(handle, "runtime-b", None))
.unwrap_err(); .unwrap_err();
assert!(matches!(err, BackendResourceError::Unauthorized { .. })); assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
} }
@@ -287,7 +453,7 @@ mod tests {
archive(), archive(),
); );
let err = broker let err = broker
.fetch_profile_source_archive(request(handle, runtime_id, Some(&worker_b.worker_id))) .fetch_resource(request(handle, runtime_id, Some(&worker_b.worker_id)))
.unwrap_err(); .unwrap_err();
assert!(matches!(err, BackendResourceError::Unauthorized { .. })); assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
} }
@@ -312,7 +478,7 @@ mod tests {
let mut extended = handle; let mut extended = handle;
extended.expires_at_unix_seconds = 4_102_444_800; extended.expires_at_unix_seconds = 4_102_444_800;
let err = broker let err = broker
.fetch_profile_source_archive(request(extended, &runtime_id, None)) .fetch_resource(request(extended, &runtime_id, None))
.unwrap_err(); .unwrap_err();
assert!(matches!(err, BackendResourceError::Expired)); assert!(matches!(err, BackendResourceError::Expired));
} }
@@ -328,7 +494,7 @@ mod tests {
); );
handle.scope_id = Some("tampered-scope".to_string()); handle.scope_id = Some("tampered-scope".to_string());
let err = broker let err = broker
.fetch_profile_source_archive(request(handle, &runtime_id, None)) .fetch_resource(request(handle, &runtime_id, None))
.unwrap_err(); .unwrap_err();
assert!(matches!(err, BackendResourceError::Unauthorized { .. })); assert!(matches!(err, BackendResourceError::Unauthorized { .. }));
} }
@@ -345,7 +511,7 @@ mod tests {
); );
handle.max_bytes = DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES + 1024; handle.max_bytes = DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES + 1024;
let err = broker let err = broker
.fetch_profile_source_archive(request(handle, &runtime_id, None)) .fetch_resource(request(handle, &runtime_id, None))
.unwrap_err(); .unwrap_err();
assert!(matches!(err, BackendResourceError::Oversized { .. })); assert!(matches!(err, BackendResourceError::Oversized { .. }));
} }
File diff suppressed because it is too large Load Diff
+199 -32
View File
@@ -257,6 +257,11 @@ const MIGRATIONS: &[Migration] = &[
name: "create Workspace Repository SSH secret authority", name: "create Workspace Repository SSH secret authority",
apply: create_repository_ssh_secret_authority, apply: create_repository_ssh_secret_authority,
}, },
Migration {
version: 47,
name: "bind Workdir create repository access evidence",
apply: bind_workdir_create_repository_access_evidence,
},
]; ];
struct Migration { struct Migration {
@@ -590,6 +595,16 @@ pub struct WorkdirCreateOperationRecord {
pub resolved_runtime_id: String, pub resolved_runtime_id: String,
pub config_revision: u64, pub config_revision: u64,
pub config_projection_digest: String, pub config_projection_digest: String,
pub source_kind: Option<String>,
pub source_uri: Option<String>,
pub source_revision: Option<u64>,
pub source_fingerprint: Option<String>,
pub credential_id: Option<String>,
pub credential_revision: Option<u64>,
pub host_trust_id: Option<String>,
pub host_trust_revision: Option<u64>,
pub repository_access_mode: Option<String>,
pub cache_generation: u64,
pub working_directory_id: String, pub working_directory_id: String,
pub state: String, pub state: String,
pub failure: Option<String>, pub failure: Option<String>,
@@ -605,8 +620,11 @@ pub struct WorkdirRegistryRecord {
pub repository_id: String, pub repository_id: String,
pub creation_selector: Option<String>, pub creation_selector: Option<String>,
pub creation_ref: Option<String>, pub creation_ref: Option<String>,
pub creation_tree: Option<String>,
pub current_selector: Option<String>, pub current_selector: Option<String>,
pub current_ref: Option<String>, pub current_ref: Option<String>,
pub current_tree: Option<String>,
pub observed_at_epoch_seconds: Option<u64>,
pub materialization_status: String, pub materialization_status: String,
pub cleanliness: String, pub cleanliness: String,
pub created_at: String, pub created_at: String,
@@ -1007,6 +1025,11 @@ pub trait ControlPlaneStore: Send + Sync {
workspace_id: &str, workspace_id: &str,
worker: &RuntimeWorkerRef, worker: &RuntimeWorkerRef,
) -> Result<Option<WorkerRegistryRecord>>; ) -> Result<Option<WorkerRegistryRecord>>;
fn has_active_worker_create_reservation(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
) -> Result<bool>;
fn list_worker_registry( fn list_worker_registry(
&self, &self,
workspace_id: &str, workspace_id: &str,
@@ -3273,6 +3296,28 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}) })
} }
fn has_active_worker_create_reservation(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
) -> Result<bool> {
self.with_conn(|conn| {
conn.query_row(
r#"SELECT EXISTS(
SELECT 1
FROM worker_create_reservations
WHERE workspace_id = ?1
AND runtime_id = ?2
AND worker_id = ?3
AND state = 'reserved'
)"#,
params![workspace_id, worker.runtime_id, worker.worker_id],
|row| row.get::<_, bool>(0),
)
.map_err(Error::from)
})
}
fn list_worker_registry( fn list_worker_registry(
&self, &self,
workspace_id: &str, workspace_id: &str,
@@ -4612,16 +4657,20 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
conn.execute( conn.execute(
r#"INSERT INTO workdir_registry ( r#"INSERT INTO workdir_registry (
workspace_id, workdir_id, runtime_id, repository_id, workspace_id, workdir_id, runtime_id, repository_id,
creation_selector, creation_ref, current_selector, current_ref, creation_selector, creation_ref, creation_tree,
current_selector, current_ref, current_tree, observed_at_epoch_seconds,
materialization_status, cleanliness, created_at, updated_at materialization_status, cleanliness, created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
ON CONFLICT(workspace_id, workdir_id) DO UPDATE SET ON CONFLICT(workspace_id, workdir_id) DO UPDATE SET
runtime_id = excluded.runtime_id, runtime_id = excluded.runtime_id,
repository_id = excluded.repository_id, repository_id = excluded.repository_id,
creation_selector = excluded.creation_selector, creation_selector = excluded.creation_selector,
creation_ref = excluded.creation_ref, creation_ref = excluded.creation_ref,
creation_tree = excluded.creation_tree,
current_selector = excluded.current_selector, current_selector = excluded.current_selector,
current_ref = excluded.current_ref, current_ref = excluded.current_ref,
current_tree = excluded.current_tree,
observed_at_epoch_seconds = excluded.observed_at_epoch_seconds,
materialization_status = excluded.materialization_status, materialization_status = excluded.materialization_status,
cleanliness = excluded.cleanliness, cleanliness = excluded.cleanliness,
updated_at = excluded.updated_at"#, updated_at = excluded.updated_at"#,
@@ -4632,8 +4681,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
record.repository_id, record.repository_id,
record.creation_selector, record.creation_selector,
record.creation_ref, record.creation_ref,
record.creation_tree,
record.current_selector, record.current_selector,
record.current_ref, record.current_ref,
record.current_tree,
record.observed_at_epoch_seconds.map(|value| value as i64),
record.materialization_status, record.materialization_status,
record.cleanliness, record.cleanliness,
record.created_at, record.created_at,
@@ -5853,7 +5905,8 @@ fn require_expected_ticket_assignment(
fn workdir_registry_select_sql(where_clause: &str) -> String { fn workdir_registry_select_sql(where_clause: &str) -> String {
format!( format!(
"SELECT workspace_id, workdir_id, runtime_id, repository_id, \ "SELECT workspace_id, workdir_id, runtime_id, repository_id, \
creation_selector, creation_ref, current_selector, current_ref, \ creation_selector, creation_ref, creation_tree, \
current_selector, current_ref, current_tree, observed_at_epoch_seconds, \
materialization_status, cleanliness, created_at, updated_at \ materialization_status, cleanliness, created_at, updated_at \
FROM workdir_registry {where_clause}" FROM workdir_registry {where_clause}"
) )
@@ -5869,12 +5922,15 @@ fn read_workdir_registry_record(
repository_id: row.get(3)?, repository_id: row.get(3)?,
creation_selector: row.get(4)?, creation_selector: row.get(4)?,
creation_ref: row.get(5)?, creation_ref: row.get(5)?,
current_selector: row.get(6)?, creation_tree: row.get(6)?,
current_ref: row.get(7)?, current_selector: row.get(7)?,
materialization_status: row.get(8)?, current_ref: row.get(8)?,
cleanliness: row.get(9)?, current_tree: row.get(9)?,
created_at: row.get(10)?, observed_at_epoch_seconds: row.get::<_, Option<i64>>(10)?.map(|value| value as u64),
updated_at: row.get(11)?, materialization_status: row.get(11)?,
cleanliness: row.get(12)?,
created_at: row.get(13)?,
updated_at: row.get(14)?,
}) })
} }
@@ -6812,6 +6868,28 @@ fn create_repository_ssh_secret_authority(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
fn bind_workdir_create_repository_access_evidence(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
ALTER TABLE workdir_registry ADD COLUMN creation_tree TEXT;
ALTER TABLE workdir_registry ADD COLUMN current_tree TEXT;
ALTER TABLE workdir_registry ADD COLUMN observed_at_epoch_seconds INTEGER;
ALTER TABLE workdir_create_operations ADD COLUMN source_kind TEXT;
ALTER TABLE workdir_create_operations ADD COLUMN source_uri TEXT;
ALTER TABLE workdir_create_operations ADD COLUMN source_revision INTEGER;
ALTER TABLE workdir_create_operations ADD COLUMN source_fingerprint TEXT;
ALTER TABLE workdir_create_operations ADD COLUMN credential_id TEXT;
ALTER TABLE workdir_create_operations ADD COLUMN credential_revision INTEGER;
ALTER TABLE workdir_create_operations ADD COLUMN host_trust_id TEXT;
ALTER TABLE workdir_create_operations ADD COLUMN host_trust_revision INTEGER;
ALTER TABLE workdir_create_operations ADD COLUMN repository_access_mode TEXT;
ALTER TABLE workdir_create_operations
ADD COLUMN cache_generation INTEGER NOT NULL DEFAULT 0;
"#,
)?;
Ok(())
}
fn create_workspace_catalog_operations(conn: &Connection) -> Result<()> { fn create_workspace_catalog_operations(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
r#" r#"
@@ -9697,7 +9775,7 @@ mod tests {
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46); assert_eq!(current_schema_version(&conn).unwrap(), 47);
let remote = conn let remote = conn
.query_row( .query_row(
"SELECT source_kind, source_uri, source_revision, source_fingerprint, observed_status \ "SELECT source_kind, source_uri, source_revision, source_fingerprint, observed_status \
@@ -9775,7 +9853,7 @@ mod tests {
let before = std::fs::read(&path).unwrap(); let before = std::fs::read(&path).unwrap();
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
assert_eq!(plan.current_schema_version, 36); assert_eq!(plan.current_schema_version, 36);
assert_eq!(plan.target_schema_version, 46); assert_eq!(plan.target_schema_version, 47);
assert!(plan.migration_required); assert!(plan.migration_required);
assert_eq!(plan.worker_count, 1); assert_eq!(plan.worker_count, 1);
assert_eq!(plan.mappings[0].legacy_worker_id, 7); assert_eq!(plan.mappings[0].legacy_worker_id, 7);
@@ -9789,7 +9867,7 @@ mod tests {
store store
.with_conn(|conn| { .with_conn(|conn| {
assert!(table_exists(conn, "worker_diagnostics_archives")?); assert!(table_exists(conn, "worker_diagnostics_archives")?);
assert_eq!(current_schema_version(conn)?, 46); assert_eq!(current_schema_version(conn)?, 47);
Ok(()) Ok(())
}) })
.unwrap(); .unwrap();
@@ -9925,7 +10003,7 @@ mod tests {
), ),
] ]
); );
assert_eq!(current_schema_version(&conn).unwrap(), 46); assert_eq!(current_schema_version(&conn).unwrap(), 47);
let foreign_key_error: Option<String> = conn let foreign_key_error: Option<String> = conn
.query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) .query_row("PRAGMA foreign_key_check", [], |row| row.get(0))
.optional() .optional()
@@ -10054,7 +10132,7 @@ INSERT INTO worker_orphan_diagnostics (
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46); assert_eq!(current_schema_version(&conn).unwrap(), 47);
assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap());
let controller_worker_id: String = conn let controller_worker_id: String = conn
.query_row( .query_row(
@@ -10172,7 +10250,7 @@ INSERT INTO worker_orphan_diagnostics (
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46); assert_eq!(current_schema_version(&conn).unwrap(), 47);
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
} }
@@ -10190,7 +10268,7 @@ INSERT INTO worker_orphan_diagnostics (
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46); assert_eq!(current_schema_version(&conn).unwrap(), 47);
let settings = conn let settings = conn
.query_row( .query_row(
"SELECT settings_revision, language FROM workspace_memory_settings \ "SELECT settings_revision, language FROM workspace_memory_settings \
@@ -10231,7 +10309,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY);
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46); assert_eq!(current_schema_version(&conn).unwrap(), 47);
assert!(table_exists(&conn, "flow_sources").unwrap()); assert!(table_exists(&conn, "flow_sources").unwrap());
assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap());
assert!(!table_exists(&conn, "flow_instances").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap());
@@ -10298,7 +10376,7 @@ INSERT INTO worker_workdir_attachment_reservations (
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46); assert_eq!(current_schema_version(&conn).unwrap(), 47);
let repositories_sql: String = conn let repositories_sql: String = conn
.query_row( .query_row(
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'",
@@ -10481,7 +10559,7 @@ INSERT INTO workdir_registry (
let db = dir.path().join("control-plane.sqlite"); let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap(); let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 46); assert_eq!(store.schema_version().await.unwrap(), 47);
assert!( assert!(
!store !store
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) .with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
@@ -10498,7 +10576,7 @@ INSERT INTO workdir_registry (
store.upsert_workspace(&record).await.unwrap(); store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 46); assert_eq!(reopened.schema_version().await.unwrap(), 47);
assert_eq!( assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(), reopened.get_workspace("local-dev").await.unwrap(),
Some(record) Some(record)
@@ -10585,6 +10663,12 @@ INSERT INTO workdir_registry (
); );
assert_eq!(reserved.memory_settings.settings_revision, 1); assert_eq!(reserved.memory_settings.settings_revision, 1);
assert_eq!(reserved.memory_settings.language, "English"); assert_eq!(reserved.memory_settings.language, "English");
let reserved_worker = RuntimeWorkerRef::new("arcadia", reserved.worker_id.to_string());
assert!(
store
.has_active_worker_create_reservation("workspace-a", &reserved_worker)
.unwrap()
);
let unchanged_memory_settings = store let unchanged_memory_settings = store
.update_workspace_memory_settings("workspace-a", 1, " English ") .update_workspace_memory_settings("workspace-a", 1, " English ")
.unwrap(); .unwrap();
@@ -10658,6 +10742,11 @@ INSERT INTO workdir_registry (
store store
.complete_worker_create_reservation("workspace-a", reserved.worker_id) .complete_worker_create_reservation("workspace-a", reserved.worker_id)
.unwrap(); .unwrap();
assert!(
!store
.has_active_worker_create_reservation("workspace-a", &reserved_worker)
.unwrap()
);
let state: String = store let state: String = store
.with_conn(|conn| { .with_conn(|conn| {
conn.query_row( conn.query_row(
@@ -11252,7 +11341,7 @@ INSERT INTO worker_registry (
let migrated = SqliteWorkspaceStore::open(&db_path).unwrap(); let migrated = SqliteWorkspaceStore::open(&db_path).unwrap();
migrated migrated
.with_conn(|conn| { .with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 46); assert_eq!(current_schema_version(conn)?, 47);
assert_eq!( assert_eq!(
conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?, conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?,
1, 1,
@@ -11609,13 +11698,16 @@ INSERT INTO worker_registry (
DROP TABLE repository_ssh_host_trust_revisions; DROP TABLE repository_ssh_host_trust_revisions;
DROP TABLE repository_ssh_host_trusts; DROP TABLE repository_ssh_host_trusts;
DROP TABLE workdir_create_operations; DROP TABLE workdir_create_operations;
DELETE FROM __yoi_schema_migrations WHERE version IN (45, 46);", ALTER TABLE workdir_registry DROP COLUMN creation_tree;
ALTER TABLE workdir_registry DROP COLUMN current_tree;
ALTER TABLE workdir_registry DROP COLUMN observed_at_epoch_seconds;
DELETE FROM __yoi_schema_migrations WHERE version IN (45, 46, 47);",
) )
.unwrap(); .unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 44); assert_eq!(current_schema_version(&conn).unwrap(), 44);
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46); assert_eq!(current_schema_version(&conn).unwrap(), 47);
assert!(table_exists(&conn, "workdir_create_operations").unwrap()); assert!(table_exists(&conn, "workdir_create_operations").unwrap());
let columns = table_columns(&conn, "workdir_create_operations").unwrap(); let columns = table_columns(&conn, "workdir_create_operations").unwrap();
for required in [ for required in [
@@ -11647,13 +11739,26 @@ INSERT INTO worker_registry (
DROP TABLE repository_ssh_credentials; DROP TABLE repository_ssh_credentials;
DROP TABLE repository_ssh_host_trust_revisions; DROP TABLE repository_ssh_host_trust_revisions;
DROP TABLE repository_ssh_host_trusts; DROP TABLE repository_ssh_host_trusts;
DELETE FROM __yoi_schema_migrations WHERE version = 46;", ALTER TABLE workdir_registry DROP COLUMN creation_tree;
ALTER TABLE workdir_registry DROP COLUMN current_tree;
ALTER TABLE workdir_registry DROP COLUMN observed_at_epoch_seconds;
ALTER TABLE workdir_create_operations DROP COLUMN source_kind;
ALTER TABLE workdir_create_operations DROP COLUMN source_uri;
ALTER TABLE workdir_create_operations DROP COLUMN source_revision;
ALTER TABLE workdir_create_operations DROP COLUMN source_fingerprint;
ALTER TABLE workdir_create_operations DROP COLUMN credential_id;
ALTER TABLE workdir_create_operations DROP COLUMN credential_revision;
ALTER TABLE workdir_create_operations DROP COLUMN host_trust_id;
ALTER TABLE workdir_create_operations DROP COLUMN host_trust_revision;
ALTER TABLE workdir_create_operations DROP COLUMN repository_access_mode;
ALTER TABLE workdir_create_operations DROP COLUMN cache_generation;
DELETE FROM __yoi_schema_migrations WHERE version IN (46, 47);",
) )
.unwrap(); .unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 45); assert_eq!(current_schema_version(&conn).unwrap(), 45);
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46); assert_eq!(current_schema_version(&conn).unwrap(), 47);
for table in [ for table in [
"repository_ssh_credentials", "repository_ssh_credentials",
"repository_ssh_credential_revisions", "repository_ssh_credential_revisions",
@@ -11672,19 +11777,72 @@ INSERT INTO worker_registry (
assert!(foreign_key_error.is_none()); assert!(foreign_key_error.is_none());
} }
#[test]
fn schema_v47_binds_workdir_create_repository_access_evidence() {
let conn = Connection::open_in_memory().unwrap();
configure_sqlite(&conn).unwrap();
apply_migrations(&conn).unwrap();
conn.execute_batch(
"ALTER TABLE workdir_registry DROP COLUMN creation_tree;
ALTER TABLE workdir_registry DROP COLUMN current_tree;
ALTER TABLE workdir_registry DROP COLUMN observed_at_epoch_seconds;
ALTER TABLE workdir_create_operations DROP COLUMN source_kind;
ALTER TABLE workdir_create_operations DROP COLUMN source_uri;
ALTER TABLE workdir_create_operations DROP COLUMN source_revision;
ALTER TABLE workdir_create_operations DROP COLUMN source_fingerprint;
ALTER TABLE workdir_create_operations DROP COLUMN credential_id;
ALTER TABLE workdir_create_operations DROP COLUMN credential_revision;
ALTER TABLE workdir_create_operations DROP COLUMN host_trust_id;
ALTER TABLE workdir_create_operations DROP COLUMN host_trust_revision;
ALTER TABLE workdir_create_operations DROP COLUMN repository_access_mode;
ALTER TABLE workdir_create_operations DROP COLUMN cache_generation;
DELETE FROM __yoi_schema_migrations WHERE version = 47;",
)
.unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46);
apply_migrations(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 47);
let columns = table_columns(&conn, "workdir_create_operations").unwrap();
for required in [
"source_kind",
"source_uri",
"source_revision",
"source_fingerprint",
"credential_id",
"credential_revision",
"host_trust_id",
"host_trust_revision",
"repository_access_mode",
"cache_generation",
] {
assert!(
columns.iter().any(|column| column == required),
"missing column {required}"
);
}
let workdir_columns = table_columns(&conn, "workdir_registry").unwrap();
for required in ["creation_tree", "current_tree", "observed_at_epoch_seconds"] {
assert!(
workdir_columns.iter().any(|column| column == required),
"missing column {required}"
);
}
}
#[test] #[test]
fn server_refuses_a_database_from_a_newer_schema_generation() { fn server_refuses_a_database_from_a_newer_schema_generation() {
let conn = Connection::open_in_memory().unwrap(); let conn = Connection::open_in_memory().unwrap();
configure_sqlite(&conn).unwrap(); configure_sqlite(&conn).unwrap();
apply_migrations(&conn).unwrap(); apply_migrations(&conn).unwrap();
conn.execute( conn.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (47, 'future')", "INSERT INTO __yoi_schema_migrations (version, name) VALUES (48, 'future')",
[], [],
) )
.unwrap(); .unwrap();
let error = apply_migrations(&conn).unwrap_err().to_string(); let error = apply_migrations(&conn).unwrap_err().to_string();
assert!(error.contains("schema version 47 is newer"), "{error}"); assert!(error.contains("schema version 48 is newer"), "{error}");
assert!(error.contains("refusing to serve"), "{error}"); assert!(error.contains("refusing to serve"), "{error}");
} }
@@ -11905,7 +12063,7 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-
apply_migrations(&mut conn).unwrap(); apply_migrations(&mut conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 46); assert_eq!(current_schema_version(&conn).unwrap(), 47);
let workspace_id: Option<String> = conn let workspace_id: Option<String> = conn
.query_row( .query_row(
"SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'", "SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'",
@@ -12450,6 +12608,9 @@ WHERE workspace_id = 'workspace-a'
"updated_at", "updated_at",
"current_selector", "current_selector",
"current_ref", "current_ref",
"creation_tree",
"current_tree",
"observed_at_epoch_seconds",
], ],
); );
assert_columns( assert_columns(
@@ -12528,7 +12689,7 @@ WHERE workspace_id = 'workspace-a'
.unwrap(); .unwrap();
let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 46); assert_eq!(store.schema_version().await.unwrap(), 47);
store store
.with_conn(|conn| { .with_conn(|conn| {
@@ -12717,7 +12878,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test] #[tokio::test]
async fn repository_records_round_trip() { async fn repository_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 46); assert_eq!(store.schema_version().await.unwrap(), 47);
let workspace = WorkspaceRecord { let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
owner_account_id: None, owner_account_id: None,
@@ -12795,7 +12956,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test] #[tokio::test]
async fn memory_authority_records_round_trip_and_close_staging() { async fn memory_authority_records_round_trip_and_close_staging() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 46); assert_eq!(store.schema_version().await.unwrap(), 47);
let workspace = WorkspaceRecord { let workspace = WorkspaceRecord {
workspace_id: "local-dev".to_string(), workspace_id: "local-dev".to_string(),
owner_account_id: None, owner_account_id: None,
@@ -12941,8 +13102,11 @@ CREATE TABLE ticket_assignment_operations (
repository_id: "repo".to_string(), repository_id: "repo".to_string(),
creation_selector: Some("develop".to_string()), creation_selector: Some("develop".to_string()),
creation_ref: Some("abcdef".to_string()), creation_ref: Some("abcdef".to_string()),
creation_tree: Some("tree-creation".to_string()),
current_selector: None, current_selector: None,
current_ref: Some("abcdef".to_string()), current_ref: Some("abcdef".to_string()),
current_tree: Some("tree-current".to_string()),
observed_at_epoch_seconds: Some(1_777_777_777),
materialization_status: "not_found".to_string(), materialization_status: "not_found".to_string(),
cleanliness: "clean".to_string(), cleanliness: "clean".to_string(),
created_at: "2".to_string(), created_at: "2".to_string(),
@@ -12956,8 +13120,11 @@ CREATE TABLE ticket_assignment_operations (
repository_id: "repo".to_string(), repository_id: "repo".to_string(),
creation_selector: Some("feature".to_string()), creation_selector: Some("feature".to_string()),
creation_ref: Some("123456".to_string()), creation_ref: Some("123456".to_string()),
creation_tree: None,
current_selector: Some("feature".to_string()), current_selector: Some("feature".to_string()),
current_ref: Some("123456".to_string()), current_ref: Some("123456".to_string()),
current_tree: None,
observed_at_epoch_seconds: None,
materialization_status: "present".to_string(), materialization_status: "present".to_string(),
cleanliness: "unknown".to_string(), cleanliness: "unknown".to_string(),
created_at: "3".to_string(), created_at: "3".to_string(),
@@ -13202,7 +13369,7 @@ CREATE TABLE ticket_assignment_operations (
#[tokio::test] #[tokio::test]
async fn account_and_login_records_round_trip() { async fn account_and_login_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 46); assert_eq!(store.schema_version().await.unwrap(), 47);
let now = "2026-07-22T00:00:00Z".to_string(); let now = "2026-07-22T00:00:00Z".to_string();
let account = AccountRecord { let account = AccountRecord {
account_id: "acct-user-alice".to_string(), account_id: "acct-user-alice".to_string(),
@@ -4,13 +4,31 @@ use sha2::{Digest, Sha256};
use crate::store::WorkdirCreateOperationRecord; use crate::store::WorkdirCreateOperationRecord;
use crate::{Error, Result, SqliteWorkspaceStore}; use crate::{Error, Result, SqliteWorkspaceStore};
pub fn selector_for_retry(
explicit_selector: Option<&str>,
persisted_selector: Option<&str>,
current_default_selector: Option<&str>,
) -> Option<String> {
explicit_selector
.or(persisted_selector)
.or(current_default_selector)
.map(str::to_string)
}
pub fn request_fingerprint( pub fn request_fingerprint(
repository_id: &str, repository_id: &str,
selector: Option<&str>, selector: Option<&str>,
requested_runtime_id: Option<&str>, requested_runtime_id: Option<&str>,
repository_source_fingerprint: &str,
repository_source_revision: u64,
) -> String { ) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
for value in [Some(repository_id), selector, requested_runtime_id] { for value in [
Some(repository_id),
selector,
requested_runtime_id,
Some(repository_source_fingerprint),
] {
match value { match value {
Some(value) => { Some(value) => {
hasher.update([1]); hasher.update([1]);
@@ -20,6 +38,7 @@ pub fn request_fingerprint(
None => hasher.update([0]), None => hasher.update([0]),
} }
} }
hasher.update(repository_source_revision.to_be_bytes());
let digest = hasher.finalize(); let digest = hasher.finalize();
let mut encoded = String::with_capacity(digest.len() * 2); let mut encoded = String::with_capacity(digest.len() * 2);
for byte in digest { for byte in digest {
@@ -40,9 +59,10 @@ impl SqliteWorkspaceStore {
r#"INSERT OR IGNORE INTO workdir_create_operations ( r#"INSERT OR IGNORE INTO workdir_create_operations (
workspace_id, operation_id, request_fingerprint, repository_id, selector, workspace_id, operation_id, request_fingerprint, repository_id, selector,
requested_runtime_id, resolved_runtime_id, config_revision, requested_runtime_id, resolved_runtime_id, config_revision,
config_projection_digest, working_directory_id, state, failure, config_projection_digest, source_kind, source_uri, source_revision,
source_fingerprint, working_directory_id, state, failure,
created_at, updated_at created_at, updated_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"#, ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)"#,
params![ params![
record.workspace_id, record.workspace_id,
record.operation_id, record.operation_id,
@@ -53,6 +73,10 @@ impl SqliteWorkspaceStore {
record.resolved_runtime_id, record.resolved_runtime_id,
record.config_revision as i64, record.config_revision as i64,
record.config_projection_digest, record.config_projection_digest,
record.source_kind,
record.source_uri,
record.source_revision.map(|revision| revision as i64),
record.source_fingerprint,
record.working_directory_id, record.working_directory_id,
record.state, record.state,
record.failure, record.failure,
@@ -79,6 +103,81 @@ impl SqliteWorkspaceStore {
}) })
} }
pub fn bind_workdir_create_repository_access(
&self,
workspace_id: &str,
operation_id: &str,
request_fingerprint: &str,
credential_id: &str,
credential_revision: u64,
host_trust_id: &str,
host_trust_revision: u64,
repository_access_mode: &str,
cache_generation: u64,
now: &str,
) -> Result<WorkdirCreateOperationRecord> {
self.with_conn_mut(|conn| {
let operation = read_workdir_create_operation(conn, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared before Repository access binding"
))
})?;
if operation.request_fingerprint != request_fingerprint {
return Err(Error::InvalidInput(format!(
"Workdir create operation `{operation_id}` was reused with different input"
)));
}
if let Some(existing) = operation.credential_id.as_deref() {
if existing != credential_id
|| operation.credential_revision != Some(credential_revision)
|| operation.host_trust_id.as_deref() != Some(host_trust_id)
|| operation.host_trust_revision != Some(host_trust_revision)
|| operation.repository_access_mode.as_deref()
!= Some(repository_access_mode)
|| operation.cache_generation != cache_generation
{
return Err(Error::InvalidInput(format!(
"Workdir create operation `{operation_id}` Repository access evidence changed"
)));
}
return Ok(operation);
}
conn.execute(
r#"UPDATE workdir_create_operations
SET credential_id = ?4, credential_revision = ?5,
host_trust_id = ?6, host_trust_revision = ?7,
repository_access_mode = ?8, cache_generation = ?9,
updated_at = ?10
WHERE workspace_id = ?1 AND operation_id = ?2
AND request_fingerprint = ?3 AND credential_id IS NULL"#,
params![
workspace_id,
operation_id,
request_fingerprint,
credential_id,
i64::try_from(credential_revision).map_err(|_| Error::InvalidInput(
"credential revision is out of range".to_string()
))?,
host_trust_id,
i64::try_from(host_trust_revision).map_err(|_| Error::InvalidInput(
"host-trust revision is out of range".to_string()
))?,
repository_access_mode,
i64::try_from(cache_generation).map_err(|_| Error::InvalidInput(
"cache generation is out of range".to_string()
))?,
now,
],
)?;
read_workdir_create_operation(conn, workspace_id, operation_id)?.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared after Repository access binding"
))
})
})
}
pub fn finish_workdir_create_operation( pub fn finish_workdir_create_operation(
&self, &self,
workspace_id: &str, workspace_id: &str,
@@ -133,7 +232,10 @@ fn read_workdir_create_operation(
conn.query_row( conn.query_row(
r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector, r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector,
requested_runtime_id, resolved_runtime_id, config_revision, requested_runtime_id, resolved_runtime_id, config_revision,
config_projection_digest, working_directory_id, state, failure, config_projection_digest, source_kind, source_uri, source_revision,
source_fingerprint, credential_id, credential_revision,
host_trust_id, host_trust_revision, repository_access_mode,
cache_generation, working_directory_id, state, failure,
created_at, updated_at created_at, updated_at
FROM workdir_create_operations FROM workdir_create_operations
WHERE workspace_id = ?1 AND operation_id = ?2"#, WHERE workspace_id = ?1 AND operation_id = ?2"#,
@@ -149,11 +251,21 @@ fn read_workdir_create_operation(
resolved_runtime_id: row.get(6)?, resolved_runtime_id: row.get(6)?,
config_revision: row.get::<_, i64>(7)? as u64, config_revision: row.get::<_, i64>(7)? as u64,
config_projection_digest: row.get(8)?, config_projection_digest: row.get(8)?,
working_directory_id: row.get(9)?, source_kind: row.get(9)?,
state: row.get(10)?, source_uri: row.get(10)?,
failure: row.get(11)?, source_revision: row.get::<_, Option<i64>>(11)?.map(|value| value as u64),
created_at: row.get(12)?, source_fingerprint: row.get(12)?,
updated_at: row.get(13)?, credential_id: row.get(13)?,
credential_revision: row.get::<_, Option<i64>>(14)?.map(|value| value as u64),
host_trust_id: row.get(15)?,
host_trust_revision: row.get::<_, Option<i64>>(16)?.map(|value| value as u64),
repository_access_mode: row.get(17)?,
cache_generation: row.get::<_, i64>(18)? as u64,
working_directory_id: row.get(19)?,
state: row.get(20)?,
failure: row.get(21)?,
created_at: row.get(22)?,
updated_at: row.get(23)?,
}) })
}, },
) )
@@ -166,6 +278,22 @@ mod tests {
use super::*; use super::*;
use crate::store::{ControlPlaneStore, RepositoryRecord, WorkspaceRecord}; use crate::store::{ControlPlaneStore, RepositoryRecord, WorkspaceRecord};
#[test]
fn retry_selector_keeps_persisted_default_but_honors_explicit_input() {
assert_eq!(
selector_for_retry(None, Some("develop"), Some("main")),
Some("develop".to_string())
);
assert_eq!(
selector_for_retry(Some("release"), Some("develop"), Some("main")),
Some("release".to_string())
);
assert_eq!(
selector_for_retry(None, None, Some("main")),
Some("main".to_string())
);
}
#[test] #[test]
fn retry_keeps_resolved_config_evidence_and_rejects_changed_input() { fn retry_keeps_resolved_config_evidence_and_rejects_changed_input() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
@@ -201,13 +329,29 @@ mod tests {
let record = WorkdirCreateOperationRecord { let record = WorkdirCreateOperationRecord {
workspace_id: "workspace".to_string(), workspace_id: "workspace".to_string(),
operation_id: "call-1".to_string(), operation_id: "call-1".to_string(),
request_fingerprint: request_fingerprint("main", Some("develop"), None), request_fingerprint: request_fingerprint(
"main",
Some("develop"),
None,
"sha256:test",
1,
),
repository_id: "main".to_string(), repository_id: "main".to_string(),
selector: Some("develop".to_string()), selector: Some("develop".to_string()),
requested_runtime_id: None, requested_runtime_id: None,
resolved_runtime_id: "arcadia".to_string(), resolved_runtime_id: "arcadia".to_string(),
config_revision: 7, config_revision: 7,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
source_kind: Some("local_path".to_string()),
source_uri: Some("/tmp/repo".to_string()),
source_revision: Some(1),
source_fingerprint: Some("sha256:source".to_string()),
credential_id: None,
credential_revision: None,
host_trust_id: None,
host_trust_revision: None,
repository_access_mode: None,
cache_generation: 0,
working_directory_id: "wd-1".to_string(), working_directory_id: "wd-1".to_string(),
state: "pending".to_string(), state: "pending".to_string(),
failure: None, failure: None,
@@ -218,23 +362,59 @@ mod tests {
store.reserve_workdir_create_operation(&record).unwrap(), store.reserve_workdir_create_operation(&record).unwrap(),
record record
); );
let bound = store
.bind_workdir_create_repository_access(
"workspace",
"call-1",
&record.request_fingerprint,
"credential-1",
3,
"trust-1",
5,
"read_only",
2,
"2026-08-24T00:00:01Z",
)
.unwrap();
assert_eq!(bound.credential_id.as_deref(), Some("credential-1"));
assert_eq!(bound.credential_revision, Some(3));
assert_eq!(bound.host_trust_revision, Some(5));
assert_eq!(bound.cache_generation, 2);
assert!(
store
.bind_workdir_create_repository_access(
"workspace",
"call-1",
&record.request_fingerprint,
"credential-1",
4,
"trust-1",
5,
"read_only",
2,
"2026-08-24T00:00:02Z",
)
.is_err()
);
let mut changed_resolution = record.clone(); let mut changed_resolution = record.clone();
changed_resolution.resolved_runtime_id = "other".to_string(); changed_resolution.resolved_runtime_id = "other".to_string();
changed_resolution.config_revision = 8; changed_resolution.config_revision = 8;
assert_eq!( changed_resolution.source_uri = Some("ssh://git@other.test/repo.git".to_string());
store changed_resolution.source_revision = Some(9);
let replayed = store
.reserve_workdir_create_operation(&changed_resolution) .reserve_workdir_create_operation(&changed_resolution)
.unwrap(), .unwrap();
record assert_eq!(replayed, bound);
); assert_eq!(replayed.source_uri.as_deref(), Some("/tmp/repo"));
assert_eq!( assert_eq!(
store store
.load_workdir_create_operation("workspace", "call-1") .load_workdir_create_operation("workspace", "call-1")
.unwrap(), .unwrap(),
Some(record.clone()) Some(bound.clone())
); );
let mut changed_input = record.clone(); let mut changed_input = record.clone();
changed_input.request_fingerprint = request_fingerprint("main", Some("main"), None); changed_input.request_fingerprint =
request_fingerprint("main", Some("main"), None, "sha256:test", 1);
assert!( assert!(
store store
.reserve_workdir_create_operation(&changed_input) .reserve_workdir_create_operation(&changed_input)

Some files were not shown because too many files have changed in this diff Show More