Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
402ae0d466 | ||
|
|
acb3c6d68b | ||
|
|
40ac83e632 | ||
|
|
58da395941 | ||
|
|
0e3ef94c9e | ||
|
|
1f68dfc2b5 | ||
|
|
84977a464c | ||
|
|
62ada5eaa4 | ||
|
|
f5ff0b7c13 | ||
|
|
3337cafcdf | ||
|
|
e87784118b | ||
|
|
40fada28ea | ||
|
|
58cc94d4b7 | ||
|
|
ccabea59c9 | ||
|
|
8cc1dc042d | ||
|
|
183c37446e | ||
|
|
7aa06afc45 | ||
|
|
1515a2fb86 | ||
|
|
ec798c58d7 | ||
|
|
e365189276 | ||
|
|
116d610ad0 | ||
|
|
75c570962d | ||
|
|
cae8ac1799 | ||
|
|
917cc222a3 | ||
|
|
7edc588202 | ||
|
|
c83461508b | ||
|
|
651d64f34d | ||
|
|
b98d4b59f5 | ||
|
|
374449e663 | ||
|
|
4c876a201b | ||
|
|
21b3dd1da1 | ||
|
|
5ca0ea9228 | ||
|
|
d5c3a68a37 | ||
|
|
2b33b9158d | ||
|
|
b31642e284 | ||
|
|
3a7a3307ef | ||
|
|
0496cd907b | ||
|
|
975b4fa700 | ||
|
|
060f280fdf | ||
|
|
7aaf189247 | ||
|
|
df6d99c07d | ||
|
|
63306cf017 | ||
|
|
08be5e85e4 | ||
|
|
9843510e1f | ||
|
|
83bda3dfb2 | ||
|
|
0ab15aa227 | ||
|
|
29c2fb8e06 | ||
|
|
4ebc465e8d | ||
|
|
4b132a21e9 | ||
|
|
b644971d45 | ||
|
|
df34533765 | ||
|
|
6f4efb36bb | ||
|
|
108d5b14d7 | ||
|
|
f633b86b35 | ||
|
|
1873e18f8e | ||
|
|
3cdcbb47bf | ||
|
|
aaa9c7987c | ||
|
|
048007a042 | ||
|
|
cb35b40b9d | ||
|
|
471db64bcc | ||
|
|
ffb2a34ae5 | ||
|
|
3a3c89e0b4 | ||
|
|
4e935c6203 | ||
|
|
eac07cf5a8 | ||
|
|
260259d461 | ||
|
|
9d572d18bc | ||
|
|
f7852e8034 | ||
|
|
8c075de147 | ||
|
|
864367f4f5 | ||
|
|
33db2ea7f4 |
Generated
+2
@@ -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]]
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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!");
|
||||||
|
|||||||
@@ -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}");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+824
-274
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
Item::assistant_message("Response 1"),
|
&mut history,
|
||||||
Item::user_message("Second"),
|
vec![
|
||||||
Item::assistant_message("Response 2"),
|
Item::assistant_message("Response 1"),
|
||||||
])
|
Item::user_message("Second"),
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
Item::user_message("prior question"),
|
&mut history,
|
||||||
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
|
vec![
|
||||||
Item::assistant_message("prior answer"),
|
Item::user_message("prior question"),
|
||||||
]);
|
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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>`
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -279,4 +279,58 @@ mod tests {
|
|||||||
assert_eq!(grep.matched_files, 2);
|
assert_eq!(grep.matched_files, 2);
|
||||||
assert!(!grep.output.contains("c.txt"));
|
assert!(!grep.output.contains("c.txt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grep_content_groups_lines_by_file_and_marks_matches() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
temp.path().join("first.txt"),
|
||||||
|
"before\nneedle one\nafter\nomitted one\nomitted two\nbefore distant\nneedle distant\nafter distant\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(temp.path().join("second.txt"), "needle two\n").unwrap();
|
||||||
|
let root = temp.path().canonicalize().unwrap();
|
||||||
|
let readable = RootAccess(root.clone());
|
||||||
|
|
||||||
|
let grep = run_grep(
|
||||||
|
&root,
|
||||||
|
root.clone(),
|
||||||
|
GrepRequest {
|
||||||
|
pattern: "needle".to_string(),
|
||||||
|
path: FsPath::root(),
|
||||||
|
glob: Some("*.txt".to_string()),
|
||||||
|
output_mode: GrepOutputMode::Content,
|
||||||
|
case_insensitive: false,
|
||||||
|
before_context: 1,
|
||||||
|
after_context: 1,
|
||||||
|
multiline: false,
|
||||||
|
file_type: None,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
&readable,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(grep.match_count, 3);
|
||||||
|
assert_eq!(grep.matched_files, 2);
|
||||||
|
assert_eq!(
|
||||||
|
grep.output,
|
||||||
|
concat!(
|
||||||
|
"first.txt\n",
|
||||||
|
" 1 │ before\n",
|
||||||
|
" > 2 │ needle one\n",
|
||||||
|
" 3 │ after\n",
|
||||||
|
" …\n",
|
||||||
|
" 6 │ before distant\n",
|
||||||
|
" > 7 │ needle distant\n",
|
||||||
|
" 8 │ after distant\n",
|
||||||
|
"\n",
|
||||||
|
"second.txt\n",
|
||||||
|
" > 1 │ needle two\n",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assert_eq!(grep.output.matches("first.txt").count(), 1);
|
||||||
|
assert_eq!(grep.output.matches("second.txt").count(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt::Write as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::FsAccessPolicy;
|
use crate::FsAccessPolicy;
|
||||||
@@ -57,20 +59,11 @@ impl GrepReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
GrepOutputMode::Content => {
|
GrepOutputMode::Content => {
|
||||||
for line in &self.lines {
|
output.push_str(&render_content_lines(
|
||||||
let separator = if line.is_match { ':' } else { '-' };
|
root,
|
||||||
let path = logical_display(root, &line.path);
|
&self.lines,
|
||||||
if self.show_line_numbers
|
self.show_line_numbers,
|
||||||
&& let Some(number) = line.line_number
|
));
|
||||||
{
|
|
||||||
output.push_str(&format!(
|
|
||||||
"{path}{separator}{number}{separator}{}\n",
|
|
||||||
line.text
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
output.push_str(&format!("{path}{separator}{}\n", line.text));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GrepResult {
|
GrepResult {
|
||||||
@@ -82,6 +75,48 @@ impl GrepReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_content_lines(root: &Path, lines: &[ContentLine], show_line_numbers: bool) -> String {
|
||||||
|
let mut grouped = BTreeMap::<&Path, Vec<&ContentLine>>::new();
|
||||||
|
for line in lines {
|
||||||
|
grouped.entry(&line.path).or_default().push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut output = String::new();
|
||||||
|
for (file_index, (path, file_lines)) in grouped.into_iter().enumerate() {
|
||||||
|
if file_index > 0 {
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
let _ = writeln!(output, "{}", logical_display(root, path));
|
||||||
|
|
||||||
|
let number_width = file_lines
|
||||||
|
.iter()
|
||||||
|
.filter_map(|line| line.line_number)
|
||||||
|
.map(|number| number.to_string().len())
|
||||||
|
.max()
|
||||||
|
.unwrap_or(1);
|
||||||
|
let mut previous_line_end = None;
|
||||||
|
for line in file_lines {
|
||||||
|
if let (Some(previous_end), Some(number)) = (previous_line_end, line.line_number)
|
||||||
|
&& number > previous_end
|
||||||
|
{
|
||||||
|
let _ = writeln!(output, " …");
|
||||||
|
}
|
||||||
|
|
||||||
|
let marker = if line.is_match { '>' } else { ' ' };
|
||||||
|
if show_line_numbers && let Some(number) = line.line_number {
|
||||||
|
let _ = writeln!(output, " {marker} {number:>number_width$} │ {}", line.text);
|
||||||
|
} else {
|
||||||
|
let _ = writeln!(output, " {marker} │ {}", line.text);
|
||||||
|
}
|
||||||
|
previous_line_end = line
|
||||||
|
.line_number
|
||||||
|
.map(|number| number + line.text.split('\n').count() as u64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
fn logical_display(root: &Path, path: &Path) -> String {
|
fn logical_display(root: &Path, path: &Path) -> String {
|
||||||
path.strip_prefix(root)
|
path.strip_prefix(root)
|
||||||
.unwrap_or(path)
|
.unwrap_or(path)
|
||||||
|
|||||||
@@ -946,7 +946,7 @@ fn apply_role_profile(
|
|||||||
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
|
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
|
||||||
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
|
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
|
||||||
value["feature"]["worker"] = serde_json::json!({
|
value["feature"]["worker"] = serde_json::json!({
|
||||||
"enabled": matches!(slug, "companion" | "orchestrator"),
|
"enabled": slug == "orchestrator",
|
||||||
"direct_spawn": slug != "orchestrator"
|
"direct_spawn": slug != "orchestrator"
|
||||||
});
|
});
|
||||||
value["feature"]["manage_workdir"] = serde_json::json!({
|
value["feature"]["manage_workdir"] = serde_json::json!({
|
||||||
@@ -1408,7 +1408,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_companion_can_manage_workdirs() {
|
fn builtin_coder_uses_sub_worker_control_without_worker_control() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let resolved = ProfileResolver::new()
|
||||||
|
.with_workspace_base(tmp.path())
|
||||||
|
.resolve(
|
||||||
|
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"),
|
||||||
|
ProfileResolveOptions::with_worker_name("coder-worker"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||||
|
assert!(!resolved.manifest.feature.worker.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn builtin_companion_uses_sub_worker_control_without_worker_control() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
let resolved = ProfileResolver::new()
|
let resolved = ProfileResolver::new()
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
@@ -1419,6 +1434,8 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(resolved.manifest.feature.manage_workdir.enabled);
|
assert!(resolved.manifest.feature.manage_workdir.enabled);
|
||||||
|
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||||
|
assert!(!resolved.manifest.feature.worker.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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()),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
} => {
|
||||||
id: None,
|
let disposition = if is_error && disposition.is_success() {
|
||||||
call_id,
|
ToolResultDisposition::Error
|
||||||
summary,
|
} else {
|
||||||
content,
|
disposition
|
||||||
is_error,
|
};
|
||||||
attachments: attachments.into_iter().map(Attachment::from).collect(),
|
Item::ToolResult {
|
||||||
},
|
id: None,
|
||||||
|
call_id,
|
||||||
|
summary,
|
||||||
|
content,
|
||||||
|
disposition,
|
||||||
|
is_error,
|
||||||
|
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(
|
||||||
|
|||||||
@@ -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,15 +45,22 @@ 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 {
|
||||||
return Err(StoreError::Corrupt {
|
SESSION_SCHEMA_VERSION => {}
|
||||||
line: 0,
|
LEGACY_SESSION_SCHEMA_VERSION => {
|
||||||
message: format!(
|
validate_legacy_segment_logs(&root)?;
|
||||||
"unsupported Worker Session schema version {}, expected {}",
|
manifest.schema_version = SESSION_SCHEMA_VERSION;
|
||||||
manifest.schema_version, SESSION_SCHEMA_VERSION
|
atomic_write_json(&root.join(SESSION_FILE), &manifest)?;
|
||||||
),
|
}
|
||||||
});
|
version => {
|
||||||
|
return Err(StoreError::Corrupt {
|
||||||
|
line: 0,
|
||||||
|
message: format!(
|
||||||
|
"unsupported Worker Session schema version {version}, expected {SESSION_SCHEMA_VERSION}"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Some(manifest.session_id)
|
Some(manifest.session_id)
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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, ¶ms.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, ¶ms.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 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();
|
||||||
|
|||||||
+159
-14
@@ -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 {
|
||||||
let workdir = self.session.clone();
|
return;
|
||||||
tokio::spawn(async move {
|
};
|
||||||
let _ = workdir.cancel_command(handle).await;
|
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 {
|
||||||
|
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(¶ms.command);
|
let cmd_summary = truncate_for_summary(¶ms.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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
|||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = schemars::schema_for!(GrepParams);
|
let schema = schemars::schema_for!(GrepParams);
|
||||||
let meta = ToolMeta::new("Grep")
|
let meta = ToolMeta::new("Grep")
|
||||||
.description("Search Workdir file contents with a regex. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
|
.description("Search Workdir file contents with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
|
||||||
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
|
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
|
||||||
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
|
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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("/");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ¬ify] {
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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(
|
_ => {
|
||||||
"unsupported Runtime catalog store selection".to_string(),
|
return Err(ProcessError::usage(
|
||||||
)),
|
"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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+186
-19
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -873,6 +888,14 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut feature_registry = FeatureRegistryBuilder::new();
|
let mut feature_registry = FeatureRegistryBuilder::new();
|
||||||
|
if feature_config.sub_worker.enabled {
|
||||||
|
feature_registry.add_module(
|
||||||
|
crate::feature::builtin::manage_worker::sub_worker_control_feature(
|
||||||
|
worker.workspace_client_handle(),
|
||||||
|
spawned_registry.clone(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if feature_config.task.enabled {
|
if feature_config.task.enabled {
|
||||||
feature_registry.add_module(task_feature);
|
feature_registry.add_module(task_feature);
|
||||||
}
|
}
|
||||||
@@ -1107,6 +1130,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,
|
||||||
@@ -1153,22 +1177,35 @@ 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();
|
||||||
set_controller_status(
|
|
||||||
&shared_state,
|
|
||||||
&runtime_dir,
|
|
||||||
&event_tx,
|
|
||||||
WorkerStatus::Running,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let parent_originated = run.is_parent_originated();
|
let parent_originated = run.is_parent_originated();
|
||||||
|
let user_input_run = matches!(&run, PendingRun::Run(_) | PendingRun::RunTracked { .. });
|
||||||
|
if !user_input_run {
|
||||||
|
set_controller_status(
|
||||||
|
&shared_state,
|
||||||
|
&runtime_dir,
|
||||||
|
&event_tx,
|
||||||
|
WorkerStatus::Running,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
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),
|
||||||
¬ify_buffer,
|
¬ify_buffer,
|
||||||
self_parent_socket.as_ref(),
|
self_parent_socket.as_ref(),
|
||||||
&spawner_name,
|
&spawner_name,
|
||||||
@@ -1178,12 +1215,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),
|
||||||
¬ify_buffer,
|
¬ify_buffer,
|
||||||
self_parent_socket.as_ref(),
|
self_parent_socket.as_ref(),
|
||||||
&spawner_name,
|
&spawner_name,
|
||||||
@@ -1198,7 +1245,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,
|
||||||
¬ify_buffer,
|
¬ify_buffer,
|
||||||
self_parent_socket.as_ref(),
|
self_parent_socket.as_ref(),
|
||||||
&spawner_name,
|
&spawner_name,
|
||||||
@@ -1213,7 +1263,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,
|
||||||
¬ify_buffer,
|
¬ify_buffer,
|
||||||
self_parent_socket.as_ref(),
|
self_parent_socket.as_ref(),
|
||||||
&spawner_name,
|
&spawner_name,
|
||||||
@@ -1338,7 +1391,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(),
|
||||||
@@ -1379,7 +1432,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:
|
||||||
@@ -1393,7 +1446,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"
|
||||||
@@ -1422,7 +1475,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"
|
||||||
@@ -1610,7 +1663,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,
|
||||||
@@ -1626,14 +1682,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) {
|
||||||
@@ -1682,7 +1782,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;
|
||||||
@@ -1744,7 +1844,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() {
|
||||||
@@ -1770,7 +1870,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) {
|
||||||
@@ -1818,7 +1918,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();
|
||||||
@@ -1934,11 +2034,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1952,6 +2054,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(),
|
||||||
@@ -1977,11 +2080,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2034,7 +2139,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",
|
||||||
@@ -2055,6 +2163,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;
|
||||||
@@ -2066,7 +2212,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",
|
||||||
@@ -2101,7 +2250,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",
|
||||||
@@ -2142,7 +2294,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",
|
||||||
@@ -2181,7 +2336,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",
|
||||||
@@ -2217,7 +2375,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",
|
||||||
@@ -2251,7 +2412,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",
|
||||||
@@ -2284,7 +2448,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",
|
||||||
|
|||||||
+634
-85
@@ -12,7 +12,7 @@
|
|||||||
//! ordinary feature reports/diagnostics instead of a separate authority layer.
|
//! ordinary feature reports/diagnostics instead of a separate authority layer.
|
||||||
|
|
||||||
use std::any::{Any, type_name};
|
use std::any::{Any, type_name};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -447,6 +447,16 @@ impl ServiceVersionReq {
|
|||||||
requirement: "*".into(),
|
requirement: "*".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn exact(version: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
requirement: version.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches(&self, provider_version: &str) -> bool {
|
||||||
|
self.requirement == "*" || self.requirement == provider_version
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Feature-provided service declaration. This first slice records provider
|
/// Feature-provided service declaration. This first slice records provider
|
||||||
@@ -1430,30 +1440,34 @@ impl FeatureInstallContext<'_> {
|
|||||||
pub struct FeatureRegistryInstallReport {
|
pub struct FeatureRegistryInstallReport {
|
||||||
pub reports: Vec<FeatureInstallReport>,
|
pub reports: Vec<FeatureInstallReport>,
|
||||||
pub services: FeatureServiceRegistry,
|
pub services: FeatureServiceRegistry,
|
||||||
|
pub plan_error: Option<FeaturePlanError>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeatureRegistryInstallReport {
|
impl FeatureRegistryInstallReport {
|
||||||
pub fn has_errors(&self) -> bool {
|
pub fn has_errors(&self) -> bool {
|
||||||
self.reports.iter().any(|report| {
|
self.plan_error.is_some()
|
||||||
report
|
|| self.reports.iter().any(|report| {
|
||||||
.diagnostics
|
|
||||||
.iter()
|
|
||||||
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error_message(&self) -> String {
|
|
||||||
self.reports
|
|
||||||
.iter()
|
|
||||||
.flat_map(|report| {
|
|
||||||
report
|
report
|
||||||
.diagnostics
|
.diagnostics
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
||||||
.map(move |diagnostic| format!("{}: {}", report.feature_id, diagnostic.message))
|
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
}
|
||||||
.join("; ")
|
|
||||||
|
pub fn error_message(&self) -> String {
|
||||||
|
let mut errors = self
|
||||||
|
.plan_error
|
||||||
|
.iter()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
errors.extend(self.reports.iter().flat_map(|report| {
|
||||||
|
report
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.filter(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
|
||||||
|
.map(move |diagnostic| format!("{}: {}", report.feature_id, diagnostic.message))
|
||||||
|
}));
|
||||||
|
errors.join("; ")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn installed_tool_names(&self) -> Vec<String> {
|
pub fn installed_tool_names(&self) -> Vec<String> {
|
||||||
@@ -1485,12 +1499,265 @@ pub fn dedupe_instruction_contributions(
|
|||||||
deduped
|
deduped
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PlannedServiceProvider {
|
||||||
|
pub service: ServiceId,
|
||||||
|
pub provider: FeatureId,
|
||||||
|
pub version: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A validated, deterministic installation order and its selected service providers.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct FeatureInstallPlan {
|
||||||
|
ordered_features: Vec<FeatureId>,
|
||||||
|
service_providers: BTreeMap<ServiceId, PlannedServiceProvider>,
|
||||||
|
ordered_indices: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeatureInstallPlan {
|
||||||
|
pub fn ordered_features(&self) -> &[FeatureId] {
|
||||||
|
&self.ordered_features
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn service_providers(&self) -> &BTreeMap<ServiceId, PlannedServiceProvider> {
|
||||||
|
&self.service_providers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Error)]
|
||||||
|
pub enum FeaturePlanError {
|
||||||
|
#[error("feature `{feature}` is registered more than once")]
|
||||||
|
DuplicateFeature { feature: FeatureId },
|
||||||
|
#[error("service `{service}` has multiple selected providers: {providers:?}")]
|
||||||
|
AmbiguousServiceProvider {
|
||||||
|
service: ServiceId,
|
||||||
|
providers: Vec<FeatureId>,
|
||||||
|
},
|
||||||
|
#[error("feature `{consumer}` requires service `{service}`, but no provider is selected")]
|
||||||
|
MissingServiceProvider {
|
||||||
|
consumer: FeatureId,
|
||||||
|
service: ServiceId,
|
||||||
|
},
|
||||||
|
#[error(
|
||||||
|
"feature `{consumer}` requires service `{service}` version `{requirement}`, but provider `{provider}` provides `{provider_version}`"
|
||||||
|
)]
|
||||||
|
ServiceVersionMismatch {
|
||||||
|
consumer: FeatureId,
|
||||||
|
service: ServiceId,
|
||||||
|
requirement: String,
|
||||||
|
provider: FeatureId,
|
||||||
|
provider_version: String,
|
||||||
|
},
|
||||||
|
#[error(
|
||||||
|
"service dependency cycle involves features {features:?} through services {services:?}"
|
||||||
|
)]
|
||||||
|
ServiceDependencyCycle {
|
||||||
|
features: Vec<FeatureId>,
|
||||||
|
services: Vec<ServiceId>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/// Builder/installer for enabled feature modules.
|
/// Builder/installer for enabled feature modules.
|
||||||
#[derive(Default)]
|
|
||||||
pub struct FeatureRegistryBuilder {
|
pub struct FeatureRegistryBuilder {
|
||||||
modules: Vec<Arc<dyn FeatureModule>>,
|
modules: Vec<Arc<dyn FeatureModule>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_feature_install_plan(
|
||||||
|
descriptors: &[FeatureDescriptor],
|
||||||
|
) -> Result<FeatureInstallPlan, FeaturePlanError> {
|
||||||
|
let mut feature_ids = BTreeSet::new();
|
||||||
|
for descriptor in descriptors {
|
||||||
|
if !feature_ids.insert(descriptor.id.clone()) {
|
||||||
|
return Err(FeaturePlanError::DuplicateFeature {
|
||||||
|
feature: descriptor.id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut provider_candidates: BTreeMap<ServiceId, Vec<(usize, ServiceDeclaration)>> =
|
||||||
|
BTreeMap::new();
|
||||||
|
for (index, descriptor) in descriptors.iter().enumerate() {
|
||||||
|
for service in &descriptor.provides_services {
|
||||||
|
provider_candidates
|
||||||
|
.entry(service.id.clone())
|
||||||
|
.or_default()
|
||||||
|
.push((index, service.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut selected_providers = BTreeMap::new();
|
||||||
|
for (service, candidates) in provider_candidates {
|
||||||
|
if candidates.len() > 1 {
|
||||||
|
let mut providers = candidates
|
||||||
|
.iter()
|
||||||
|
.map(|(index, _)| descriptors[*index].id.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
providers.sort();
|
||||||
|
return Err(FeaturePlanError::AmbiguousServiceProvider { service, providers });
|
||||||
|
}
|
||||||
|
let (provider_index, declaration) = candidates
|
||||||
|
.into_iter()
|
||||||
|
.next()
|
||||||
|
.expect("provider candidates are non-empty");
|
||||||
|
selected_providers.insert(service, (provider_index, declaration));
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = (0..descriptors.len()).collect::<BTreeSet<_>>();
|
||||||
|
let mut adjacency = vec![BTreeSet::new(); descriptors.len()];
|
||||||
|
let mut indegree = vec![0usize; descriptors.len()];
|
||||||
|
let mut edge_services: BTreeMap<(usize, usize), BTreeSet<ServiceId>> = BTreeMap::new();
|
||||||
|
|
||||||
|
for &consumer_index in &active {
|
||||||
|
let consumer = &descriptors[consumer_index];
|
||||||
|
for requirement in &consumer.requires_services {
|
||||||
|
let provider = selected_providers.get(&requirement.id);
|
||||||
|
let Some((provider_index, declaration)) = provider else {
|
||||||
|
if requirement.required {
|
||||||
|
return Err(FeaturePlanError::MissingServiceProvider {
|
||||||
|
consumer: consumer.id.clone(),
|
||||||
|
service: requirement.id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !requirement.version.matches(&declaration.version) {
|
||||||
|
return Err(FeaturePlanError::ServiceVersionMismatch {
|
||||||
|
consumer: consumer.id.clone(),
|
||||||
|
service: requirement.id.clone(),
|
||||||
|
requirement: requirement.version.requirement.clone(),
|
||||||
|
provider: descriptors[*provider_index].id.clone(),
|
||||||
|
provider_version: declaration.version.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if adjacency[*provider_index].insert(consumer_index) {
|
||||||
|
indegree[consumer_index] += 1;
|
||||||
|
}
|
||||||
|
edge_services
|
||||||
|
.entry((*provider_index, consumer_index))
|
||||||
|
.or_default()
|
||||||
|
.insert(requirement.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ready = active
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|index| indegree[*index] == 0)
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let mut ordered_indices = Vec::with_capacity(active.len());
|
||||||
|
while let Some(index) = ready.pop_first() {
|
||||||
|
ordered_indices.push(index);
|
||||||
|
for &dependent in &adjacency[index] {
|
||||||
|
indegree[dependent] -= 1;
|
||||||
|
if indegree[dependent] == 0 {
|
||||||
|
ready.insert(dependent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ordered_indices.len() != active.len() {
|
||||||
|
let cycle_indices = find_service_dependency_cycle(&active, &adjacency);
|
||||||
|
let features = cycle_indices
|
||||||
|
.iter()
|
||||||
|
.map(|index| descriptors[*index].id.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut services = BTreeSet::new();
|
||||||
|
for edge in cycle_indices.windows(2) {
|
||||||
|
if let Some(ids) = edge_services.get(&(edge[0], edge[1])) {
|
||||||
|
services.extend(ids.iter().cloned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let (Some(first), Some(last)) = (cycle_indices.first(), cycle_indices.last())
|
||||||
|
&& let Some(ids) = edge_services.get(&(*last, *first))
|
||||||
|
{
|
||||||
|
services.extend(ids.iter().cloned());
|
||||||
|
}
|
||||||
|
return Err(FeaturePlanError::ServiceDependencyCycle {
|
||||||
|
features,
|
||||||
|
services: services.into_iter().collect(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let service_providers = selected_providers
|
||||||
|
.into_iter()
|
||||||
|
.map(|(service, (provider_index, declaration))| {
|
||||||
|
let planned = PlannedServiceProvider {
|
||||||
|
service: service.clone(),
|
||||||
|
provider: descriptors[provider_index].id.clone(),
|
||||||
|
version: declaration.version,
|
||||||
|
};
|
||||||
|
(service, planned)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let ordered_features = ordered_indices
|
||||||
|
.iter()
|
||||||
|
.map(|index| descriptors[*index].id.clone())
|
||||||
|
.collect();
|
||||||
|
Ok(FeatureInstallPlan {
|
||||||
|
ordered_features,
|
||||||
|
service_providers,
|
||||||
|
ordered_indices,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_service_dependency_cycle(
|
||||||
|
active: &BTreeSet<usize>,
|
||||||
|
adjacency: &[BTreeSet<usize>],
|
||||||
|
) -> Vec<usize> {
|
||||||
|
fn visit(
|
||||||
|
index: usize,
|
||||||
|
active: &BTreeSet<usize>,
|
||||||
|
adjacency: &[BTreeSet<usize>],
|
||||||
|
state: &mut [u8],
|
||||||
|
stack: &mut Vec<usize>,
|
||||||
|
) -> Option<Vec<usize>> {
|
||||||
|
state[index] = 1;
|
||||||
|
stack.push(index);
|
||||||
|
for &next in &adjacency[index] {
|
||||||
|
if !active.contains(&next) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match state[next] {
|
||||||
|
0 => {
|
||||||
|
if let Some(cycle) = visit(next, active, adjacency, state, stack) {
|
||||||
|
return Some(cycle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
let start = stack
|
||||||
|
.iter()
|
||||||
|
.position(|candidate| *candidate == next)
|
||||||
|
.expect("visiting node is present in DFS stack");
|
||||||
|
return Some(stack[start..].to_vec());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stack.pop();
|
||||||
|
state[index] = 2;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut state = vec![0u8; adjacency.len()];
|
||||||
|
let mut stack = Vec::new();
|
||||||
|
for &index in active {
|
||||||
|
if state[index] == 0
|
||||||
|
&& let Some(cycle) = visit(index, active, adjacency, &mut state, &mut stack)
|
||||||
|
{
|
||||||
|
return cycle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FeatureRegistryBuilder {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
modules: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl FeatureRegistryBuilder {
|
impl FeatureRegistryBuilder {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
@@ -1523,10 +1790,14 @@ impl FeatureRegistryBuilder {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn plan(&self) -> Result<FeatureInstallPlan, FeaturePlanError> {
|
||||||
|
build_feature_install_plan(&self.descriptors())
|
||||||
|
}
|
||||||
|
|
||||||
/// 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();
|
||||||
@@ -1568,53 +1839,52 @@ impl FeatureRegistryBuilder {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|module| module.descriptor())
|
.map(|module| module.descriptor())
|
||||||
.collect();
|
.collect();
|
||||||
let mut service_registry = FeatureServiceRegistry::default();
|
let plan = match build_feature_install_plan(&descriptors) {
|
||||||
let mut reports = Vec::with_capacity(self.modules.len());
|
Ok(plan) => plan,
|
||||||
let mut seen_features = HashSet::new();
|
Err(error) => {
|
||||||
|
let message = error.to_string();
|
||||||
let mut pending_modules: Vec<_> = self.modules.into_iter().zip(descriptors).collect();
|
let reports = descriptors
|
||||||
let mut ordered_modules = Vec::with_capacity(pending_modules.len());
|
|
||||||
let mut declared_services = HashSet::new();
|
|
||||||
while !pending_modules.is_empty() {
|
|
||||||
let next = pending_modules
|
|
||||||
.iter()
|
|
||||||
.position(|(_, descriptor)| {
|
|
||||||
descriptor
|
|
||||||
.requires_services
|
|
||||||
.iter()
|
|
||||||
.filter(|requirement| requirement.required)
|
|
||||||
.all(|requirement| declared_services.contains(&requirement.id))
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
|
||||||
let entry = pending_modules.remove(next);
|
|
||||||
declared_services.extend(
|
|
||||||
entry
|
|
||||||
.1
|
|
||||||
.provides_services
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|service| service.id.clone()),
|
.map(|descriptor| {
|
||||||
);
|
let mut report = FeatureInstallReport::new(descriptor);
|
||||||
ordered_modules.push(entry);
|
report
|
||||||
}
|
.diagnostics
|
||||||
|
.push(FeatureDiagnostic::error(message.clone()));
|
||||||
|
report.mark_skipped(
|
||||||
|
FeatureContributionKind::Diagnostic,
|
||||||
|
descriptor.id.to_string(),
|
||||||
|
"feature installation plan rejected before installation",
|
||||||
|
);
|
||||||
|
report
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
return FeatureRegistryInstallReport {
|
||||||
|
reports,
|
||||||
|
services: FeatureServiceRegistry::default(),
|
||||||
|
plan_error: Some(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut service_registry = FeatureServiceRegistry::default();
|
||||||
|
let mut reports = Vec::with_capacity(plan.ordered_indices.len());
|
||||||
|
let mut modules = self.modules.into_iter().map(Some).collect::<Vec<_>>();
|
||||||
|
let ordered_modules = plan
|
||||||
|
.ordered_indices
|
||||||
|
.into_iter()
|
||||||
|
.map(|index| {
|
||||||
|
(
|
||||||
|
modules[index]
|
||||||
|
.take()
|
||||||
|
.expect("planned feature module is selected exactly once"),
|
||||||
|
descriptors[index].clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
for (module, descriptor) in ordered_modules {
|
for (module, descriptor) in ordered_modules {
|
||||||
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
|
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
|
||||||
let mut report = FeatureInstallReport::new(&descriptor);
|
let mut report = FeatureInstallReport::new(&descriptor);
|
||||||
|
|
||||||
if !seen_features.insert(descriptor.id.clone()) {
|
|
||||||
report.diagnostics.push(FeatureDiagnostic::error(format!(
|
|
||||||
"duplicate feature id: {}",
|
|
||||||
descriptor.id
|
|
||||||
)));
|
|
||||||
report.mark_skipped(
|
|
||||||
FeatureContributionKind::Diagnostic,
|
|
||||||
descriptor.id.to_string(),
|
|
||||||
"duplicate feature id",
|
|
||||||
);
|
|
||||||
reports.push(report);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut required_service_failed = false;
|
let mut required_service_failed = false;
|
||||||
for requirement in descriptor.requires_services.iter().cloned() {
|
for requirement in descriptor.requires_services.iter().cloned() {
|
||||||
if service_registry.provides(&requirement.id) {
|
if service_registry.provides(&requirement.id) {
|
||||||
@@ -1681,6 +1951,7 @@ impl FeatureRegistryBuilder {
|
|||||||
FeatureRegistryInstallReport {
|
FeatureRegistryInstallReport {
|
||||||
reports,
|
reports,
|
||||||
services: service_registry,
|
services: service_registry,
|
||||||
|
plan_error: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1821,6 +2092,59 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct PlannedServiceFeature {
|
||||||
|
descriptor: FeatureDescriptor,
|
||||||
|
install_calls: Arc<AtomicUsize>,
|
||||||
|
fail_install: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlannedServiceFeature {
|
||||||
|
fn new(descriptor: FeatureDescriptor) -> Self {
|
||||||
|
Self {
|
||||||
|
descriptor,
|
||||||
|
install_calls: Arc::new(AtomicUsize::new(0)),
|
||||||
|
fail_install: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeatureModule for PlannedServiceFeature {
|
||||||
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
|
self.descriptor.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(
|
||||||
|
&self,
|
||||||
|
context: &mut FeatureInstallContext<'_>,
|
||||||
|
) -> Result<(), FeatureInstallError> {
|
||||||
|
self.install_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
if self.fail_install {
|
||||||
|
return Err(FeatureInstallError::Install(
|
||||||
|
"injected provider failure".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for service in &self.descriptor.provides_services {
|
||||||
|
context
|
||||||
|
.services()
|
||||||
|
.provide(service.clone(), Arc::new(service.id.to_string()))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provided_service(id: &'static str, version: &'static str) -> ServiceDeclaration {
|
||||||
|
ServiceDeclaration::new(ServiceId::builtin(id), version, "test service")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_service(id: &'static str, version: ServiceVersionReq) -> ServiceRequirement {
|
||||||
|
{
|
||||||
|
let mut requirement =
|
||||||
|
ServiceRequirement::required(ServiceId::builtin(id), "test dependency");
|
||||||
|
requirement.version = version;
|
||||||
|
requirement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn instruction(id: &'static str, prompt_ref: &'static str) -> FeatureInstructionDeclaration {
|
fn instruction(id: &'static str, prompt_ref: &'static str) -> FeatureInstructionDeclaration {
|
||||||
FeatureInstructionDeclaration::new(
|
FeatureInstructionDeclaration::new(
|
||||||
FeatureInstructionId::builtin(id),
|
FeatureInstructionId::builtin(id),
|
||||||
@@ -1830,6 +2154,249 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_plan_orders_provider_before_consumer_stably() {
|
||||||
|
let consumer = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("consumer", "Consumer")
|
||||||
|
.with_service_requirement(required_service("catalog", ServiceVersionReq::any())),
|
||||||
|
);
|
||||||
|
let unrelated =
|
||||||
|
PlannedServiceFeature::new(FeatureDescriptor::builtin("unrelated", "Unrelated"));
|
||||||
|
let provider = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("provider", "Provider")
|
||||||
|
.with_provided_service(provided_service("catalog", "1")),
|
||||||
|
);
|
||||||
|
let builder = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(consumer)
|
||||||
|
.with_module(unrelated)
|
||||||
|
.with_module(provider);
|
||||||
|
|
||||||
|
let plan = builder.plan().expect("service graph should be valid");
|
||||||
|
assert_eq!(
|
||||||
|
plan.ordered_features(),
|
||||||
|
&[
|
||||||
|
FeatureId::builtin("unrelated"),
|
||||||
|
FeatureId::builtin("provider"),
|
||||||
|
FeatureId::builtin("consumer"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_plan_orders_compatible_optional_provider_before_consumer() {
|
||||||
|
let service = ServiceId::builtin("catalog");
|
||||||
|
let consumer = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("consumer", "Consumer").with_service_requirement(
|
||||||
|
ServiceRequirement::optional(service.clone(), "optional catalog"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
let provider = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("provider", "Provider")
|
||||||
|
.with_provided_service(provided_service("catalog", "1")),
|
||||||
|
);
|
||||||
|
let builder = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(consumer)
|
||||||
|
.with_module(provider);
|
||||||
|
|
||||||
|
let plan = builder.plan().expect("optional binding should be valid");
|
||||||
|
assert_eq!(
|
||||||
|
plan.ordered_features(),
|
||||||
|
&[
|
||||||
|
FeatureId::builtin("provider"),
|
||||||
|
FeatureId::builtin("consumer"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut hooks = HookRegistryBuilder::default();
|
||||||
|
let mut pending_tools = Vec::new();
|
||||||
|
let report = builder.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
assert!(!report.has_errors());
|
||||||
|
let consumer = report
|
||||||
|
.reports
|
||||||
|
.iter()
|
||||||
|
.find(|report| report.feature_id == FeatureId::builtin("consumer"))
|
||||||
|
.expect("consumer report");
|
||||||
|
assert!(
|
||||||
|
consumer
|
||||||
|
.resolved_service_requirements
|
||||||
|
.iter()
|
||||||
|
.any(|requirement| requirement.id == service)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_plan_rejects_missing_required_service() {
|
||||||
|
let builder = FeatureRegistryBuilder::new().with_module(PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("consumer", "Consumer")
|
||||||
|
.with_service_requirement(required_service("missing", ServiceVersionReq::any())),
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
builder.plan(),
|
||||||
|
Err(FeaturePlanError::MissingServiceProvider {
|
||||||
|
consumer: FeatureId::builtin("consumer"),
|
||||||
|
service: ServiceId::builtin("missing"),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_plan_rejects_ambiguous_provider_independent_of_registration_order() {
|
||||||
|
fn plan(reverse: bool) -> FeaturePlanError {
|
||||||
|
let provider_a = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("provider-a", "Provider A")
|
||||||
|
.with_provided_service(provided_service("catalog", "1")),
|
||||||
|
);
|
||||||
|
let provider_b = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("provider-b", "Provider B")
|
||||||
|
.with_provided_service(provided_service("catalog", "1")),
|
||||||
|
);
|
||||||
|
let mut builder = FeatureRegistryBuilder::new();
|
||||||
|
if reverse {
|
||||||
|
builder.add_module(provider_b).add_module(provider_a);
|
||||||
|
} else {
|
||||||
|
builder.add_module(provider_a).add_module(provider_b);
|
||||||
|
}
|
||||||
|
builder.plan().expect_err("duplicate providers must fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected = FeaturePlanError::AmbiguousServiceProvider {
|
||||||
|
service: ServiceId::builtin("catalog"),
|
||||||
|
providers: vec![
|
||||||
|
FeatureId::builtin("provider-a"),
|
||||||
|
FeatureId::builtin("provider-b"),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert_eq!(plan(false), expected);
|
||||||
|
assert_eq!(plan(true), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_plan_rejects_service_version_mismatch() {
|
||||||
|
let builder = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("consumer", "Consumer").with_service_requirement(
|
||||||
|
required_service("catalog", ServiceVersionReq::exact("2")),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.with_module(PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("provider", "Provider")
|
||||||
|
.with_provided_service(provided_service("catalog", "1")),
|
||||||
|
));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
builder.plan(),
|
||||||
|
Err(FeaturePlanError::ServiceVersionMismatch {
|
||||||
|
consumer: FeatureId::builtin("consumer"),
|
||||||
|
service: ServiceId::builtin("catalog"),
|
||||||
|
requirement: "2".into(),
|
||||||
|
provider: FeatureId::builtin("provider"),
|
||||||
|
provider_version: "1".into(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_plan_reports_service_cycle_members() {
|
||||||
|
let builder = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("alpha", "Alpha")
|
||||||
|
.with_provided_service(provided_service("alpha-service", "1"))
|
||||||
|
.with_service_requirement(required_service(
|
||||||
|
"beta-service",
|
||||||
|
ServiceVersionReq::any(),
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
.with_module(PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("beta", "Beta")
|
||||||
|
.with_provided_service(provided_service("beta-service", "1"))
|
||||||
|
.with_service_requirement(required_service(
|
||||||
|
"alpha-service",
|
||||||
|
ServiceVersionReq::any(),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
|
||||||
|
let error = builder.plan().expect_err("cycle must fail before install");
|
||||||
|
let FeaturePlanError::ServiceDependencyCycle { features, services } = error else {
|
||||||
|
panic!("unexpected plan error: {error:?}");
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
features.into_iter().collect::<BTreeSet<_>>(),
|
||||||
|
BTreeSet::from([FeatureId::builtin("alpha"), FeatureId::builtin("beta")])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
services.into_iter().collect::<BTreeSet<_>>(),
|
||||||
|
BTreeSet::from([
|
||||||
|
ServiceId::builtin("alpha-service"),
|
||||||
|
ServiceId::builtin("beta-service"),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plan_failure_has_no_install_side_effects_and_reports_identities() {
|
||||||
|
let first = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("first", "First")
|
||||||
|
.with_provided_service(provided_service("control", "1")),
|
||||||
|
);
|
||||||
|
let first_calls = Arc::clone(&first.install_calls);
|
||||||
|
let second = PlannedServiceFeature::new(
|
||||||
|
FeatureDescriptor::builtin("second", "Second")
|
||||||
|
.with_provided_service(provided_service("control", "1")),
|
||||||
|
);
|
||||||
|
let second_calls = Arc::clone(&second.install_calls);
|
||||||
|
let mut hook_builder = HookRegistryBuilder::default();
|
||||||
|
let mut pending_tools = Vec::new();
|
||||||
|
|
||||||
|
let report = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(first)
|
||||||
|
.with_module(second)
|
||||||
|
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||||
|
|
||||||
|
assert!(report.has_errors());
|
||||||
|
assert!(matches!(
|
||||||
|
report.plan_error,
|
||||||
|
Some(FeaturePlanError::AmbiguousServiceProvider { .. })
|
||||||
|
));
|
||||||
|
assert!(report.error_message().contains("builtin:control"));
|
||||||
|
assert!(report.error_message().contains("builtin:first"));
|
||||||
|
assert!(report.error_message().contains("builtin:second"));
|
||||||
|
assert_eq!(first_calls.load(Ordering::SeqCst), 0);
|
||||||
|
assert_eq!(second_calls.load(Ordering::SeqCst), 0);
|
||||||
|
assert!(pending_tools.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn provider_install_failure_is_fatal_and_does_not_queue_tools() {
|
||||||
|
let provider_calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let provider = PlannedServiceFeature {
|
||||||
|
descriptor: FeatureDescriptor::builtin("provider", "Provider")
|
||||||
|
.with_provided_service(provided_service("control", "1")),
|
||||||
|
install_calls: Arc::clone(&provider_calls),
|
||||||
|
fail_install: true,
|
||||||
|
};
|
||||||
|
let consumer = ToolFeature {
|
||||||
|
descriptor: FeatureDescriptor::builtin("consumer", "Consumer")
|
||||||
|
.with_service_requirement(required_service("control", ServiceVersionReq::any()))
|
||||||
|
.with_tool(ToolDeclaration::new("Dependent", "dependent tool")),
|
||||||
|
contribution_name: "Dependent",
|
||||||
|
model_visible_name: "Dependent",
|
||||||
|
};
|
||||||
|
let mut hook_builder = HookRegistryBuilder::default();
|
||||||
|
let mut pending_tools = Vec::new();
|
||||||
|
|
||||||
|
let report = FeatureRegistryBuilder::new()
|
||||||
|
.with_module(consumer)
|
||||||
|
.with_module(provider)
|
||||||
|
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||||
|
|
||||||
|
assert!(report.has_errors());
|
||||||
|
assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
|
||||||
|
assert!(pending_tools.is_empty());
|
||||||
|
assert!(report.error_message().contains("builtin:provider"));
|
||||||
|
assert!(report.error_message().contains("injected provider failure"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn descriptor_contributions_are_recorded() {
|
fn descriptor_contributions_are_recorded() {
|
||||||
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
|
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
|
||||||
@@ -2382,10 +2949,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let consumer = FeatureDescriptor::builtin("consumer", "Consumer")
|
let consumer = FeatureDescriptor::builtin("consumer", "Consumer")
|
||||||
.with_service_requirement(ServiceRequirement::required(service.clone(), "needs demo"));
|
.with_service_requirement(ServiceRequirement::required(service.clone(), "needs demo"));
|
||||||
let missing_service = ServiceId::builtin("missing-service");
|
|
||||||
let missing = FeatureDescriptor::builtin("missing", "Missing").with_service_requirement(
|
|
||||||
ServiceRequirement::required(missing_service, "needs missing"),
|
|
||||||
);
|
|
||||||
let optional_service = ServiceId::builtin("optional-service");
|
let optional_service = ServiceId::builtin("optional-service");
|
||||||
let optional = FeatureDescriptor::builtin("optional", "Optional").with_service_requirement(
|
let optional = FeatureDescriptor::builtin("optional", "Optional").with_service_requirement(
|
||||||
ServiceRequirement::optional(optional_service, "nice to have"),
|
ServiceRequirement::optional(optional_service, "nice to have"),
|
||||||
@@ -2399,9 +2962,6 @@ mod tests {
|
|||||||
.with_module(ServiceFeature {
|
.with_module(ServiceFeature {
|
||||||
descriptor: consumer,
|
descriptor: consumer,
|
||||||
})
|
})
|
||||||
.with_module(ServiceFeature {
|
|
||||||
descriptor: missing,
|
|
||||||
})
|
|
||||||
.with_module(ServiceFeature {
|
.with_module(ServiceFeature {
|
||||||
descriptor: optional,
|
descriptor: optional,
|
||||||
})
|
})
|
||||||
@@ -2413,18 +2973,6 @@ mod tests {
|
|||||||
report.reports[1].resolved_service_requirements[0].id,
|
report.reports[1].resolved_service_requirements[0].id,
|
||||||
service
|
service
|
||||||
);
|
);
|
||||||
let missing_report = report
|
|
||||||
.reports
|
|
||||||
.iter()
|
|
||||||
.find(|feature| feature.feature_id == FeatureId::builtin("missing"))
|
|
||||||
.unwrap();
|
|
||||||
assert!(!missing_report.installed);
|
|
||||||
assert!(
|
|
||||||
missing_report
|
|
||||||
.diagnostics
|
|
||||||
.iter()
|
|
||||||
.any(|diagnostic| diagnostic.message.contains("required service requirement"))
|
|
||||||
);
|
|
||||||
let optional_report = report
|
let optional_report = report
|
||||||
.reports
|
.reports
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2543,11 +3091,12 @@ mod tests {
|
|||||||
.with_module(ServiceFeature { descriptor })
|
.with_module(ServiceFeature { descriptor })
|
||||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||||
assert!(report.has_errors());
|
assert!(report.has_errors());
|
||||||
assert!(
|
assert!(matches!(
|
||||||
report
|
report.plan_error,
|
||||||
.error_message()
|
Some(FeaturePlanError::MissingServiceProvider { .. })
|
||||||
.contains("required service requirement")
|
));
|
||||||
);
|
assert!(report.error_message().contains("builtin:consumer"));
|
||||||
|
assert!(report.error_message().contains("builtin:missing-service"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -356,6 +356,52 @@ pub fn manage_worker_feature(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct SubWorkerControlFeature {
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubWorkerControlFeature {
|
||||||
|
pub fn new(client: Arc<dyn WorkspaceClient>, registry: Arc<SpawnedWorkerRegistry>) -> Self {
|
||||||
|
Self { client, registry }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeatureModule for SubWorkerControlFeature {
|
||||||
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
|
FeatureDescriptor::builtin("sub-worker-control", "SubWorker Control")
|
||||||
|
.with_description("Parent-owned SubWorker control service provider")
|
||||||
|
.with_provided_service(ServiceDeclaration::new(
|
||||||
|
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||||
|
WORKER_LIFECYCLE_SERVICE_VERSION,
|
||||||
|
"Parent-owned SubWorker discovery and control operations",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
|
let control: Arc<dyn WorkerControlService> = Arc::new(WorkspaceWorkerControlService {
|
||||||
|
workspace_id: self.client.workspace_id().unwrap_or_default().to_string(),
|
||||||
|
client: self.client.clone(),
|
||||||
|
registry: Some(self.registry.clone()),
|
||||||
|
});
|
||||||
|
context.services().provide(
|
||||||
|
ServiceDeclaration::new(
|
||||||
|
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||||
|
WORKER_LIFECYCLE_SERVICE_VERSION,
|
||||||
|
"Parent-owned SubWorker discovery and control operations",
|
||||||
|
),
|
||||||
|
control,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sub_worker_control_feature(
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
|
) -> SubWorkerControlFeature {
|
||||||
|
SubWorkerControlFeature::new(client, registry)
|
||||||
|
}
|
||||||
|
|
||||||
impl FeatureModule for ManageWorkerFeature {
|
impl FeatureModule for ManageWorkerFeature {
|
||||||
fn descriptor(&self) -> FeatureDescriptor {
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
||||||
@@ -1046,6 +1092,42 @@ mod tests {
|
|||||||
assert!(body.get("initial_text").is_none());
|
assert!(body.get("initial_text").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worker_and_sub_worker_providers_are_ambiguous_before_install() {
|
||||||
|
let runtime_base = tempfile::tempdir().unwrap();
|
||||||
|
let runtime_dir = Arc::new(
|
||||||
|
crate::runtime::dir::RuntimeDir::create(runtime_base.path(), "feature-plan")
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let registry = SpawnedWorkerRegistry::new(runtime_dir);
|
||||||
|
let client: Arc<dyn WorkspaceClient> = Arc::new(RecordingWorkspaceClient::default());
|
||||||
|
let mut builder = crate::feature::FeatureRegistryBuilder::new();
|
||||||
|
builder.add_module(sub_worker_control_feature(client.clone(), registry.clone()));
|
||||||
|
builder.add_module(manage_worker_feature(client, Some(registry), true));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
builder.plan(),
|
||||||
|
Err(crate::feature::FeaturePlanError::AmbiguousServiceProvider {
|
||||||
|
service: ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
|
||||||
|
providers: vec![
|
||||||
|
crate::feature::FeatureId::builtin("sub-worker-control"),
|
||||||
|
crate::feature::FeatureId::builtin("worker"),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut hooks = crate::HookRegistryBuilder::default();
|
||||||
|
let mut pending_tools = Vec::new();
|
||||||
|
let report = builder.install_into_pending(&mut pending_tools, &mut hooks);
|
||||||
|
assert!(matches!(
|
||||||
|
report.plan_error,
|
||||||
|
Some(crate::feature::FeaturePlanError::AmbiguousServiceProvider { .. })
|
||||||
|
));
|
||||||
|
assert!(pending_tools.is_empty());
|
||||||
|
assert!(report.services.providers().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_service_can_remain_enabled_without_direct_spawn_surface() {
|
fn worker_service_can_remain_enabled_without_direct_spawn_surface() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::default());
|
let client = Arc::new(RecordingWorkspaceClient::default());
|
||||||
|
|||||||
@@ -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 ¶ms.entry_refs {
|
for entry_ref in ¶ms.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() };
|
||||||
|
|||||||
@@ -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,44 @@ 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- 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 +222,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 +284,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- 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 +313,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 +464,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 +475,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 +491,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 +507,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 +518,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 +533,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 +553,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 +648,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 +708,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_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_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_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,792 @@
|
|||||||
|
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: resource_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_resource_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: resource_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_resource_ref(key, "T-"))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(ModelObjectiveQueryItem {
|
||||||
|
objective: resource_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: resource_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: resource_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: resource_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: resource_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: resource_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: resource_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 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 resource_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} key is unavailable"))?;
|
||||||
|
validate_resource_ref(value, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_resource_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} key is unavailable"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn objective_projection_exposes_only_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 resource_projection_rejects_noncanonical_keys() {
|
||||||
|
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
|
||||||
|
assert!(validate_resource_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()),
|
||||||
|
|||||||
@@ -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,18 +286,52 @@ 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 {
|
||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct WorkspaceTicketService {
|
||||||
|
backend: WorkspaceHttpTicketBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_handoff_from_record(ticket: Ticket) -> Result<TicketHandoff, TicketError> {
|
||||||
|
let resource_key = ticket
|
||||||
|
.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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
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
|
ticket_handoff_from_record(self.backend.show(ticket_ref.into())?)
|
||||||
.show(ticket_id.into())
|
}
|
||||||
.map(|ticket| ticket.meta.workflow_state)
|
}
|
||||||
|
|
||||||
|
impl TicketService for WorkspaceTicketService {
|
||||||
|
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||||
|
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,9 +653,14 @@ impl FeatureModule for TicketFeature {
|
|||||||
let Some(backend) = self.tool_backend(context) else {
|
let Some(backend) = self.tool_backend(context) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let ticket_service: Arc<dyn TicketService> = Arc::new(BackendTicketService {
|
let ticket_service: Arc<dyn TicketService> = match &self.backend {
|
||||||
backend: backend.clone(),
|
TicketFeatureBackend::WorkspaceClient(client) => Arc::new(WorkspaceTicketService {
|
||||||
});
|
backend: WorkspaceHttpTicketBackend::new(client.clone()),
|
||||||
|
}),
|
||||||
|
TicketFeatureBackend::Local { .. } => Arc::new(BackendTicketService {
|
||||||
|
backend: backend.clone(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
context.services().provide(
|
context.services().provide(
|
||||||
ServiceDeclaration::new(
|
ServiceDeclaration::new(
|
||||||
ServiceId::builtin(TICKET_SERVICE_ID),
|
ServiceId::builtin(TICKET_SERVICE_ID),
|
||||||
@@ -674,6 +732,26 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
Self::invoke_client(client, workspace_id, operation)
|
Self::invoke_client(client, workspace_id, operation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn show_unprojected(&self, ticket_ref: &str) -> TicketResult<Ticket> {
|
||||||
|
let client = self.client.clone();
|
||||||
|
let workspace_id = self.client.workspace_id().unwrap_or_default().to_string();
|
||||||
|
let ticket_path = Self::ticket_path(&TicketIdOrSlug::from(ticket_ref));
|
||||||
|
let request = move || {
|
||||||
|
Self::request_unprojected(
|
||||||
|
client,
|
||||||
|
WorkspaceRequestMethod::Get,
|
||||||
|
format!("/api/w/{workspace_id}/tickets/{ticket_path}/record"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if tokio::runtime::Handle::try_current().is_ok() {
|
||||||
|
return std::thread::spawn(request).join().map_err(|_| {
|
||||||
|
TicketError::Conflict("ticket REST request thread panicked".to_string())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
request()
|
||||||
|
}
|
||||||
|
|
||||||
fn ticket_path(id: &TicketIdOrSlug) -> String {
|
fn ticket_path(id: &TicketIdOrSlug) -> String {
|
||||||
let value = match id {
|
let value = match id {
|
||||||
TicketIdOrSlug::Id(value)
|
TicketIdOrSlug::Id(value)
|
||||||
@@ -698,6 +776,29 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
endpoint: String,
|
endpoint: String,
|
||||||
body: Option<serde_json::Value>,
|
body: Option<serde_json::Value>,
|
||||||
) -> TicketResult<T> {
|
) -> TicketResult<T> {
|
||||||
|
let mut value = Self::request_value(client, method, endpoint, body)?;
|
||||||
|
Self::canonicalize_ticket_references(&mut value);
|
||||||
|
serde_json::from_value(value)
|
||||||
|
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_unprojected<T: serde::de::DeserializeOwned>(
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
method: WorkspaceRequestMethod,
|
||||||
|
endpoint: String,
|
||||||
|
body: Option<serde_json::Value>,
|
||||||
|
) -> TicketResult<T> {
|
||||||
|
let value = Self::request_value(client, method, endpoint, body)?;
|
||||||
|
serde_json::from_value(value)
|
||||||
|
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_value(
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
method: WorkspaceRequestMethod,
|
||||||
|
endpoint: String,
|
||||||
|
body: Option<serde_json::Value>,
|
||||||
|
) -> TicketResult<Value> {
|
||||||
let request = match body {
|
let request = match body {
|
||||||
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
|
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
|
||||||
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
|
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
|
||||||
@@ -712,14 +813,63 @@ 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)
|
serde_json::from_str(&response.body)
|
||||||
.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 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 +889,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 +931,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 key is unavailable".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
Ok(TicketBackendOperationResult::Ticket(ticket))
|
Ok(TicketBackendOperationResult::Ticket(ticket))
|
||||||
}
|
}
|
||||||
TicketBackendOperation::Create { input } => {
|
TicketBackendOperation::Create { input } => {
|
||||||
@@ -889,7 +1049,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 +1064,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 +1422,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 +1915,102 @@ provider = "github"
|
|||||||
server.join().unwrap();
|
server.join().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_http_backend_records_relation_with_authoritative_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 +2051,56 @@ 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 workspace_ticket_service_preserves_internal_identity_for_handoff() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let local = LocalTicketBackend::new(temp.path().join("tickets"));
|
||||||
|
let created = local.create(NewTicket::new("Ticket handoff")).unwrap();
|
||||||
|
let mut ticket = local.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
|
||||||
|
ticket.meta.resource_key = Some("T-548".to_string());
|
||||||
|
ticket.meta.workflow_state = TicketWorkflowState::Queued;
|
||||||
|
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let response_body = serde_json::to_string(&ticket).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("GET /api/w/workspace-a/tickets/T-548/record HTTP/1.1"));
|
||||||
|
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 service = WorkspaceTicketService {
|
||||||
|
backend: WorkspaceHttpTicketBackend::new(Arc::new(
|
||||||
|
crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
let handoff = service.ticket_handoff("T-548").unwrap();
|
||||||
|
|
||||||
|
server.join().unwrap();
|
||||||
|
assert_eq!(handoff.id, created.id);
|
||||||
|
assert_eq!(handoff.resource_key, "T-548");
|
||||||
|
assert_eq!(handoff.workflow_state, TicketWorkflowState::Queued);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
|||||||
@@ -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,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:?}"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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 { .. }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
|
||||||
|
|||||||
+1223
-274
File diff suppressed because it is too large
Load Diff
@@ -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,28 +205,34 @@ 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 {
|
||||||
return history
|
session_store::LogEntry::AnnotatedSegmentStart { history, .. } => history
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|logged| {
|
.map(|entry| entry.item)
|
||||||
let item: Item = logged.into();
|
.collect::<Vec<_>>(),
|
||||||
match item {
|
session_store::LogEntry::SegmentStart { history, .. } => history,
|
||||||
Item::Message {
|
_ => continue,
|
||||||
role: agen::Role::System,
|
};
|
||||||
content,
|
return history
|
||||||
..
|
.into_iter()
|
||||||
} => Some(
|
.filter_map(|logged| {
|
||||||
content
|
let item: Item = logged.into();
|
||||||
.iter()
|
match item {
|
||||||
.map(|p| p.as_text().to_owned())
|
Item::Message {
|
||||||
.collect::<Vec<_>>()
|
role: agen::Role::System,
|
||||||
.join(""),
|
content,
|
||||||
),
|
..
|
||||||
_ => None,
|
} => Some(
|
||||||
}
|
content
|
||||||
})
|
.iter()
|
||||||
.collect();
|
.map(|p| p.as_text().to_owned())
|
||||||
}
|
.collect::<Vec<_>>()
|
||||||
|
.join(""),
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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]]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ impl Tool for BigContentTool {
|
|||||||
Ok(ToolOutput {
|
Ok(ToolOutput {
|
||||||
summary: self.summary.into(),
|
summary: self.summary.into(),
|
||||||
content: Some(self.content.clone()),
|
content: Some(self.content.clone()),
|
||||||
|
attachments: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,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(
|
||||||
@@ -459,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,
|
||||||
@@ -535,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,
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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",
|
||||||
assert!(
|
"server.toml",
|
||||||
error.to_string().contains("unknown field"),
|
|
||||||
"unexpected error: {error}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolves_relative_paths_against_workspace_root() {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
|
||||||
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();
|
.unwrap();
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
let resolved = ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
|
tempfile::tempdir().unwrap().path(),
|
||||||
assert_eq!(
|
identity(),
|
||||||
resolved.server.static_assets_dir,
|
&host_config,
|
||||||
Some(dir.path().join("web/build"))
|
&BackendRuntimesConfigFile::default(),
|
||||||
);
|
|
||||||
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();
|
.unwrap();
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
let AuthConfig::Passkey {
|
||||||
resolved.database_path,
|
rp_id,
|
||||||
PathBuf::from("/tmp/yoi-workspace.db")
|
origin,
|
||||||
);
|
public_base_url,
|
||||||
assert_eq!(
|
..
|
||||||
resolved.server.embedded_runtime_store_root,
|
} = &resolved.server.auth;
|
||||||
PathBuf::from("/tmp/yoi-runtime")
|
assert_eq!(rp_id, "yoi.example");
|
||||||
);
|
assert_eq!(origin, "https://yoi.example");
|
||||||
|
assert_eq!(public_base_url, "https://yoi.example");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn data_root_derives_runtime_store_path_only() {
|
fn browser_public_url_rejects_non_origin_urls() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
for value in [
|
||||||
let config = WorkspaceBackendConfigFile::parse_str(
|
"https://example.test/path",
|
||||||
r#"
|
"https://example.test?query=true",
|
||||||
[data]
|
"file:///tmp/web",
|
||||||
root = ".local-data"
|
] {
|
||||||
"#,
|
let host_config = ServerHostConfigFile {
|
||||||
"test",
|
browser: ServerBrowserConfig {
|
||||||
)
|
public_url: value.to_string(),
|
||||||
.unwrap();
|
},
|
||||||
let resolved = config.resolve(dir.path(), identity()).unwrap();
|
};
|
||||||
|
let result = ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
assert!(resolved.database_path.ends_with("server.db"));
|
tempfile::tempdir().unwrap().path(),
|
||||||
assert_eq!(
|
identity(),
|
||||||
resolved.server.embedded_runtime_store_root,
|
&host_config,
|
||||||
dir.path().join(".local-data/embedded-runtime")
|
&BackendRuntimesConfigFile::default(),
|
||||||
);
|
);
|
||||||
|
let error = match result {
|
||||||
|
Ok(_) => panic!("expected {value} to be rejected"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("browser.public_url"),
|
||||||
|
"unexpected error for {value}: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn copies_local_config_without_overwriting() {
|
fn server_host_config_loads_only_from_the_explicit_host_path() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
WorkspaceBackendConfigFile::ensure_local_config_for_workspace(dir.path()).unwrap();
|
let path = ServerHostConfigFile::path_for_config_dir(dir.path());
|
||||||
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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -4483,6 +4449,13 @@ mod tests {
|
|||||||
.resolve_profile("builtin:companion", root.path(), "embedded-test-companion")
|
.resolve_profile("builtin:companion", root.path(), "embedded-test-companion")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(companion.feature.manage_workdir.enabled);
|
assert!(companion.feature.manage_workdir.enabled);
|
||||||
|
assert!(companion.feature.sub_worker.enabled);
|
||||||
|
assert!(!companion.feature.worker.enabled);
|
||||||
|
let coder = archive
|
||||||
|
.resolve_profile("builtin:coder", root.path(), "embedded-test-coder")
|
||||||
|
.unwrap();
|
||||||
|
assert!(coder.feature.sub_worker.enabled);
|
||||||
|
assert!(!coder.feature.worker.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4509,6 +4482,8 @@ mod tests {
|
|||||||
.resolve_profile("builtin:coder", root.path(), "remote-test-worker")
|
.resolve_profile("builtin:coder", root.path(), "remote-test-worker")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(manifest.worker.name, "remote-test-worker");
|
assert_eq!(manifest.worker.name, "remote-test-worker");
|
||||||
|
assert!(manifest.feature.sub_worker.enabled);
|
||||||
|
assert!(!manifest.feature.worker.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -4800,22 +4775,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,
|
os: "test".to_string(),
|
||||||
can_list_workers: true,
|
arch: "test".to_string(),
|
||||||
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(),
|
|
||||||
arch: "test".to_string(),
|
|
||||||
},
|
|
||||||
diagnostics: Vec::new(),
|
diagnostics: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4830,7 +4792,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 +5197,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(
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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::{
|
||||||
|
|||||||
@@ -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()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user