Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b54b99568e | ||
|
|
911162372c | ||
|
|
bdedf2965d | ||
|
|
d092074ca4 | ||
|
|
1fb1dd489d |
@@ -1,19 +1,21 @@
|
|||||||
すでにシステムのドッグフーディングに成功しており、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceの実装を進めている。
|
すでにシステムのドッグフーディングに成功しているが、一旦安定した旧バージョンで、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceを作るObjectiveを進めている。
|
||||||
|
|
||||||
## このシステムに置ける設計要旨
|
## このシステムに置ける設計要旨
|
||||||
|
|
||||||
- プロンプトはすべて`resources/prompts`に集約している。管理効率の向上のためであると同時に、ユーザーがオーバーライドする形式でもある。
|
- プロンプトはすべて resources/promptsに集約している。管理効率の向上と同時に、ユーザーがオーバーライドする形式でもある。
|
||||||
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
|
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
|
||||||
|
|
||||||
### LLM コンテキストの加工原則
|
### LLM コンテキストの加工原則
|
||||||
|
|
||||||
LLM に投げる context はappend-onlyが基本であり、またその永続化形式からAPIコールの形式を純粋に再現可能である必要が有る。
|
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止**。
|
||||||
|
|
||||||
一時的にメッセージを差し込む等の、揮発性の有るコンテキストの改変や、過去のメッセージを改ざんすることは基本的に禁止されている。
|
Workerの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。(pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。
|
||||||
これを行うと、 LLM はそのコンテキストに基づいて生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。
|
||||||
|
|
||||||
過去のコンテキストの圧縮は、キャッシュ破壊とトークン消費のトレードオフであり、必要であれば行っている。
|
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
||||||
しかし、キャッシュを破壊するタイミングと頻度は正確にコントロールされる必要があり、実際のセッションデータの解析に基づいて慎重に設計されるべきである。
|
|
||||||
|
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / typed `SystemItem` reminder はこの原則で扱う。
|
||||||
|
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Generated
+659
-107
File diff suppressed because it is too large
Load Diff
-11
@@ -5,7 +5,6 @@ members = [
|
|||||||
"crates/agen",
|
"crates/agen",
|
||||||
"crates/agen-macros",
|
"crates/agen-macros",
|
||||||
"crates/session-store",
|
"crates/session-store",
|
||||||
"crates/standalone",
|
|
||||||
"crates/secrets",
|
"crates/secrets",
|
||||||
"crates/manifest",
|
"crates/manifest",
|
||||||
"crates/mcp",
|
"crates/mcp",
|
||||||
@@ -37,7 +36,6 @@ default-members = [
|
|||||||
"crates/agen",
|
"crates/agen",
|
||||||
"crates/agen-macros",
|
"crates/agen-macros",
|
||||||
"crates/session-store",
|
"crates/session-store",
|
||||||
"crates/standalone",
|
|
||||||
"crates/secrets",
|
"crates/secrets",
|
||||||
"crates/manifest",
|
"crates/manifest",
|
||||||
"crates/mcp",
|
"crates/mcp",
|
||||||
@@ -68,12 +66,6 @@ default-members = [
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
[profile.dev]
|
|
||||||
debug = "line-tables-only"
|
|
||||||
|
|
||||||
[profile.dev.package."*"]
|
|
||||||
debug = false
|
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# Internal crates
|
# Internal crates
|
||||||
client = { path = "crates/client" }
|
client = { path = "crates/client" }
|
||||||
@@ -95,7 +87,6 @@ protocol = { path = "crates/protocol" }
|
|||||||
session-metrics = { path = "crates/session-metrics" }
|
session-metrics = { path = "crates/session-metrics" }
|
||||||
session-analytics = { path = "crates/session-analytics" }
|
session-analytics = { path = "crates/session-analytics" }
|
||||||
session-store = { path = "crates/session-store" }
|
session-store = { path = "crates/session-store" }
|
||||||
standalone = { path = "crates/standalone" }
|
|
||||||
secrets = { path = "crates/secrets" }
|
secrets = { path = "crates/secrets" }
|
||||||
tools = { path = "crates/tools" }
|
tools = { path = "crates/tools" }
|
||||||
config-source = { path = "crates/config-source" }
|
config-source = { path = "crates/config-source" }
|
||||||
@@ -132,8 +123,6 @@ tokio-tungstenite = "0.29"
|
|||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
toml = "1.1"
|
toml = "1.1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
|
||||||
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
|
||||||
- /etc/yoi/server.toml:/server-config/server.toml:ro
|
- ./docker/workspace:/workspace:ro
|
||||||
|
|
||||||
webui:
|
webui:
|
||||||
image: yoi-webui:latest
|
image: yoi-webui:latest
|
||||||
|
|||||||
@@ -21,21 +21,20 @@ 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), 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.
|
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.
|
||||||
|
|
||||||
```no_run
|
```no_run
|
||||||
use agen::{Engine, EngineError, History};
|
use agen::{Engine, EngineError};
|
||||||
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(&mut history, "Explain typed state in one sentence.")
|
.run("Explain typed state in one sentence.")
|
||||||
.await;
|
.await?;
|
||||||
|
|
||||||
let mut engine = output.engine;
|
let mut engine = output.engine;
|
||||||
let _result = engine.run(&mut history, "Give a Rust example.").await;
|
let _result = engine.run("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, EngineRunExit, RunInterruptionReason};
|
use agen::{Engine, EngineResult};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -29,7 +29,6 @@ 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");
|
||||||
@@ -46,15 +45,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
println!("📡 Sending request to LLM...");
|
println!("📡 Sending request to LLM...");
|
||||||
|
|
||||||
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;
|
match engine.run("Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await {
|
||||||
match output.result {
|
Ok(out) => match out.result {
|
||||||
EngineRunExit::Finished => println!("✅ Task completed normally"),
|
EngineResult::Finished => println!("✅ Task completed normally"),
|
||||||
EngineRunExit::Paused => println!("⏸️ Task paused"),
|
EngineResult::Paused => println!("⏸️ Task paused"),
|
||||||
EngineRunExit::Yielded => println!("↩️ Task yielded"),
|
EngineResult::LimitReached => println!("🔒 Turn limit reached"),
|
||||||
EngineRunExit::Interrupted(RunInterruptionReason::LimitReached) => {
|
EngineResult::Yielded => println!("↩️ Task yielded"),
|
||||||
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,8 +39,8 @@ use tracing::info;
|
|||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use agen::{
|
use agen::{
|
||||||
Engine, EngineRunExit, RunInterruptionReason,
|
Engine,
|
||||||
interceptor::{Interceptor, InterceptorResult, PostToolAction, ToolResultInfo},
|
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
||||||
llm_client::{
|
llm_client::{
|
||||||
LlmClient,
|
LlmClient,
|
||||||
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
|
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
|
||||||
@@ -280,10 +280,7 @@ impl ToolResultPrinterPolicy {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for ToolResultPrinterPolicy {
|
impl Interceptor for ToolResultPrinterPolicy {
|
||||||
async fn post_tool_call(
|
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
||||||
&self,
|
|
||||||
info: &ToolResultInfo<'_, ()>,
|
|
||||||
) -> InterceptorResult<PostToolAction> {
|
|
||||||
let name = self
|
let name = self
|
||||||
.call_names
|
.call_names
|
||||||
.lock()
|
.lock()
|
||||||
@@ -297,7 +294,7 @@ impl Interceptor for ToolResultPrinterPolicy {
|
|||||||
println!(" Result ({}): ✅ {}", name, info.result.summary);
|
println!(" Result ({}): ✅ {}", name, info.result.summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(PostToolAction::Continue)
|
PostToolAction::Continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +451,6 @@ 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()));
|
||||||
|
|
||||||
@@ -480,10 +476,12 @@ 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 {
|
||||||
let output = engine.run(&mut history, &prompt).await;
|
match engine.run(&prompt).await {
|
||||||
if let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(error)) = output.result
|
Ok(_) => {}
|
||||||
{
|
Err(e) => {
|
||||||
eprintln!("\n❌ Error: {error}");
|
eprintln!("\n❌ Error: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -502,8 +500,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = engine.run(&mut history, first_input).await;
|
let mut locked = match engine.run(first_input).await {
|
||||||
let mut locked = output.engine;
|
Ok(out) => out.engine,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("\n❌ Error: {}", e);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
print!("\n👤 You: ");
|
print!("\n👤 You: ");
|
||||||
@@ -522,10 +525,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let EngineRunExit::Interrupted(RunInterruptionReason::Unexpected(error)) =
|
match locked.run(input).await {
|
||||||
locked.run(&mut history, input).await
|
Ok(_) => {}
|
||||||
{
|
Err(e) => {
|
||||||
eprintln!("\n❌ Error: {error}");
|
eprintln!("\n❌ Error: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+320
-1155
File diff suppressed because it is too large
Load Diff
@@ -1,199 +0,0 @@
|
|||||||
//! 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+28
-250
@@ -9,202 +9,8 @@ use std::sync::Arc;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
use crate::Item;
|
use crate::Item;
|
||||||
use crate::engine::EngineRunExit;
|
|
||||||
use crate::history::HistoryEntry;
|
|
||||||
use crate::tool::{Tool, ToolCall, ToolExecutionContext, ToolMeta, ToolResult};
|
use crate::tool::{Tool, ToolCall, ToolExecutionContext, ToolMeta, ToolResult};
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Typed lifecycle metadata and failures
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/// Maximum UTF-8 byte length retained for interceptor diagnostics.
|
|
||||||
pub const MAX_INTERCEPTOR_DIAGNOSTIC_BYTES: usize = 1024;
|
|
||||||
|
|
||||||
/// Stable category for the source of an interceptor failure.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum InterceptorErrorCategory {
|
|
||||||
Policy,
|
|
||||||
Dependency,
|
|
||||||
ContractViolation,
|
|
||||||
Internal,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for InterceptorErrorCategory {
|
|
||||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
formatter.write_str(match self {
|
|
||||||
Self::Policy => "policy",
|
|
||||||
Self::Dependency => "dependency",
|
|
||||||
Self::ContractViolation => "contract_violation",
|
|
||||||
Self::Internal => "internal",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A typed, bounded failure returned by an [`Interceptor`] implementation.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
||||||
#[error("{category}: {diagnostic}")]
|
|
||||||
pub struct InterceptorError {
|
|
||||||
category: InterceptorErrorCategory,
|
|
||||||
diagnostic: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InterceptorError {
|
|
||||||
pub fn new(category: InterceptorErrorCategory, diagnostic: impl Into<String>) -> Self {
|
|
||||||
let mut diagnostic = diagnostic.into();
|
|
||||||
if diagnostic.len() > MAX_INTERCEPTOR_DIAGNOSTIC_BYTES {
|
|
||||||
let mut end = MAX_INTERCEPTOR_DIAGNOSTIC_BYTES;
|
|
||||||
while !diagnostic.is_char_boundary(end) {
|
|
||||||
end -= 1;
|
|
||||||
}
|
|
||||||
diagnostic.truncate(end);
|
|
||||||
}
|
|
||||||
Self {
|
|
||||||
category,
|
|
||||||
diagnostic,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn category(&self) -> InterceptorErrorCategory {
|
|
||||||
self.category
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn diagnostic(&self) -> &str {
|
|
||||||
&self.diagnostic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The lifecycle phase at which an interceptor callback executes.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum InterceptorPhase {
|
|
||||||
#[default]
|
|
||||||
PromptSubmit,
|
|
||||||
PendingHistoryAppends,
|
|
||||||
PreLlmRequest,
|
|
||||||
PreToolCall,
|
|
||||||
PostToolCall,
|
|
||||||
AssistantTurnEnd,
|
|
||||||
RunExit,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for InterceptorPhase {
|
|
||||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
formatter.write_str(match self {
|
|
||||||
Self::PromptSubmit => "prompt_submit",
|
|
||||||
Self::PendingHistoryAppends => "pending_history_appends",
|
|
||||||
Self::PreLlmRequest => "pre_llm_request",
|
|
||||||
Self::PreToolCall => "pre_tool_call",
|
|
||||||
Self::PostToolCall => "post_tool_call",
|
|
||||||
Self::AssistantTurnEnd => "assistant_turn_end",
|
|
||||||
Self::RunExit => "run_exit",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
|
||||||
pub struct InterceptorRunId(pub u64);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
||||||
pub struct InterceptorTurnId(pub u64);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
||||||
pub enum InterceptorCallId {
|
|
||||||
Llm(u64),
|
|
||||||
Tool(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Saturating public counter used by interceptor contexts.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
|
|
||||||
pub struct InterceptorCounter(u32);
|
|
||||||
|
|
||||||
impl InterceptorCounter {
|
|
||||||
pub fn from_usize(value: usize) -> Self {
|
|
||||||
Self(u32::try_from(value).unwrap_or(u32::MAX))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(self) -> u32 {
|
|
||||||
self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub struct InterceptorCounters {
|
|
||||||
pub invocation: InterceptorCounter,
|
|
||||||
pub engine_turn: InterceptorCounter,
|
|
||||||
pub run_turn: InterceptorCounter,
|
|
||||||
pub llm_call: InterceptorCounter,
|
|
||||||
pub tool_batch: InterceptorCounter,
|
|
||||||
pub tool_call: InterceptorCounter,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Identity, phase, and bounded counters common to every lifecycle callback.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
||||||
pub struct InterceptorInvocation {
|
|
||||||
pub run_id: InterceptorRunId,
|
|
||||||
pub turn_id: Option<InterceptorTurnId>,
|
|
||||||
pub call_id: Option<InterceptorCallId>,
|
|
||||||
pub phase: InterceptorPhase,
|
|
||||||
pub counters: InterceptorCounters,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An interceptor failure bound to the exact Engine lifecycle phase that ran it.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
|
||||||
#[error("{phase} interceptor failed: {error}")]
|
|
||||||
pub struct InterceptorFailure {
|
|
||||||
phase: InterceptorPhase,
|
|
||||||
#[source]
|
|
||||||
error: InterceptorError,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InterceptorFailure {
|
|
||||||
pub(crate) fn new(phase: InterceptorPhase, error: InterceptorError) -> Self {
|
|
||||||
Self { phase, error }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn phase(&self) -> InterceptorPhase {
|
|
||||||
self.phase
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error(&self) -> &InterceptorError {
|
|
||||||
&self.error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type InterceptorResult<T> = Result<T, InterceptorError>;
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Lifecycle Contexts
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
pub struct PromptSubmitContext<'a, A = ()> {
|
|
||||||
pub invocation: InterceptorInvocation,
|
|
||||||
pub item: &'a mut Item,
|
|
||||||
pub history: &'a [HistoryEntry<A>],
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PendingHistoryAppendsContext<'a, A = ()> {
|
|
||||||
pub invocation: InterceptorInvocation,
|
|
||||||
pub history: &'a [HistoryEntry<A>],
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PreLlmRequestContext<'a, A = ()> {
|
|
||||||
pub invocation: InterceptorInvocation,
|
|
||||||
pub items: &'a mut Vec<Item>,
|
|
||||||
pub history: &'a [HistoryEntry<A>],
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct AssistantTurnEndContext<'a, A = ()> {
|
|
||||||
pub invocation: InterceptorInvocation,
|
|
||||||
pub assistant_entries: &'a [HistoryEntry<A>],
|
|
||||||
pub history: &'a [HistoryEntry<A>],
|
|
||||||
pub tool_calls: &'a [ToolCall],
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct RunExitContext<'a, A = ()> {
|
|
||||||
pub invocation: InterceptorInvocation,
|
|
||||||
pub exit: &'a EngineRunExit,
|
|
||||||
pub history: &'a [HistoryEntry<A>],
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Action Enums
|
// Action Enums
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -280,9 +86,9 @@ pub enum PostToolAction {
|
|||||||
/// Action at the end of a turn (when LLM produces no tool calls).
|
/// Action at the end of a turn (when LLM produces no tool calls).
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum TurnEndAction {
|
pub enum TurnEndAction {
|
||||||
/// Accept the Engine's natural next phase: execute tools, or finish when none exist.
|
/// Turn is finished, return to caller.
|
||||||
Finish,
|
Finish,
|
||||||
/// Commit additional messages, then continue through the natural next phase.
|
/// Continue with additional messages injected into history.
|
||||||
ContinueWithMessages(Vec<Item>),
|
ContinueWithMessages(Vec<Item>),
|
||||||
/// Pause execution (can be resumed later).
|
/// Pause execution (can be resumed later).
|
||||||
Pause,
|
Pause,
|
||||||
@@ -293,9 +99,8 @@ pub enum TurnEndAction {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/// Context for pre-tool-call decisions.
|
/// Context for pre-tool-call decisions.
|
||||||
pub struct ToolCallInfo<'a, A = ()> {
|
pub struct ToolCallInfo {
|
||||||
pub invocation: InterceptorInvocation,
|
/// Tool call information (modifiable).
|
||||||
pub history: &'a [HistoryEntry<A>],
|
|
||||||
pub call: ToolCall,
|
pub call: ToolCall,
|
||||||
/// Tool meta information.
|
/// Tool meta information.
|
||||||
pub meta: ToolMeta,
|
pub meta: ToolMeta,
|
||||||
@@ -306,11 +111,10 @@ pub struct ToolCallInfo<'a, A = ()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Context for post-tool-call decisions.
|
/// Context for post-tool-call decisions.
|
||||||
pub struct ToolResultInfo<'a, A = ()> {
|
pub struct ToolResultInfo {
|
||||||
pub invocation: InterceptorInvocation,
|
/// Original tool call.
|
||||||
pub history: &'a [HistoryEntry<A>],
|
|
||||||
pub call: ToolCall,
|
pub call: ToolCall,
|
||||||
/// Committed terminal tool execution result.
|
/// Tool execution result (modifiable).
|
||||||
pub result: ToolResult,
|
pub result: ToolResult,
|
||||||
/// Tool meta information.
|
/// Tool meta information.
|
||||||
pub meta: ToolMeta,
|
pub meta: ToolMeta,
|
||||||
@@ -326,22 +130,14 @@ pub struct ToolResultInfo<'a, A = ()> {
|
|||||||
|
|
||||||
/// Intercepts the Engine execution loop at key decision points.
|
/// Intercepts the Engine execution loop at key decision points.
|
||||||
///
|
///
|
||||||
/// Every lifecycle method is asynchronous and returns [`InterceptorResult`],
|
/// All methods have default implementations that let the Engine
|
||||||
/// keeping implementation failure separate from the method's control-flow
|
/// proceed without intervention. Callers provide richer implementations for
|
||||||
/// action. The Engine reports a failure as a typed run interruption annotated
|
/// approval flows, permission checks, etc.
|
||||||
/// with the exact [`InterceptorPhase`] that failed.
|
|
||||||
///
|
|
||||||
/// All methods have default implementations that let the Engine proceed
|
|
||||||
/// without intervention. Callers provide richer implementations for approval
|
|
||||||
/// flows, permission checks, and other trusted host adaptation.
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
|
pub trait Interceptor: Send + Sync {
|
||||||
/// Called after receiving user input, before adding it to Engine history.
|
/// Called after receiving user input, before adding to history.
|
||||||
async fn on_prompt_submit(
|
async fn on_prompt_submit(&self, _item: &mut Item) -> PromptAction {
|
||||||
&self,
|
PromptAction::Continue
|
||||||
_context: PromptSubmitContext<'_, A>,
|
|
||||||
) -> InterceptorResult<PromptAction> {
|
|
||||||
Ok(PromptAction::Continue)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Items that should be **committed to `engine.history`** just
|
/// Items that should be **committed to `engine.history`** just
|
||||||
@@ -362,10 +158,7 @@ pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
|
|||||||
/// reproducible per-request transformations (pruning, content
|
/// reproducible per-request transformations (pruning, content
|
||||||
/// trimming, cache anchors) that depend only on the existing
|
/// trimming, cache anchors) that depend only on the existing
|
||||||
/// history.
|
/// history.
|
||||||
async fn pending_history_appends(
|
async fn pending_history_appends(&self) -> Result<Vec<Item>, String> {
|
||||||
&self,
|
|
||||||
_context: PendingHistoryAppendsContext<'_, A>,
|
|
||||||
) -> InterceptorResult<Vec<Item>> {
|
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,42 +170,27 @@ pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
|
|||||||
/// If an interceptor derives a human/model-visible nudge from the current
|
/// If an interceptor derives a human/model-visible nudge from the current
|
||||||
/// request context, return [`PreRequestAction::ContinueWith`] so the Engine
|
/// request context, return [`PreRequestAction::ContinueWith`] so the Engine
|
||||||
/// commits it to history before the request is sent.
|
/// commits it to history before the request is sent.
|
||||||
async fn pre_llm_request(
|
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
|
||||||
&self,
|
PreRequestAction::Continue
|
||||||
_context: PreLlmRequestContext<'_, A>,
|
|
||||||
) -> InterceptorResult<PreRequestAction> {
|
|
||||||
Ok(PreRequestAction::Continue)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called before each tool is executed.
|
/// Called before each tool is executed.
|
||||||
async fn pre_tool_call(
|
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
|
||||||
&self,
|
PreToolAction::Continue
|
||||||
_info: &mut ToolCallInfo<'_, A>,
|
|
||||||
) -> InterceptorResult<PreToolAction> {
|
|
||||||
Ok(PreToolAction::Continue)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called after each tool reaches one terminal result and that result is committed.
|
/// Called after each tool completes.
|
||||||
async fn post_tool_call(
|
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
|
||||||
&self,
|
PostToolAction::Continue
|
||||||
_info: &ToolResultInfo<'_, A>,
|
|
||||||
) -> InterceptorResult<PostToolAction> {
|
|
||||||
Ok(PostToolAction::Continue)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called after every terminal assistant response is committed and before
|
/// Called when a turn ends with no tool calls.
|
||||||
/// the Engine decides whether to execute tools, continue, or finish.
|
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
|
||||||
async fn on_assistant_turn_end(
|
TurnEndAction::Finish
|
||||||
&self,
|
|
||||||
_context: AssistantTurnEndContext<'_, A>,
|
|
||||||
) -> InterceptorResult<TurnEndAction> {
|
|
||||||
Ok(TurnEndAction::Finish)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called once for the terminal outcome of each public run or resume call.
|
/// Called when execution is interrupted (abort or cancel).
|
||||||
async fn on_run_exit(&self, _context: RunExitContext<'_, A>) -> InterceptorResult<()> {
|
async fn on_abort(&self, _reason: &str) {}
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default interceptor: no intervention. Engine proceeds through the loop
|
/// Default interceptor: no intervention. Engine proceeds through the loop
|
||||||
@@ -420,4 +198,4 @@ pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
|
|||||||
pub(crate) struct DefaultInterceptor;
|
pub(crate) struct DefaultInterceptor;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl<A: Send + Sync> Interceptor<A> for DefaultInterceptor {}
|
impl Interceptor for DefaultInterceptor {}
|
||||||
|
|||||||
+4
-16
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
mod engine;
|
mod engine;
|
||||||
mod handler;
|
mod handler;
|
||||||
mod history;
|
|
||||||
mod message;
|
mod message;
|
||||||
|
|
||||||
pub(crate) mod callback;
|
pub(crate) mod callback;
|
||||||
@@ -21,24 +20,13 @@ 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, EngineRunExit, EngineRunOutput,
|
Engine, EngineConfig, EngineError, EngineResult, EngineRunOutput, LlmRetryNotice,
|
||||||
LlmRetryNotice, RunInterruptionReason, ToolRegistryError,
|
ToolRegistryError,
|
||||||
};
|
};
|
||||||
pub use handler::ToolUseBlockStart;
|
pub use handler::ToolUseBlockStart;
|
||||||
pub use history::{History, HistoryEntry};
|
pub use interceptor::Interceptor;
|
||||||
pub use interceptor::{
|
|
||||||
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorCounter,
|
|
||||||
InterceptorCounters, InterceptorError, InterceptorErrorCategory, InterceptorFailure,
|
|
||||||
InterceptorInvocation, InterceptorPhase, InterceptorResult, InterceptorRunId,
|
|
||||||
InterceptorTurnId, MAX_INTERCEPTOR_DIAGNOSTIC_BYTES, PendingHistoryAppendsContext,
|
|
||||||
PreLlmRequestContext, PromptSubmitContext, RunExitContext,
|
|
||||||
};
|
|
||||||
pub use message::{ContentPart, Item, Message, Role};
|
pub use message::{ContentPart, Item, Message, Role};
|
||||||
pub use tool::{
|
pub use tool::{ToolCall, ToolExecutionContext, ToolOutputLimits, ToolResult};
|
||||||
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,9 +18,6 @@ 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,
|
||||||
@@ -51,7 +48,6 @@ 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())
|
||||||
}
|
}
|
||||||
@@ -116,10 +112,7 @@ 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::ContextWindowExceeded
|
ClientError::Json(_) | ClientError::Sse(_) | ClientError::Config(_) => false,
|
||||||
| ClientError::Json(_)
|
|
||||||
| ClientError::Sse(_)
|
|
||||||
| ClientError::Config(_) => false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -431,7 +431,13 @@ fn api_error_code(error: &ClientError) -> Option<&str> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_context_length_exceeded(error: &ClientError) -> bool {
|
fn is_context_length_exceeded(error: &ClientError) -> bool {
|
||||||
matches!(error, ClientError::ContextWindowExceeded)
|
match error {
|
||||||
|
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(
|
||||||
@@ -481,9 +487,6 @@ 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, ToolResultDisposition};
|
use crate::tool::Attachment;
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -121,9 +121,6 @@ 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,
|
||||||
@@ -264,17 +261,7 @@ impl Item {
|
|||||||
content: Option<String>,
|
content: Option<String>,
|
||||||
is_error: bool,
|
is_error: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::tool_result_item_with_disposition_and_attachments(
|
Self::tool_result_item_with_attachments(call_id, summary, content, is_error, Vec::new())
|
||||||
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.
|
||||||
@@ -285,33 +272,11 @@ 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(&history)`.
|
/// Can transition to [`Locked`] state via `Engine::lock()`.
|
||||||
///
|
///
|
||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
|
|||||||
+2
-227
@@ -3,14 +3,7 @@
|
|||||||
//! 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::{
|
use std::{collections::HashMap, fmt, sync::Arc};
|
||||||
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};
|
||||||
@@ -30,12 +23,6 @@ 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),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -171,28 +158,6 @@ 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
|
||||||
@@ -357,12 +322,6 @@ 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)
|
||||||
@@ -375,142 +334,6 @@ 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
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -579,26 +402,6 @@ 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -626,9 +429,6 @@ 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)
|
||||||
@@ -645,20 +445,11 @@ 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: !disposition.is_success(),
|
is_error: false,
|
||||||
attachments: output.attachments,
|
attachments: output.attachments,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -667,28 +458,12 @@ 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)]
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
mod common;
|
|
||||||
|
|
||||||
use agen::interceptor::{
|
|
||||||
AssistantTurnEndContext, Interceptor, InterceptorCallId, InterceptorInvocation,
|
|
||||||
InterceptorPhase, InterceptorResult, PendingHistoryAppendsContext, PreLlmRequestContext,
|
|
||||||
PreRequestAction, PromptAction, PromptSubmitContext, RunExitContext, TurnEndAction,
|
|
||||||
};
|
|
||||||
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
|
|
||||||
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use common::MockLlmClient;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct AnnotationObservingInterceptor {
|
|
||||||
observed: Arc<Mutex<Vec<(InterceptorInvocation, Vec<String>)>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AnnotationObservingInterceptor {
|
|
||||||
fn record(&self, invocation: &InterceptorInvocation, history: &[HistoryEntry<String>]) {
|
|
||||||
self.observed.lock().unwrap().push((
|
|
||||||
invocation.clone(),
|
|
||||||
history
|
|
||||||
.iter()
|
|
||||||
.map(|entry| entry.annotation.clone())
|
|
||||||
.collect(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Interceptor<String> for AnnotationObservingInterceptor {
|
|
||||||
async fn on_prompt_submit(
|
|
||||||
&self,
|
|
||||||
context: PromptSubmitContext<'_, String>,
|
|
||||||
) -> InterceptorResult<PromptAction> {
|
|
||||||
self.record(&context.invocation, context.history);
|
|
||||||
Ok(PromptAction::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn pending_history_appends(
|
|
||||||
&self,
|
|
||||||
context: PendingHistoryAppendsContext<'_, String>,
|
|
||||||
) -> InterceptorResult<Vec<Item>> {
|
|
||||||
self.record(&context.invocation, context.history);
|
|
||||||
Ok(Vec::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn pre_llm_request(
|
|
||||||
&self,
|
|
||||||
context: PreLlmRequestContext<'_, String>,
|
|
||||||
) -> InterceptorResult<PreRequestAction> {
|
|
||||||
self.record(&context.invocation, context.history);
|
|
||||||
Ok(PreRequestAction::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_assistant_turn_end(
|
|
||||||
&self,
|
|
||||||
context: AssistantTurnEndContext<'_, String>,
|
|
||||||
) -> InterceptorResult<TurnEndAction> {
|
|
||||||
assert_eq!(context.assistant_entries.len(), 1);
|
|
||||||
assert_eq!(context.assistant_entries[0].annotation, "2:assistant");
|
|
||||||
self.record(&context.invocation, context.history);
|
|
||||||
Ok(TurnEndAction::Finish)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn on_run_exit(&self, context: RunExitContext<'_, String>) -> InterceptorResult<()> {
|
|
||||||
self.record(&context.invocation, context.history);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn interceptor_contexts_preserve_annotations_and_typed_lifecycle_identity() {
|
|
||||||
let client = MockLlmClient::new(completed_text_events("assistant reply"));
|
|
||||||
let mut engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
|
|
||||||
let observed = Arc::new(Mutex::new(Vec::new()));
|
|
||||||
engine.set_interceptor(AnnotationObservingInterceptor {
|
|
||||||
observed: observed.clone(),
|
|
||||||
});
|
|
||||||
let mut history = History::<String>::new();
|
|
||||||
let mut next = 0usize;
|
|
||||||
let mut annotate = |item: &Item| {
|
|
||||||
next += 1;
|
|
||||||
let kind = if item.is_assistant_message() {
|
|
||||||
"assistant"
|
|
||||||
} else {
|
|
||||||
"user"
|
|
||||||
};
|
|
||||||
Ok(format!("{next}:{kind}"))
|
|
||||||
};
|
|
||||||
|
|
||||||
let output = engine
|
|
||||||
.run_with_annotation(&mut history, "hello", &mut annotate)
|
|
||||||
.await;
|
|
||||||
assert!(matches!(output.result, agen::EngineRunExit::Finished));
|
|
||||||
|
|
||||||
let observed = observed.lock().unwrap();
|
|
||||||
let phases: Vec<_> = observed
|
|
||||||
.iter()
|
|
||||||
.map(|(invocation, _)| invocation.phase)
|
|
||||||
.collect();
|
|
||||||
assert_eq!(
|
|
||||||
phases,
|
|
||||||
[
|
|
||||||
InterceptorPhase::PromptSubmit,
|
|
||||||
InterceptorPhase::PendingHistoryAppends,
|
|
||||||
InterceptorPhase::PreLlmRequest,
|
|
||||||
InterceptorPhase::AssistantTurnEnd,
|
|
||||||
InterceptorPhase::RunExit,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
observed
|
|
||||||
.iter()
|
|
||||||
.all(|(invocation, _)| invocation.run_id == observed[0].0.run_id)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
observed
|
|
||||||
.iter()
|
|
||||||
.map(|(invocation, _)| invocation.counters.invocation.get())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
[0, 1, 2, 3, 4]
|
|
||||||
);
|
|
||||||
assert_eq!(observed[2].0.call_id, Some(InterceptorCallId::Llm(0)));
|
|
||||||
assert_eq!(observed[3].0.call_id, Some(InterceptorCallId::Llm(0)));
|
|
||||||
assert_eq!(observed[1].1, ["1:user"]);
|
|
||||||
assert_eq!(observed[2].1, ["1:user"]);
|
|
||||||
assert_eq!(observed[3].1, ["1:user", "2:assistant"]);
|
|
||||||
assert_eq!(observed[4].1, ["1:user", "2:assistant"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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,7 +58,6 @@ 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();
|
||||||
@@ -66,11 +65,8 @@ 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(&mut history, "retry once").await;
|
let result = engine.run("retry once").await;
|
||||||
assert!(
|
assert!(result.is_ok(), "engine should succeed after one retry");
|
||||||
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);
|
||||||
@@ -95,7 +91,6 @@ 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()));
|
||||||
@@ -113,12 +108,9 @@ async fn test_callback_text_block_events() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||||
let result = engine.run(&mut history, "Greet me").await;
|
let result = engine.run("Greet me").await;
|
||||||
assert!(
|
assert!(result.is_ok(), "Engine should complete");
|
||||||
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);
|
||||||
@@ -145,7 +137,6 @@ 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()));
|
||||||
@@ -163,8 +154,8 @@ async fn test_callback_tool_call_complete() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||||
let _ = engine.run(&mut history, "Weather please").await;
|
let _ = engine.run("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);
|
||||||
@@ -192,7 +183,6 @@ 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()));
|
||||||
@@ -207,9 +197,9 @@ async fn test_callback_turn_events() {
|
|||||||
ends.lock().unwrap().push(turn);
|
ends.lock().unwrap().push(turn);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mutable::run consumes self, returns (Locked, EngineRunExit)
|
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||||
let result = engine.run(&mut history, "Do something").await;
|
let result = engine.run("Do something").await;
|
||||||
assert!(matches!(result.result, agen::EngineRunExit::Finished));
|
assert!(result.is_ok());
|
||||||
|
|
||||||
let starts = turn_starts.lock().unwrap();
|
let starts = turn_starts.lock().unwrap();
|
||||||
let ends = turn_ends.lock().unwrap();
|
let ends = turn_ends.lock().unwrap();
|
||||||
@@ -264,7 +254,6 @@ 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",
|
||||||
@@ -287,7 +276,7 @@ async fn test_callback_tool_result_events() {
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
let _ = engine.run(&mut history, "call it").await;
|
let _ = engine.run("call it").await;
|
||||||
|
|
||||||
let observed = captured.lock().unwrap();
|
let observed = captured.lock().unwrap();
|
||||||
assert_eq!(observed.len(), 1);
|
assert_eq!(observed.len(), 1);
|
||||||
@@ -341,7 +330,6 @@ 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"));
|
||||||
|
|
||||||
@@ -357,7 +345,7 @@ async fn test_callback_tool_result_error_path() {
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
let _ = engine.run(&mut history, "fail it").await;
|
let _ = engine.run("fail it").await;
|
||||||
|
|
||||||
let observed = captured.lock().unwrap();
|
let observed = captured.lock().unwrap();
|
||||||
assert_eq!(observed.len(), 1);
|
assert_eq!(observed.len(), 1);
|
||||||
@@ -386,7 +374,6 @@ 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()));
|
||||||
|
|
||||||
@@ -395,8 +382,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, EngineRunExit)
|
// Mutable::run consumes self, returns (Locked, EngineResult)
|
||||||
let _ = engine.run(&mut history, "Hello").await;
|
let _ = engine.run("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,7 +19,6 @@ 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 {
|
||||||
@@ -31,7 +30,6 @@ 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())),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +41,6 @@ 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]
|
||||||
@@ -57,9 +51,8 @@ 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,15 +134,11 @@ 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(&mut history, "Hello").await;
|
let result = engine.run("Hello").await;
|
||||||
|
|
||||||
assert!(
|
assert!(result.is_ok(), "Engine should complete successfully");
|
||||||
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
|
||||||
@@ -160,7 +156,6 @@ 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();
|
||||||
@@ -168,9 +163,7 @@ 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
|
let _result = engine.run("What's the weather in Tokyo?").await;
|
||||||
.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
|
||||||
@@ -202,15 +195,11 @@ 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(&mut history, "Greet me").await;
|
let result = engine.run("Greet me").await;
|
||||||
|
|
||||||
assert!(
|
assert!(result.is_ok(), "Engine should complete successfully");
|
||||||
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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 mut history: History = History::new();
|
let out = engine.run("question?").await.expect("run ok");
|
||||||
let _out = engine.run(&mut history, "question?").await;
|
let engine = out.engine;
|
||||||
|
|
||||||
let entries = history.entries();
|
let history = engine.history();
|
||||||
// user / reasoning / assistant_message
|
// user / reasoning / assistant_message
|
||||||
assert_eq!(history.len(), 3, "history: {history:?}");
|
assert_eq!(history.len(), 3, "history: {history:?}");
|
||||||
|
|
||||||
assert!(matches!(entries[0].item, Item::Message { .. }));
|
assert!(matches!(history[0], Item::Message { .. }));
|
||||||
match &entries[1].item {
|
match &history[1] {
|
||||||
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!(entries[2].item.as_text(), Some("Here's the answer"));
|
assert_eq!(history[2].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 mut history: History = History::new();
|
let out = engine.run("q").await.expect("run ok");
|
||||||
let _out = engine.run(&mut history, "q").await;
|
let engine = out.engine;
|
||||||
|
|
||||||
let entries = history.entries();
|
let history = engine.history();
|
||||||
match &entries[1].item {
|
match &history[1] {
|
||||||
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 mut history: History = History::new();
|
let out = engine.run("q").await.expect("run ok");
|
||||||
let _out = engine.run(&mut history, "q").await;
|
let engine = out.engine;
|
||||||
|
|
||||||
let entries = history.entries();
|
let history = engine.history();
|
||||||
// user / reasoning(先頭) / assistant_message
|
// user / reasoning(先頭) / assistant_message
|
||||||
assert!(matches!(entries[1].item, Item::Reasoning { .. }));
|
assert!(matches!(history[1], Item::Reasoning { .. }));
|
||||||
assert_eq!(entries[2].item.as_text(), Some("intermediate"));
|
assert_eq!(history[2].as_text(), Some("intermediate"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Engine に
|
/// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Engine に
|
||||||
@@ -207,18 +207,14 @@ 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(
|
engine.set_history(vec![
|
||||||
&mut history,
|
Item::user_message("prior question"),
|
||||||
vec![
|
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
|
||||||
Item::user_message("prior question"),
|
Item::assistant_message("prior answer"),
|
||||||
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
|
]);
|
||||||
Item::assistant_message("prior answer"),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = engine.run(&mut history, "follow up").await;
|
let _ = engine.run("follow up").await.expect("run ok");
|
||||||
|
|
||||||
let req = captured
|
let req = captured
|
||||||
.lock()
|
.lock()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use agen::{Engine, History};
|
use agen::Engine;
|
||||||
use agen::llm_client::capability::{
|
use agen::llm_client::capability::{
|
||||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||||
};
|
};
|
||||||
@@ -22,8 +22,7 @@ fn main() {
|
|||||||
cap,
|
cap,
|
||||||
);
|
);
|
||||||
let engine = Engine::new(client);
|
let engine = Engine::new(client);
|
||||||
let history = History::new();
|
let mut locked = engine.lock();
|
||||||
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:28:20
|
--> tests/ui/locked_register_tool.rs:27:20
|
||||||
|
|
|
|
||||||
28 | let _ = locked.register_tool(def);
|
27 | 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, Mutable, A>`
|
- `Engine<C>`
|
||||||
|
|||||||
@@ -5,19 +5,19 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
async-trait.workspace = true
|
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
|
manifest = { workspace = true }
|
||||||
ticket = { workspace = true }
|
ticket = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
|
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time"] }
|
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
|
||||||
tokio-tungstenite = { workspace = true }
|
tokio-tungstenite = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
workspace-api.workspace = true
|
workspace-api.workspace = true
|
||||||
|
workdir = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -1,839 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use reqwest::{Method, StatusCode, Url, redirect};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::env;
|
|
||||||
use std::fmt;
|
|
||||||
use std::fs::{self, OpenOptions};
|
|
||||||
use std::io::Write as _;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
const TOKEN_FILE_NAME: &str = "backend-tokens.json";
|
|
||||||
const MAX_REDIRECTS: usize = 10;
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
||||||
pub struct BackendOrigin(String);
|
|
||||||
|
|
||||||
impl BackendOrigin {
|
|
||||||
pub fn parse(input: &str) -> Result<Self, BackendApiClientError> {
|
|
||||||
let url = Url::parse(input.trim()).map_err(|error| {
|
|
||||||
BackendApiClientError::InvalidBackendOrigin(format!(
|
|
||||||
"Backend URL is not a valid absolute URL: {error}"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
if !url.path().bytes().all(|byte| byte == b'/')
|
|
||||||
|| url.query().is_some()
|
|
||||||
|| url.fragment().is_some()
|
|
||||||
{
|
|
||||||
return Err(BackendApiClientError::InvalidBackendOrigin(
|
|
||||||
"Backend URL must contain only an origin, without a path, query, or fragment"
|
|
||||||
.to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Self::from_url(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_url(mut url: Url) -> Result<Self, BackendApiClientError> {
|
|
||||||
if !matches!(url.scheme(), "http" | "https") {
|
|
||||||
return Err(BackendApiClientError::InvalidBackendOrigin(
|
|
||||||
"Backend URL scheme must be http or https".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !url.username().is_empty() || url.password().is_some() {
|
|
||||||
return Err(BackendApiClientError::InvalidBackendOrigin(
|
|
||||||
"Backend URL must not contain user information".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if url.host().is_none() {
|
|
||||||
return Err(BackendApiClientError::InvalidBackendOrigin(
|
|
||||||
"Backend URL must contain a host".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let default_port = match url.scheme() {
|
|
||||||
"http" => 80,
|
|
||||||
"https" => 443,
|
|
||||||
_ => unreachable!("validated Backend URL scheme"),
|
|
||||||
};
|
|
||||||
if url.port() == Some(default_port) {
|
|
||||||
url.set_port(None).map_err(|()| {
|
|
||||||
BackendApiClientError::InvalidBackendOrigin(
|
|
||||||
"Backend URL contains an invalid port".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
url.set_path("");
|
|
||||||
url.set_query(None);
|
|
||||||
url.set_fragment(None);
|
|
||||||
let normalized = url.as_str().trim_end_matches('/').to_string();
|
|
||||||
Ok(Self(normalized))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_str(&self) -> &str {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
|
|
||||||
fn url(&self, path_and_query: &str) -> Result<Url, BackendApiClientError> {
|
|
||||||
if !path_and_query.starts_with('/') || path_and_query.starts_with("//") {
|
|
||||||
return Err(BackendApiClientError::InvalidRequestPath(
|
|
||||||
"Backend API request path must start with one `/`".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Url::parse(&format!("{}{path_and_query}", self.0)).map_err(|error| {
|
|
||||||
BackendApiClientError::InvalidRequestPath(format!(
|
|
||||||
"Backend API request path is invalid: {error}"
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for BackendOrigin {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_tuple("BackendOrigin").field(&self.0).finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for BackendOrigin {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.write_str(&self.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct BackendAccessToken(String);
|
|
||||||
|
|
||||||
impl fmt::Debug for BackendAccessToken {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.write_str("BackendAccessToken([REDACTED])")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct BackendApiClient {
|
|
||||||
origin: BackendOrigin,
|
|
||||||
access_token: BackendAccessToken,
|
|
||||||
asynchronous: reqwest::Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Debug for BackendApiClient {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.debug_struct("BackendApiClient")
|
|
||||||
.field("origin", &self.origin)
|
|
||||||
.field("access_token", &self.access_token)
|
|
||||||
.finish_non_exhaustive()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BackendApiClient {
|
|
||||||
pub fn from_stored_token(base_url: &str) -> Result<Self, BackendApiClientError> {
|
|
||||||
let path = backend_token_file_path()?;
|
|
||||||
Self::from_token_file(base_url, &path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_token_file(base_url: &str, path: &Path) -> Result<Self, BackendApiClientError> {
|
|
||||||
let origin = BackendOrigin::parse(base_url)?;
|
|
||||||
let token_file = read_token_file(path)?;
|
|
||||||
let entry = token_file.tokens.get(origin.as_str()).ok_or_else(|| {
|
|
||||||
BackendApiClientError::TokenEntryMissing {
|
|
||||||
origin: origin.clone(),
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
validate_token_entry(entry, &origin, path)?;
|
|
||||||
Self::new(origin, BackendAccessToken(entry.access_token.clone()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn new(
|
|
||||||
origin: BackendOrigin,
|
|
||||||
access_token: BackendAccessToken,
|
|
||||||
) -> Result<Self, BackendApiClientError> {
|
|
||||||
let asynchronous = reqwest::Client::builder()
|
|
||||||
.redirect(redirect_policy(origin.clone()))
|
|
||||||
.build()
|
|
||||||
.map_err(BackendApiClientError::Http)?;
|
|
||||||
Ok(Self {
|
|
||||||
origin,
|
|
||||||
access_token,
|
|
||||||
asynchronous,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn origin(&self) -> &BackendOrigin {
|
|
||||||
&self.origin
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn request(
|
|
||||||
&self,
|
|
||||||
method: Method,
|
|
||||||
path_and_query: &str,
|
|
||||||
) -> Result<reqwest::RequestBuilder, BackendApiClientError> {
|
|
||||||
let url = self.origin.url(path_and_query)?;
|
|
||||||
Ok(self
|
|
||||||
.asynchronous
|
|
||||||
.request(method, url)
|
|
||||||
.bearer_auth(&self.access_token.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn blocking_request(
|
|
||||||
&self,
|
|
||||||
method: Method,
|
|
||||||
path_and_query: &str,
|
|
||||||
) -> Result<reqwest::blocking::RequestBuilder, BackendApiClientError> {
|
|
||||||
let url = self.origin.url(path_and_query)?;
|
|
||||||
let client = reqwest::blocking::Client::builder()
|
|
||||||
.redirect(redirect_policy(self.origin.clone()))
|
|
||||||
.build()
|
|
||||||
.map_err(BackendApiClientError::Http)?;
|
|
||||||
Ok(client
|
|
||||||
.request(method, url)
|
|
||||||
.bearer_auth(&self.access_token.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn authorization_header_value(&self) -> String {
|
|
||||||
format!("Bearer {}", self.access_token.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn require_success(
|
|
||||||
&self,
|
|
||||||
response: reqwest::Response,
|
|
||||||
) -> Result<reqwest::Response, BackendApiClientError> {
|
|
||||||
let status = response.status();
|
|
||||||
match status {
|
|
||||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
|
|
||||||
self.check_status(status)?;
|
|
||||||
}
|
|
||||||
status if !status.is_success() => {
|
|
||||||
let detail = response
|
|
||||||
.bytes()
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.and_then(|body| backend_error_detail(&body));
|
|
||||||
return Err(BackendApiClientError::BackendResponse {
|
|
||||||
origin: self.origin.clone(),
|
|
||||||
status: status.as_u16(),
|
|
||||||
detail,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
Ok(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn check_status(&self, status: StatusCode) -> Result<(), BackendApiClientError> {
|
|
||||||
match status {
|
|
||||||
StatusCode::UNAUTHORIZED => Err(BackendApiClientError::Unauthorized {
|
|
||||||
origin: self.origin.clone(),
|
|
||||||
}),
|
|
||||||
StatusCode::FORBIDDEN => Err(BackendApiClientError::Forbidden {
|
|
||||||
origin: self.origin.clone(),
|
|
||||||
}),
|
|
||||||
status if !status.is_success() => Err(BackendApiClientError::BackendStatus {
|
|
||||||
origin: self.origin.clone(),
|
|
||||||
status: status.as_u16(),
|
|
||||||
}),
|
|
||||||
_ => Ok(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn from_access_token_for_test(
|
|
||||||
base_url: &str,
|
|
||||||
access_token: &str,
|
|
||||||
) -> Result<Self, BackendApiClientError> {
|
|
||||||
Self::new(
|
|
||||||
BackendOrigin::parse(base_url)?,
|
|
||||||
BackendAccessToken(access_token.to_string()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn redirect_policy(origin: BackendOrigin) -> redirect::Policy {
|
|
||||||
redirect::Policy::custom(move |attempt| {
|
|
||||||
if attempt.previous().len() >= MAX_REDIRECTS {
|
|
||||||
return attempt.error("Backend request exceeded the redirect limit");
|
|
||||||
}
|
|
||||||
match BackendOrigin::from_url(attempt.url().clone()) {
|
|
||||||
Ok(target_origin) if target_origin == origin => attempt.follow(),
|
|
||||||
Ok(target_origin) => attempt.error(format!(
|
|
||||||
"Backend request refused a cross-origin redirect from {origin} to {target_origin}"
|
|
||||||
)),
|
|
||||||
Err(error) => attempt.error(error.to_string()),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct BackendErrorBody {
|
|
||||||
message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn backend_error_detail(body: &[u8]) -> Option<String> {
|
|
||||||
serde_json::from_slice::<BackendErrorBody>(body)
|
|
||||||
.ok()
|
|
||||||
.map(|body| body.message)
|
|
||||||
.filter(|message| !message.trim().is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum BackendApiClientError {
|
|
||||||
InvalidBackendOrigin(String),
|
|
||||||
InvalidRequestPath(String),
|
|
||||||
ConfigDirectoryUnavailable,
|
|
||||||
TokenFileMissing {
|
|
||||||
path: PathBuf,
|
|
||||||
},
|
|
||||||
TokenFileMalformed {
|
|
||||||
path: PathBuf,
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
TokenEntryMissing {
|
|
||||||
origin: BackendOrigin,
|
|
||||||
path: PathBuf,
|
|
||||||
},
|
|
||||||
TokenExpired {
|
|
||||||
origin: BackendOrigin,
|
|
||||||
expired_at: String,
|
|
||||||
},
|
|
||||||
Http(reqwest::Error),
|
|
||||||
Unauthorized {
|
|
||||||
origin: BackendOrigin,
|
|
||||||
},
|
|
||||||
Forbidden {
|
|
||||||
origin: BackendOrigin,
|
|
||||||
},
|
|
||||||
BackendStatus {
|
|
||||||
origin: BackendOrigin,
|
|
||||||
status: u16,
|
|
||||||
},
|
|
||||||
BackendResponse {
|
|
||||||
origin: BackendOrigin,
|
|
||||||
status: u16,
|
|
||||||
detail: Option<String>,
|
|
||||||
},
|
|
||||||
Io {
|
|
||||||
path: PathBuf,
|
|
||||||
source: std::io::Error,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for BackendApiClientError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::InvalidBackendOrigin(message) | Self::InvalidRequestPath(message) => {
|
|
||||||
f.write_str(message)
|
|
||||||
}
|
|
||||||
Self::ConfigDirectoryUnavailable => f.write_str(
|
|
||||||
"cannot locate the client configuration directory for backend-tokens.json",
|
|
||||||
),
|
|
||||||
Self::TokenFileMissing { path } => write!(
|
|
||||||
f,
|
|
||||||
"Backend token file {} is missing; run `yoi login --backend <BACKEND>` first",
|
|
||||||
path.display()
|
|
||||||
),
|
|
||||||
Self::TokenFileMalformed { path, message } => write!(
|
|
||||||
f,
|
|
||||||
"Backend token file {} is malformed: {message}; run `yoi login --backend <BACKEND>` again",
|
|
||||||
path.display()
|
|
||||||
),
|
|
||||||
Self::TokenEntryMissing { origin, path } => write!(
|
|
||||||
f,
|
|
||||||
"no Backend token for {origin} exists in {}; login URLs are matched by normalized origin, so run `yoi login --backend {origin}`",
|
|
||||||
path.display()
|
|
||||||
),
|
|
||||||
Self::TokenExpired { origin, expired_at } => write!(
|
|
||||||
f,
|
|
||||||
"Backend token for {origin} expired at {expired_at}; run `yoi login --backend {origin}` again"
|
|
||||||
),
|
|
||||||
Self::Http(error) => write!(f, "Backend request failed: {error}"),
|
|
||||||
Self::Unauthorized { origin } => write!(
|
|
||||||
f,
|
|
||||||
"Backend {origin} returned HTTP 401 for the saved token; it may be expired or revoked, so run `yoi login --backend {origin}` again"
|
|
||||||
),
|
|
||||||
Self::Forbidden { origin } => write!(
|
|
||||||
f,
|
|
||||||
"Backend {origin} returned HTTP 403; the saved token is authenticated but is not authorized for this operation"
|
|
||||||
),
|
|
||||||
Self::BackendStatus { origin, status } => {
|
|
||||||
write!(f, "Backend {origin} returned HTTP {status}")
|
|
||||||
}
|
|
||||||
Self::BackendResponse {
|
|
||||||
origin,
|
|
||||||
status,
|
|
||||||
detail,
|
|
||||||
} => {
|
|
||||||
write!(f, "Backend {origin} returned HTTP {status}")?;
|
|
||||||
if let Some(detail) = detail {
|
|
||||||
write!(f, ": {detail}")?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Self::Io { path, source } => {
|
|
||||||
write!(f, "failed to access {}: {source}", path.display())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for BackendApiClientError {
|
|
||||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
||||||
match self {
|
|
||||||
Self::Http(error) => Some(error),
|
|
||||||
Self::Io { source, .. } => Some(source),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct BackendTokenFile {
|
|
||||||
tokens: BTreeMap<String, BackendTokenEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct BackendTokenEntry {
|
|
||||||
token_type: String,
|
|
||||||
access_token: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
expires_at: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn save_backend_token(
|
|
||||||
base_url: &str,
|
|
||||||
token_type: &str,
|
|
||||||
access_token: &str,
|
|
||||||
) -> Result<PathBuf, BackendApiClientError> {
|
|
||||||
save_backend_token_with_expiry(base_url, token_type, access_token, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_backend_token_with_expiry(
|
|
||||||
base_url: &str,
|
|
||||||
token_type: &str,
|
|
||||||
access_token: &str,
|
|
||||||
expires_at: Option<String>,
|
|
||||||
) -> Result<PathBuf, BackendApiClientError> {
|
|
||||||
let path = backend_token_file_path()?;
|
|
||||||
save_backend_token_to_file(base_url, token_type, access_token, expires_at, &path)?;
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_backend_token_to_file(
|
|
||||||
base_url: &str,
|
|
||||||
token_type: &str,
|
|
||||||
access_token: &str,
|
|
||||||
expires_at: Option<String>,
|
|
||||||
path: &Path,
|
|
||||||
) -> Result<(), BackendApiClientError> {
|
|
||||||
let origin = BackendOrigin::parse(base_url)?;
|
|
||||||
let mut token_file = if path.exists() {
|
|
||||||
read_token_file(&path)?
|
|
||||||
} else {
|
|
||||||
BackendTokenFile {
|
|
||||||
tokens: BTreeMap::new(),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let entry = BackendTokenEntry {
|
|
||||||
token_type: token_type.to_string(),
|
|
||||||
access_token: access_token.to_string(),
|
|
||||||
expires_at,
|
|
||||||
};
|
|
||||||
validate_token_entry(&entry, &origin, path)?;
|
|
||||||
token_file.tokens.insert(origin.to_string(), entry);
|
|
||||||
write_token_file(path, &token_file)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn backend_token_file_path() -> Result<PathBuf, BackendApiClientError> {
|
|
||||||
if let Some(config_home) = env::var_os("XDG_CONFIG_HOME") {
|
|
||||||
return Ok(PathBuf::from(config_home).join("yoi").join(TOKEN_FILE_NAME));
|
|
||||||
}
|
|
||||||
let Some(home) = env::var_os("HOME") else {
|
|
||||||
return Err(BackendApiClientError::ConfigDirectoryUnavailable);
|
|
||||||
};
|
|
||||||
Ok(PathBuf::from(home)
|
|
||||||
.join(".config")
|
|
||||||
.join("yoi")
|
|
||||||
.join(TOKEN_FILE_NAME))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_token_file(path: &Path) -> Result<BackendTokenFile, BackendApiClientError> {
|
|
||||||
let bytes = fs::read(path).map_err(|source| {
|
|
||||||
if source.kind() == std::io::ErrorKind::NotFound {
|
|
||||||
BackendApiClientError::TokenFileMissing {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
BackendApiClientError::Io {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
source,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let raw: BackendTokenFile = serde_json::from_slice(&bytes).map_err(|error| {
|
|
||||||
BackendApiClientError::TokenFileMalformed {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
message: error.to_string(),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
normalize_token_file(raw, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_token_file(
|
|
||||||
token_file: BackendTokenFile,
|
|
||||||
path: &Path,
|
|
||||||
) -> Result<BackendTokenFile, BackendApiClientError> {
|
|
||||||
let mut normalized = BTreeMap::new();
|
|
||||||
for (raw_origin, entry) in token_file.tokens {
|
|
||||||
let origin = BackendOrigin::parse(&raw_origin).map_err(|error| {
|
|
||||||
BackendApiClientError::TokenFileMalformed {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
message: format!("token key `{raw_origin}` is invalid: {error}"),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
if normalized.insert(origin.to_string(), entry).is_some() {
|
|
||||||
return Err(BackendApiClientError::TokenFileMalformed {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
message: format!("more than one token entry normalizes to `{origin}`"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(BackendTokenFile { tokens: normalized })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_token_entry(
|
|
||||||
entry: &BackendTokenEntry,
|
|
||||||
origin: &BackendOrigin,
|
|
||||||
path: &Path,
|
|
||||||
) -> Result<(), BackendApiClientError> {
|
|
||||||
if !entry.token_type.eq_ignore_ascii_case("Bearer") {
|
|
||||||
return Err(BackendApiClientError::TokenFileMalformed {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
message: format!("token for `{origin}` does not use the Bearer token type"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if entry.access_token.trim().is_empty()
|
|
||||||
|| entry.access_token.contains('\r')
|
|
||||||
|| entry.access_token.contains('\n')
|
|
||||||
{
|
|
||||||
return Err(BackendApiClientError::TokenFileMalformed {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
message: format!("token for `{origin}` is empty or contains an invalid line break"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if reqwest::header::HeaderValue::from_str(&format!("Bearer {}", entry.access_token)).is_err() {
|
|
||||||
return Err(BackendApiClientError::TokenFileMalformed {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
message: format!("token for `{origin}` cannot be represented as an HTTP header"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Some(expires_at) = entry.expires_at.as_deref() {
|
|
||||||
let expiration = DateTime::parse_from_rfc3339(expires_at).map_err(|error| {
|
|
||||||
BackendApiClientError::TokenFileMalformed {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
message: format!("token for `{origin}` has invalid expires_at: {error}"),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
if expiration <= Utc::now() {
|
|
||||||
return Err(BackendApiClientError::TokenExpired {
|
|
||||||
origin: origin.clone(),
|
|
||||||
expired_at: expires_at.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_token_file(
|
|
||||||
path: &Path,
|
|
||||||
token_file: &BackendTokenFile,
|
|
||||||
) -> Result<(), BackendApiClientError> {
|
|
||||||
let parent = path
|
|
||||||
.parent()
|
|
||||||
.ok_or(BackendApiClientError::ConfigDirectoryUnavailable)?;
|
|
||||||
fs::create_dir_all(parent).map_err(|source| BackendApiClientError::Io {
|
|
||||||
path: parent.to_path_buf(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
let payload = serde_json::to_vec_pretty(token_file).map_err(|error| {
|
|
||||||
BackendApiClientError::TokenFileMalformed {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
message: error.to_string(),
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let temp_path = parent.join(format!(".{TOKEN_FILE_NAME}.tmp-{}", std::process::id()));
|
|
||||||
let mut options = OpenOptions::new();
|
|
||||||
options.write(true).create(true).truncate(true);
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
|
||||||
options.mode(0o600);
|
|
||||||
}
|
|
||||||
let mut file = options
|
|
||||||
.open(&temp_path)
|
|
||||||
.map_err(|source| BackendApiClientError::Io {
|
|
||||||
path: temp_path.clone(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
file.write_all(&payload)
|
|
||||||
.and_then(|()| file.write_all(b"\n"))
|
|
||||||
.and_then(|()| file.sync_all())
|
|
||||||
.map_err(|source| BackendApiClientError::Io {
|
|
||||||
path: temp_path.clone(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
fs::rename(&temp_path, path).map_err(|source| BackendApiClientError::Io {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::net::TcpListener;
|
|
||||||
use std::thread;
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
fn temp_path(label: &str) -> PathBuf {
|
|
||||||
let nonce = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_nanos();
|
|
||||||
env::temp_dir().join(format!(
|
|
||||||
"yoi-client-{label}-{}-{nonce}.json",
|
|
||||||
std::process::id()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_fixture(path: &Path, value: serde_json::Value) {
|
|
||||||
fs::write(path, serde_json::to_vec(&value).unwrap()).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_origin_normalizes_safe_equivalents() {
|
|
||||||
let variants = [
|
|
||||||
"HTTP://Example.COM",
|
|
||||||
"http://example.com/",
|
|
||||||
"http://example.com:80////",
|
|
||||||
];
|
|
||||||
for variant in variants {
|
|
||||||
assert_eq!(
|
|
||||||
BackendOrigin::parse(variant).unwrap().as_str(),
|
|
||||||
"http://example.com"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
BackendOrigin::parse("https://EXAMPLE.com:443/")
|
|
||||||
.unwrap()
|
|
||||||
.as_str(),
|
|
||||||
"https://example.com"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
BackendOrigin::parse("https://example.com:8443/")
|
|
||||||
.unwrap()
|
|
||||||
.as_str(),
|
|
||||||
"https://example.com:8443"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_error_detail_preserves_public_server_message() {
|
|
||||||
let detail = backend_error_detail(
|
|
||||||
br#"{"error":"Bad Request","message":"working_directory_runtime_mismatch: Working directory is owned by a different Runtime","diagnostics":[{"code":"working_directory_runtime_mismatch"}]}"#,
|
|
||||||
);
|
|
||||||
let error = BackendApiClientError::BackendResponse {
|
|
||||||
origin: BackendOrigin::parse("http://127.0.0.1:8787").unwrap(),
|
|
||||||
status: 400,
|
|
||||||
detail,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
error.to_string(),
|
|
||||||
"Backend http://127.0.0.1:8787 returned HTTP 400: working_directory_runtime_mismatch: Working directory is owned by a different Runtime"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_origin_rejects_unsafe_authority_changes() {
|
|
||||||
for invalid in [
|
|
||||||
"ftp://example.com",
|
|
||||||
"https://user@example.com",
|
|
||||||
"https://example.com/api",
|
|
||||||
"https://example.com/?query=1",
|
|
||||||
"https://example.com/#fragment",
|
|
||||||
] {
|
|
||||||
assert!(BackendOrigin::parse(invalid).is_err(), "accepted {invalid}");
|
|
||||||
}
|
|
||||||
assert_ne!(
|
|
||||||
BackendOrigin::parse("http://localhost:8787").unwrap(),
|
|
||||||
BackendOrigin::parse("http://127.0.0.1:8787").unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn token_lookup_distinguishes_missing_malformed_mismatch_and_expired() {
|
|
||||||
let missing = temp_path("missing");
|
|
||||||
assert!(matches!(
|
|
||||||
BackendApiClient::from_token_file("http://localhost:8787", &missing),
|
|
||||||
Err(BackendApiClientError::TokenFileMissing { .. })
|
|
||||||
));
|
|
||||||
|
|
||||||
let malformed = temp_path("malformed");
|
|
||||||
fs::write(&malformed, b"not json").unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
BackendApiClient::from_token_file("http://localhost:8787", &malformed),
|
|
||||||
Err(BackendApiClientError::TokenFileMalformed { .. })
|
|
||||||
));
|
|
||||||
|
|
||||||
let mismatch = temp_path("mismatch");
|
|
||||||
write_fixture(
|
|
||||||
&mismatch,
|
|
||||||
serde_json::json!({"tokens": {"http://localhost:8787": {
|
|
||||||
"token_type": "Bearer", "access_token": "secret"
|
|
||||||
}}}),
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
BackendApiClient::from_token_file("http://127.0.0.1:8787", &mismatch),
|
|
||||||
Err(BackendApiClientError::TokenEntryMissing { .. })
|
|
||||||
));
|
|
||||||
|
|
||||||
let expired = temp_path("expired");
|
|
||||||
write_fixture(
|
|
||||||
&expired,
|
|
||||||
serde_json::json!({"tokens": {"http://localhost:8787": {
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"access_token": "secret",
|
|
||||||
"expires_at": "2000-01-01T00:00:00Z"
|
|
||||||
}}}),
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
BackendApiClient::from_token_file("http://localhost:8787", &expired),
|
|
||||||
Err(BackendApiClientError::TokenExpired { .. })
|
|
||||||
));
|
|
||||||
|
|
||||||
for path in [malformed, mismatch, expired] {
|
|
||||||
let _ = fs::remove_file(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn token_write_and_lookup_share_origin_normalization() {
|
|
||||||
let path = temp_path("normalized-write");
|
|
||||||
save_backend_token_to_file(
|
|
||||||
"HTTP://Example.COM:80////",
|
|
||||||
"Bearer",
|
|
||||||
"normalized-secret",
|
|
||||||
None,
|
|
||||||
&path,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let contents = fs::read_to_string(&path).unwrap();
|
|
||||||
assert!(contents.contains("\"http://example.com\""));
|
|
||||||
let client = BackendApiClient::from_token_file("http://example.com/", &path).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
client.authorization_header_value(),
|
|
||||||
"Bearer normalized-secret"
|
|
||||||
);
|
|
||||||
fs::remove_file(path).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn client_debug_and_errors_never_include_token_value() {
|
|
||||||
let client = BackendApiClient::from_access_token_for_test(
|
|
||||||
"http://localhost:8787",
|
|
||||||
"never-print-this-token",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(!format!("{client:?}").contains("never-print-this-token"));
|
|
||||||
assert!(
|
|
||||||
!BackendApiClientError::Unauthorized {
|
|
||||||
origin: client.origin().clone()
|
|
||||||
}
|
|
||||||
.to_string()
|
|
||||||
.contains("never-print-this-token")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn authenticated_requests_follow_only_same_origin_redirects() {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
||||||
let origin = format!("http://{}", listener.local_addr().unwrap());
|
|
||||||
let handle = thread::spawn(move || {
|
|
||||||
for response in [
|
|
||||||
"HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
|
||||||
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
|
||||||
] {
|
|
||||||
let (mut stream, _) = listener.accept().unwrap();
|
|
||||||
let mut request = vec![0; 4096];
|
|
||||||
let read = stream.read(&mut request).unwrap();
|
|
||||||
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
|
||||||
assert!(request.contains("authorization: bearer redirect-secret\r\n"));
|
|
||||||
stream.write_all(response.as_bytes()).unwrap();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let client =
|
|
||||||
BackendApiClient::from_access_token_for_test(&origin, "redirect-secret").unwrap();
|
|
||||||
let response = client
|
|
||||||
.blocking_request(Method::GET, "/start")
|
|
||||||
.unwrap()
|
|
||||||
.send()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn authenticated_requests_reject_cross_origin_redirects_without_leaking_token() {
|
|
||||||
let source = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
||||||
let target = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
||||||
target.set_nonblocking(true).unwrap();
|
|
||||||
let source_origin = format!("http://{}", source.local_addr().unwrap());
|
|
||||||
let target_origin = format!("http://{}", target.local_addr().unwrap());
|
|
||||||
let location = format!("{target_origin}/capture");
|
|
||||||
let handle = thread::spawn(move || {
|
|
||||||
let (mut stream, _) = source.accept().unwrap();
|
|
||||||
let mut request = vec![0; 4096];
|
|
||||||
let read = stream.read(&mut request).unwrap();
|
|
||||||
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
|
||||||
assert!(request.contains("authorization: bearer redirect-secret\r\n"));
|
|
||||||
let response = format!(
|
|
||||||
"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
|
||||||
);
|
|
||||||
stream.write_all(response.as_bytes()).unwrap();
|
|
||||||
});
|
|
||||||
let client =
|
|
||||||
BackendApiClient::from_access_token_for_test(&source_origin, "redirect-secret")
|
|
||||||
.unwrap();
|
|
||||||
let error = client
|
|
||||||
.blocking_request(Method::GET, "/start")
|
|
||||||
.unwrap()
|
|
||||||
.send()
|
|
||||||
.unwrap_err();
|
|
||||||
let message = error.to_string();
|
|
||||||
assert!(message.contains("redirect"));
|
|
||||||
assert!(!message.contains("redirect-secret"));
|
|
||||||
handle.join().unwrap();
|
|
||||||
thread::sleep(Duration::from_millis(20));
|
|
||||||
assert!(matches!(
|
|
||||||
target.accept(),
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn status_diagnostics_distinguish_unauthorized_and_forbidden() {
|
|
||||||
let client =
|
|
||||||
BackendApiClient::from_access_token_for_test("http://localhost:8787", "secret")
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
client.check_status(StatusCode::UNAUTHORIZED),
|
|
||||||
Err(BackendApiClientError::Unauthorized { .. })
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
client.check_status(StatusCode::FORBIDDEN),
|
|
||||||
Err(BackendApiClientError::Forbidden { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
use crate::BackendOrigin;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde::Deserialize;
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use workspace_api::{DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest};
|
|
||||||
pub use workspace_api::{DeviceLoginPollResponse, DeviceLoginStartResponse};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct BackendAuthTarget {
|
pub struct BackendAuthTarget {
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
@@ -13,11 +9,9 @@ pub struct BackendAuthTarget {
|
|||||||
|
|
||||||
impl BackendAuthTarget {
|
impl BackendAuthTarget {
|
||||||
pub fn new(base_url: impl Into<String>) -> Self {
|
pub fn new(base_url: impl Into<String>) -> Self {
|
||||||
let base_url = base_url.into();
|
Self {
|
||||||
let base_url = BackendOrigin::parse(&base_url)
|
base_url: base_url.into(),
|
||||||
.map(|origin| origin.to_string())
|
}
|
||||||
.unwrap_or(base_url);
|
|
||||||
Self { base_url }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_url(&self, path: &str) -> String {
|
fn api_url(&self, path: &str) -> String {
|
||||||
@@ -31,6 +25,23 @@ impl BackendAuthTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct DeviceLoginStartResponse {
|
||||||
|
pub device_code: String,
|
||||||
|
pub user_code: String,
|
||||||
|
pub verification_uri: String,
|
||||||
|
pub verification_uri_complete: String,
|
||||||
|
pub expires_in: u64,
|
||||||
|
pub interval: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct DeviceLoginPollResponse {
|
||||||
|
pub status: String,
|
||||||
|
pub access_token: Option<String>,
|
||||||
|
pub token_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BackendAuthClientError {
|
pub enum BackendAuthClientError {
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
@@ -60,6 +71,16 @@ impl From<reqwest::Error> for BackendAuthClientError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct DeviceLoginStartRequest<'a> {
|
||||||
|
client_name: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct DeviceLoginPollRequest<'a> {
|
||||||
|
device_code: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn start_device_login(
|
pub async fn start_device_login(
|
||||||
target: &BackendAuthTarget,
|
target: &BackendAuthTarget,
|
||||||
client_name: Option<&str>,
|
client_name: Option<&str>,
|
||||||
@@ -67,9 +88,7 @@ pub async fn start_device_login(
|
|||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let response = client
|
let response = client
|
||||||
.post(target.api_url("/api/auth/device-login/start"))
|
.post(target.api_url("/api/auth/device-login/start"))
|
||||||
.json(&DeviceLoginStartRequest {
|
.json(&DeviceLoginStartRequest { client_name })
|
||||||
client_name: client_name.map(ToOwned::to_owned),
|
|
||||||
})
|
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
parse_json_response(response).await
|
parse_json_response(response).await
|
||||||
@@ -82,38 +101,12 @@ pub async fn poll_device_login(
|
|||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let response = client
|
let response = client
|
||||||
.post(target.api_url("/api/auth/device-login/poll"))
|
.post(target.api_url("/api/auth/device-login/poll"))
|
||||||
.json(&DeviceLoginPollRequest {
|
.json(&DeviceLoginPollRequest { device_code })
|
||||||
device_code: device_code.to_string(),
|
|
||||||
})
|
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
parse_json_response(response).await
|
parse_json_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn device_login_poll_result(
|
|
||||||
response: DeviceLoginPollResponse,
|
|
||||||
) -> Result<Option<String>, BackendAuthClientError> {
|
|
||||||
match response.status {
|
|
||||||
DeviceLoginPollStatus::Approved => response
|
|
||||||
.access_token
|
|
||||||
.ok_or(BackendAuthClientError::MissingAccessToken)
|
|
||||||
.map(Some),
|
|
||||||
DeviceLoginPollStatus::Expired => Err(BackendAuthClientError::BackendStatus {
|
|
||||||
status: 410,
|
|
||||||
body: "device login expired".to_string(),
|
|
||||||
}),
|
|
||||||
DeviceLoginPollStatus::Denied => Err(BackendAuthClientError::BackendStatus {
|
|
||||||
status: 403,
|
|
||||||
body: "device login was denied".to_string(),
|
|
||||||
}),
|
|
||||||
DeviceLoginPollStatus::Consumed => Err(BackendAuthClientError::BackendStatus {
|
|
||||||
status: 409,
|
|
||||||
body: "device login was already consumed".to_string(),
|
|
||||||
}),
|
|
||||||
DeviceLoginPollStatus::Pending => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn wait_for_device_login(
|
pub async fn wait_for_device_login(
|
||||||
target: &BackendAuthTarget,
|
target: &BackendAuthTarget,
|
||||||
device_code: &str,
|
device_code: &str,
|
||||||
@@ -123,8 +116,25 @@ pub async fn wait_for_device_login(
|
|||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
loop {
|
loop {
|
||||||
let response = poll_device_login(target, device_code).await?;
|
let response = poll_device_login(target, device_code).await?;
|
||||||
if let Some(access_token) = device_login_poll_result(response)? {
|
match response.status.as_str() {
|
||||||
return Ok(access_token);
|
"approved" => {
|
||||||
|
return response
|
||||||
|
.access_token
|
||||||
|
.ok_or(BackendAuthClientError::MissingAccessToken);
|
||||||
|
}
|
||||||
|
"expired" => {
|
||||||
|
return Err(BackendAuthClientError::BackendStatus {
|
||||||
|
status: 410,
|
||||||
|
body: "device login expired".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"consumed" => {
|
||||||
|
return Err(BackendAuthClientError::BackendStatus {
|
||||||
|
status: 409,
|
||||||
|
body: "device login was already consumed".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
if started.elapsed() >= expires_in {
|
if started.elapsed() >= expires_in {
|
||||||
return Err(BackendAuthClientError::BackendStatus {
|
return Err(BackendAuthClientError::BackendStatus {
|
||||||
@@ -149,81 +159,3 @@ async fn parse_json_response<T: for<'de> Deserialize<'de>>(
|
|||||||
}
|
}
|
||||||
Ok(response.json::<T>().await?)
|
Ok(response.json::<T>().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use workspace_api::DeviceAccessTokenType;
|
|
||||||
|
|
||||||
fn poll_response(status: DeviceLoginPollStatus) -> DeviceLoginPollResponse {
|
|
||||||
DeviceLoginPollResponse {
|
|
||||||
status,
|
|
||||||
access_token: None,
|
|
||||||
token_type: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn device_login_start_response_enforces_shared_expiry_bounds() {
|
|
||||||
let valid = serde_json::json!({
|
|
||||||
"device_code": "device-secret",
|
|
||||||
"user_code": "ABCD-EFGH",
|
|
||||||
"verification_uri": "https://yoi.example/login/device",
|
|
||||||
"verification_uri_complete": "https://yoi.example/login/device?user_code=ABCD-EFGH",
|
|
||||||
"expires_in": 600,
|
|
||||||
"interval": 5
|
|
||||||
});
|
|
||||||
assert!(serde_json::from_value::<DeviceLoginStartResponse>(valid.clone()).is_ok());
|
|
||||||
|
|
||||||
let mut expired = valid;
|
|
||||||
expired["expires_in"] = serde_json::json!(0);
|
|
||||||
assert!(serde_json::from_value::<DeviceLoginStartResponse>(expired).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn device_login_poll_response_rejects_unknown_status() {
|
|
||||||
assert!(
|
|
||||||
serde_json::from_value::<DeviceLoginPollResponse>(
|
|
||||||
serde_json::json!({"status": "future_status"}),
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn device_login_poll_result_handles_pending_and_terminal_states() {
|
|
||||||
assert!(
|
|
||||||
device_login_poll_result(poll_response(DeviceLoginPollStatus::Pending))
|
|
||||||
.unwrap()
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
|
|
||||||
let approved = DeviceLoginPollResponse {
|
|
||||||
status: DeviceLoginPollStatus::Approved,
|
|
||||||
access_token: Some("access-secret".to_string()),
|
|
||||||
token_type: Some(DeviceAccessTokenType::Bearer),
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
device_login_poll_result(approved).unwrap(),
|
|
||||||
Some("access-secret".to_string())
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
device_login_poll_result(poll_response(DeviceLoginPollStatus::Approved)),
|
|
||||||
Err(BackendAuthClientError::MissingAccessToken)
|
|
||||||
));
|
|
||||||
|
|
||||||
for (status, expected_http_status) in [
|
|
||||||
(DeviceLoginPollStatus::Expired, 410),
|
|
||||||
(DeviceLoginPollStatus::Denied, 403),
|
|
||||||
(DeviceLoginPollStatus::Consumed, 409),
|
|
||||||
] {
|
|
||||||
assert!(matches!(
|
|
||||||
device_login_poll_result(poll_response(status)),
|
|
||||||
Err(BackendAuthClientError::BackendStatus {
|
|
||||||
status,
|
|
||||||
..
|
|
||||||
}) if status == expected_http_status
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,30 +1,20 @@
|
|||||||
use crate::transport::websocket::{Socket as WebSocket, SocketError as WebSocketError};
|
use futures::{SinkExt, StreamExt};
|
||||||
use crate::{BackendApiClient, BackendApiClientError, Client};
|
use protocol::stream::{decode_event, encode_method};
|
||||||
use reqwest::Method as HttpMethod;
|
use protocol::{ErrorCode, Event, Method};
|
||||||
use serde::Deserialize;
|
use std::collections::VecDeque;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
use tokio::sync::mpsc;
|
||||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
use tokio_tungstenite::connect_async;
|
||||||
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
|
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||||
|
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
|
||||||
pub use workspace_api::{
|
pub use workspace_api::{
|
||||||
BrowserCreateWorkerResponse as BackendCreateWorkerResponse,
|
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
|
||||||
CreateWorkspaceWorkerRequest as BackendCreateWorkerRequest, Diagnostic as BackendDiagnostic,
|
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary,
|
||||||
DiagnosticSeverity as BackendDiagnosticSeverity, ListResponse as BackendRuntimeListResponse,
|
|
||||||
RuntimeSummary as BackendRuntimeSummary,
|
|
||||||
WorkerCapabilitySummary as BackendWorkerCapabilitySummary,
|
WorkerCapabilitySummary as BackendWorkerCapabilitySummary,
|
||||||
WorkerImplementationSummary as BackendWorkerImplementationSummary,
|
WorkerImplementationSummary as BackendWorkerImplementationSummary,
|
||||||
WorkerLaunchOptionsResponse as BackendWorkerLaunchOptions,
|
|
||||||
WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate,
|
|
||||||
WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption,
|
|
||||||
WorkerOperationState as BackendWorkerOperationState,
|
|
||||||
WorkerRestoreResponse as BackendWorkerRestoreResponse,
|
WorkerRestoreResponse as BackendWorkerRestoreResponse,
|
||||||
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
|
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
|
||||||
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
|
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
|
||||||
WorkingDirectoryCreateRequest as BackendWorkingDirectoryCreateRequest,
|
|
||||||
WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse,
|
|
||||||
WorkingDirectoryDetailResponse as BackendWorkingDirectoryDetailResponse,
|
|
||||||
WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse,
|
|
||||||
WorkingDirectorySummary as BackendWorkingDirectorySummary,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -58,164 +48,6 @@ impl BackendRuntimeTarget {
|
|||||||
pub fn display_label(&self) -> String {
|
pub fn display_label(&self) -> String {
|
||||||
format!("{}:{}", self.runtime_id, self.worker_id)
|
format!("{}:{}", self.runtime_id, self.worker_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn upload_file(
|
|
||||||
&self,
|
|
||||||
file_name: &str,
|
|
||||||
media_type: &str,
|
|
||||||
content: Vec<u8>,
|
|
||||||
) -> Result<protocol::UploadedFileRef, BackendRuntimeClientError> {
|
|
||||||
self.upload_file_with_id(
|
|
||||||
&uuid::Uuid::now_v7().to_string(),
|
|
||||||
file_name,
|
|
||||||
media_type,
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn upload_file_with_id(
|
|
||||||
&self,
|
|
||||||
upload_id: &str,
|
|
||||||
file_name: &str,
|
|
||||||
media_type: &str,
|
|
||||||
content: Vec<u8>,
|
|
||||||
) -> Result<protocol::UploadedFileRef, BackendRuntimeClientError> {
|
|
||||||
let api = BackendApiClient::from_stored_token(&self.base_url)?;
|
|
||||||
let worker_path = format!(
|
|
||||||
"/api/w/{}/runtimes/{}/workers/{}",
|
|
||||||
path_segment_encode(&self.workspace_id),
|
|
||||||
path_segment_encode(&self.runtime_id),
|
|
||||||
path_segment_encode(&self.worker_id),
|
|
||||||
);
|
|
||||||
let grant_path = format!(
|
|
||||||
"{worker_path}/attachment-upload-grants?file_name={}&media_type={}&upload_id={}",
|
|
||||||
path_segment_encode(file_name),
|
|
||||||
path_segment_encode(media_type),
|
|
||||||
path_segment_encode(&upload_id),
|
|
||||||
);
|
|
||||||
let grant_response = api
|
|
||||||
.request(HttpMethod::POST, &grant_path)?
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(BackendRuntimeClientError::Http)?;
|
|
||||||
api.check_status(grant_response.status())?;
|
|
||||||
let grant = grant_response
|
|
||||||
.json::<AttachmentUploadGrantResponse>()
|
|
||||||
.await
|
|
||||||
.map_err(BackendRuntimeClientError::Http)?;
|
|
||||||
let upload_path = format!(
|
|
||||||
"{worker_path}/attachment-uploads/{}",
|
|
||||||
path_segment_encode(&grant.upload_id),
|
|
||||||
);
|
|
||||||
let response = api
|
|
||||||
.request(HttpMethod::PUT, &upload_path)?
|
|
||||||
.body(content)
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(BackendRuntimeClientError::Http)?;
|
|
||||||
api.check_status(response.status())?;
|
|
||||||
response
|
|
||||||
.json::<UploadedFileResponse>()
|
|
||||||
.await
|
|
||||||
.map(|response| response.file)
|
|
||||||
.map_err(BackendRuntimeClientError::Http)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cancel_file_upload(
|
|
||||||
&self,
|
|
||||||
upload_id: &str,
|
|
||||||
) -> Result<(), BackendRuntimeClientError> {
|
|
||||||
let api = BackendApiClient::from_stored_token(&self.base_url)?;
|
|
||||||
let path = format!(
|
|
||||||
"/api/w/{}/runtimes/{}/workers/{}/attachment-uploads/{}",
|
|
||||||
path_segment_encode(&self.workspace_id),
|
|
||||||
path_segment_encode(&self.runtime_id),
|
|
||||||
path_segment_encode(&self.worker_id),
|
|
||||||
path_segment_encode(upload_id),
|
|
||||||
);
|
|
||||||
let response = api
|
|
||||||
.request(HttpMethod::DELETE, &path)?
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(BackendRuntimeClientError::Http)?;
|
|
||||||
api.check_status(response.status())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_uploaded_file(
|
|
||||||
&self,
|
|
||||||
artifact_id: &str,
|
|
||||||
) -> Result<(), BackendRuntimeClientError> {
|
|
||||||
let api = BackendApiClient::from_stored_token(&self.base_url)?;
|
|
||||||
let path = format!(
|
|
||||||
"/api/w/{}/runtimes/{}/workers/{}/attachments/{}",
|
|
||||||
path_segment_encode(&self.workspace_id),
|
|
||||||
path_segment_encode(&self.runtime_id),
|
|
||||||
path_segment_encode(&self.worker_id),
|
|
||||||
path_segment_encode(artifact_id),
|
|
||||||
);
|
|
||||||
let response = api
|
|
||||||
.request(HttpMethod::DELETE, &path)?
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(BackendRuntimeClientError::Http)?;
|
|
||||||
api.check_status(response.status())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct AttachmentUploadGrantResponse {
|
|
||||||
upload_id: String,
|
|
||||||
#[allow(dead_code)]
|
|
||||||
expires_at_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct UploadedFileResponse {
|
|
||||||
file: protocol::UploadedFileRef,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct BackendWorkerLaunchTarget {
|
|
||||||
pub base_url: String,
|
|
||||||
pub workspace_id: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BackendWorkerLaunchTarget {
|
|
||||||
pub fn new(base_url: impl Into<String>, workspace_id: Option<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
base_url: base_url.into(),
|
|
||||||
workspace_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select_workspace(&mut self, workspace_id: impl Into<String>) {
|
|
||||||
self.workspace_id = Some(workspace_id.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn workspace_id(&self) -> Option<&str> {
|
|
||||||
self.workspace_id.as_deref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn runtime_target(
|
|
||||||
&self,
|
|
||||||
runtime_id: impl Into<String>,
|
|
||||||
worker_id: impl Into<String>,
|
|
||||||
) -> Result<BackendRuntimeTarget, BackendRuntimeClientError> {
|
|
||||||
let workspace_id = self.workspace_id.clone().ok_or_else(|| {
|
|
||||||
BackendRuntimeClientError::InvalidTarget(
|
|
||||||
"workspace_id is required before creating a Backend worker".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(BackendRuntimeTarget::new(
|
|
||||||
self.base_url.clone(),
|
|
||||||
workspace_id,
|
|
||||||
runtime_id,
|
|
||||||
worker_id,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -269,96 +101,43 @@ impl BackendRuntimeListTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BackendRuntimeClient {
|
||||||
|
target: BackendRuntimeTarget,
|
||||||
|
command_tx: mpsc::UnboundedSender<Method>,
|
||||||
|
events: mpsc::UnboundedReceiver<Event>,
|
||||||
|
diagnostics: VecDeque<Event>,
|
||||||
|
_protocol_task: tokio::task::JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BackendRuntimeClientError {
|
pub enum BackendRuntimeClientError {
|
||||||
InvalidTarget(String),
|
InvalidTarget(String),
|
||||||
Api(BackendApiClientError),
|
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
Protocol(String),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for BackendRuntimeClientError {
|
impl fmt::Display for BackendRuntimeClientError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::InvalidTarget(message) => f.write_str(message),
|
Self::InvalidTarget(message) => f.write_str(message),
|
||||||
Self::Api(error) => write!(f, "{error}"),
|
|
||||||
Self::Http(error) => write!(f, "{error}"),
|
Self::Http(error) => write!(f, "{error}"),
|
||||||
Self::Protocol(message) => f.write_str(message),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for BackendRuntimeClientError {}
|
impl std::error::Error for BackendRuntimeClientError {}
|
||||||
|
|
||||||
impl From<BackendApiClientError> for BackendRuntimeClientError {
|
|
||||||
fn from(error: BackendApiClientError) -> Self {
|
|
||||||
Self::Api(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<reqwest::Error> for BackendRuntimeClientError {
|
impl From<reqwest::Error> for BackendRuntimeClientError {
|
||||||
fn from(error: reqwest::Error) -> Self {
|
fn from(error: reqwest::Error) -> Self {
|
||||||
Self::Http(error)
|
Self::Http(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_backend_worker_launch_options(
|
|
||||||
target: &BackendWorkerLaunchTarget,
|
|
||||||
) -> Result<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
|
|
||||||
validate_launch_target(target)?;
|
|
||||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
|
||||||
get_backend_worker_launch_options_with_client(target, &api).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_backend_worker_launch_options_with_client(
|
|
||||||
target: &BackendWorkerLaunchTarget,
|
|
||||||
api: &BackendApiClient,
|
|
||||||
) -> Result<BackendWorkerLaunchOptions, BackendRuntimeClientError> {
|
|
||||||
let path = backend_workspace_workers_launch_options_path(
|
|
||||||
target
|
|
||||||
.workspace_id
|
|
||||||
.as_deref()
|
|
||||||
.expect("validated Backend Workspace scope"),
|
|
||||||
);
|
|
||||||
let response = api.request(HttpMethod::GET, &path)?.send().await?;
|
|
||||||
let response = api.require_success(response).await?;
|
|
||||||
Ok(response.json::<BackendWorkerLaunchOptions>().await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_backend_worker(
|
|
||||||
target: &BackendWorkerLaunchTarget,
|
|
||||||
request: &BackendCreateWorkerRequest,
|
|
||||||
) -> Result<BackendCreateWorkerResponse, BackendRuntimeClientError> {
|
|
||||||
validate_launch_target(target)?;
|
|
||||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
|
||||||
create_backend_worker_with_client(target, request, &api).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_backend_worker_with_client(
|
|
||||||
target: &BackendWorkerLaunchTarget,
|
|
||||||
request: &BackendCreateWorkerRequest,
|
|
||||||
api: &BackendApiClient,
|
|
||||||
) -> Result<BackendCreateWorkerResponse, BackendRuntimeClientError> {
|
|
||||||
let path = backend_workspace_workers_path(
|
|
||||||
target
|
|
||||||
.workspace_id
|
|
||||||
.as_deref()
|
|
||||||
.expect("validated Backend Workspace scope"),
|
|
||||||
);
|
|
||||||
let response = api
|
|
||||||
.request(HttpMethod::POST, &path)?
|
|
||||||
.json(request)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let response = api.require_success(response).await?;
|
|
||||||
Ok(response.json::<BackendCreateWorkerResponse>().await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_backend_workers(
|
pub async fn list_backend_workers(
|
||||||
target: &BackendRuntimeListTarget,
|
target: &BackendRuntimeListTarget,
|
||||||
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
|
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
|
||||||
validate_list_target(target)?;
|
validate_list_target(target)?;
|
||||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
let http = reqwest::Client::new();
|
||||||
if let Some(runtime_id) = target.runtime_id.as_deref() {
|
if let Some(runtime_id) = target.runtime_id.as_deref() {
|
||||||
let path = backend_runtime_workers_path(
|
let path = backend_runtime_workers_path(
|
||||||
target
|
target
|
||||||
@@ -367,9 +146,12 @@ pub async fn list_backend_workers(
|
|||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
runtime_id,
|
runtime_id,
|
||||||
);
|
);
|
||||||
let response = api.request(HttpMethod::GET, &path)?.send().await?;
|
let url = join_base_and_path(&target.base_url, &path);
|
||||||
api.check_status(response.status())?;
|
return Ok(http
|
||||||
return Ok(response
|
.get(url)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||||
.await?);
|
.await?);
|
||||||
}
|
}
|
||||||
@@ -380,9 +162,12 @@ pub async fn list_backend_workers(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
);
|
);
|
||||||
let response = api.request(HttpMethod::GET, &runtime_path)?.send().await?;
|
let runtime_url = join_base_and_path(&target.base_url, &runtime_path);
|
||||||
api.check_status(response.status())?;
|
let runtimes = http
|
||||||
let runtimes = response
|
.get(runtime_url)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
.json::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
|
.json::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -396,43 +181,29 @@ pub async fn list_backend_workers(
|
|||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
&runtime.runtime_id,
|
&runtime.runtime_id,
|
||||||
);
|
);
|
||||||
let response = match api.request(HttpMethod::GET, &path)?.send().await {
|
let url = join_base_and_path(&target.base_url, &path);
|
||||||
Ok(response) => response,
|
match http
|
||||||
Err(error) => {
|
.get(url)
|
||||||
diagnostics.push(BackendDiagnostic {
|
.send()
|
||||||
code: "runtime_worker_list_failed".to_string(),
|
.await
|
||||||
severity: BackendDiagnosticSeverity::Error,
|
.and_then(|response| response.error_for_status())
|
||||||
message: format!(
|
{
|
||||||
"failed to list workers for runtime {}: {error}",
|
Ok(response) => {
|
||||||
runtime.runtime_id
|
let response = response
|
||||||
),
|
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||||
});
|
.await?;
|
||||||
continue;
|
diagnostics.extend(response.diagnostics);
|
||||||
|
items.extend(response.items);
|
||||||
}
|
}
|
||||||
};
|
Err(error) => diagnostics.push(BackendDiagnostic {
|
||||||
if matches!(
|
|
||||||
response.status(),
|
|
||||||
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
|
|
||||||
) {
|
|
||||||
api.check_status(response.status())?;
|
|
||||||
}
|
|
||||||
if !response.status().is_success() {
|
|
||||||
diagnostics.push(BackendDiagnostic {
|
|
||||||
code: "runtime_worker_list_failed".to_string(),
|
code: "runtime_worker_list_failed".to_string(),
|
||||||
severity: BackendDiagnosticSeverity::Error,
|
severity: BackendDiagnosticSeverity::Error,
|
||||||
message: format!(
|
message: format!(
|
||||||
"failed to list workers for runtime {}: Backend returned HTTP {}",
|
"failed to list workers for runtime {}: {error}",
|
||||||
runtime.runtime_id,
|
runtime.runtime_id
|
||||||
response.status().as_u16()
|
|
||||||
),
|
),
|
||||||
});
|
}),
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
let response = response
|
|
||||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
|
||||||
.await?;
|
|
||||||
diagnostics.extend(response.diagnostics);
|
|
||||||
items.extend(response.items);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(BackendRuntimeListResponse {
|
Ok(BackendRuntimeListResponse {
|
||||||
@@ -453,7 +224,7 @@ pub async fn list_backend_stopped_workers(
|
|||||||
"stopped worker listing requires a runtime id".to_string(),
|
"stopped worker listing requires a runtime id".to_string(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
let http = reqwest::Client::new();
|
||||||
let path = backend_runtime_workers_path(
|
let path = backend_runtime_workers_path(
|
||||||
target
|
target
|
||||||
.workspace_id
|
.workspace_id
|
||||||
@@ -461,12 +232,12 @@ pub async fn list_backend_stopped_workers(
|
|||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
runtime_id,
|
runtime_id,
|
||||||
);
|
);
|
||||||
let response = api
|
let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped"));
|
||||||
.request(HttpMethod::GET, &format!("{path}?status=stopped"))?
|
Ok(http
|
||||||
|
.get(url)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?
|
||||||
api.check_status(response.status())?;
|
.error_for_status()?
|
||||||
Ok(response
|
|
||||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
@@ -475,61 +246,166 @@ pub async fn restore_backend_worker(
|
|||||||
target: &BackendRuntimeTarget,
|
target: &BackendRuntimeTarget,
|
||||||
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
||||||
validate_target(target)?;
|
validate_target(target)?;
|
||||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
let http = reqwest::Client::new();
|
||||||
let path = backend_runtime_worker_restore_path(
|
let path = backend_runtime_worker_restore_path(
|
||||||
&target.workspace_id,
|
&target.workspace_id,
|
||||||
&target.runtime_id,
|
&target.runtime_id,
|
||||||
&target.worker_id,
|
&target.worker_id,
|
||||||
);
|
);
|
||||||
let response = api
|
let url = join_base_and_path(&target.base_url, &path);
|
||||||
.request(HttpMethod::POST, &path)?
|
Ok(http
|
||||||
|
.post(url)
|
||||||
.json(&serde_json::json!({}))
|
.json(&serde_json::json!({}))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?
|
||||||
let response = api.require_success(response).await?;
|
.error_for_status()?
|
||||||
Ok(response.json::<BackendWorkerRestoreResponse>().await?)
|
.json::<BackendWorkerRestoreResponse>()
|
||||||
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn connect_backend_runtime(
|
impl BackendRuntimeClient {
|
||||||
target: BackendRuntimeTarget,
|
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
|
||||||
) -> Result<Client<WebSocket>, BackendRuntimeClientError> {
|
validate_target(&target)?;
|
||||||
validate_target(&target)?;
|
let (event_tx, rx) = mpsc::unbounded_channel();
|
||||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
||||||
let request = protocol_ws_request(&target, &api).map_err(|error| {
|
|
||||||
BackendRuntimeClientError::Protocol(format!(
|
let protocol_target = target.clone();
|
||||||
"Backend protocol request could not be constructed for {}: {error}",
|
let protocol_event_tx = event_tx.clone();
|
||||||
target.display_label()
|
let protocol_task = tokio::spawn(async move {
|
||||||
))
|
run_worker_protocol_transport(protocol_target, command_rx, protocol_event_tx).await;
|
||||||
})?;
|
});
|
||||||
match WebSocket::connect(request).await {
|
|
||||||
Ok(socket) => Ok(Client::new(socket)),
|
Ok(Self {
|
||||||
Err(WebSocketError::WebSocket(error)) => Err(BackendRuntimeClientError::Protocol(
|
target,
|
||||||
protocol_connect_error_message(&target, &api, &error),
|
command_tx,
|
||||||
)),
|
events: rx,
|
||||||
|
diagnostics: VecDeque::new(),
|
||||||
|
_protocol_task: protocol_task,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_next_event(&mut self) -> Option<Event> {
|
||||||
|
if let Some(event) = self.diagnostics.pop_front() {
|
||||||
|
return Some(event);
|
||||||
|
}
|
||||||
|
self.events.try_recv().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn next_event(&mut self) -> Option<Event> {
|
||||||
|
if let Some(event) = self.diagnostics.pop_front() {
|
||||||
|
return Some(event);
|
||||||
|
}
|
||||||
|
self.events.recv().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send(&mut self, method: &Method) -> Result<(), BackendRuntimeClientError> {
|
||||||
|
self.command_tx.send(method.clone()).map_err(|_| {
|
||||||
|
BackendRuntimeClientError::InvalidTarget(format!(
|
||||||
|
"Backend protocol command stream is closed for {}",
|
||||||
|
self.target.display_label()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn protocol_connect_error_message(
|
impl Drop for BackendRuntimeClient {
|
||||||
target: &BackendRuntimeTarget,
|
fn drop(&mut self) {
|
||||||
api: &BackendApiClient,
|
self._protocol_task.abort();
|
||||||
error: &tokio_tungstenite::tungstenite::Error,
|
}
|
||||||
) -> String {
|
}
|
||||||
if let tokio_tungstenite::tungstenite::Error::Http(response) = error {
|
|
||||||
if let Ok(status) = reqwest::StatusCode::from_u16(response.status().as_u16()) {
|
async fn run_worker_protocol_transport(
|
||||||
if matches!(
|
target: BackendRuntimeTarget,
|
||||||
status,
|
mut commands: mpsc::UnboundedReceiver<Method>,
|
||||||
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
|
tx: mpsc::UnboundedSender<Event>,
|
||||||
) {
|
) {
|
||||||
if let Err(error) = api.check_status(status) {
|
let url = protocol_ws_url(&target);
|
||||||
return error.to_string();
|
match connect_async(&url).await {
|
||||||
|
Ok((ws, _)) => {
|
||||||
|
let (mut sink, mut stream) = ws.split();
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
maybe_method = commands.recv() => {
|
||||||
|
let Some(method) = maybe_method else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
match encode_method(&method) {
|
||||||
|
Ok(text) => {
|
||||||
|
if let Err(error) = sink.send(TungsteniteMessage::Text(text.into())).await {
|
||||||
|
let _ = tx.send(diagnostic_event(format!(
|
||||||
|
"Backend protocol command send failed for {}: {error}",
|
||||||
|
target.display_label()
|
||||||
|
)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let _ = tx.send(diagnostic_event(format!(
|
||||||
|
"Backend protocol command could not serialize method for {}: {error}",
|
||||||
|
target.display_label()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frame = stream.next() => {
|
||||||
|
match frame {
|
||||||
|
Some(Ok(TungsteniteMessage::Text(text))) => {
|
||||||
|
match decode_event(&text) {
|
||||||
|
Ok(event) => {
|
||||||
|
let _ = tx.send(event);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let _ = tx.send(diagnostic_event(format!(
|
||||||
|
"Backend protocol response was not valid Event JSON for {}: {error}",
|
||||||
|
target.display_label()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(TungsteniteMessage::Close(_))) | None => {
|
||||||
|
let _ = tx.send(diagnostic_event(format!(
|
||||||
|
"Backend protocol command stream closed for {}",
|
||||||
|
target.display_label()
|
||||||
|
)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Some(Ok(TungsteniteMessage::Ping(_)))
|
||||||
|
| Some(Ok(TungsteniteMessage::Pong(_)))
|
||||||
|
| Some(Ok(TungsteniteMessage::Binary(_)))
|
||||||
|
| Some(Ok(TungsteniteMessage::Frame(_))) => {}
|
||||||
|
Some(Err(error)) => {
|
||||||
|
let _ = tx.send(diagnostic_event(format!(
|
||||||
|
"Backend protocol WebSocket error for {}: {error}",
|
||||||
|
target.display_label()
|
||||||
|
)));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let _ = tx.send(diagnostic_event(format!(
|
||||||
|
"Backend protocol WebSocket connect failed for {}: {error}",
|
||||||
|
target.display_label()
|
||||||
|
)));
|
||||||
|
while commands.recv().await.is_some() {
|
||||||
|
let _ = tx.send(diagnostic_event(format!(
|
||||||
|
"Backend protocol command was not sent because command stream is unavailable for {}",
|
||||||
|
target.display_label()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diagnostic_event(message: impl Into<String>) -> Event {
|
||||||
|
Event::Error {
|
||||||
|
code: ErrorCode::Internal,
|
||||||
|
message: message.into(),
|
||||||
}
|
}
|
||||||
format!(
|
|
||||||
"Backend protocol WebSocket connect failed for {}: {error}",
|
|
||||||
target.display_label()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeClientError> {
|
fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeClientError> {
|
||||||
@@ -561,30 +437,6 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_launch_target(
|
|
||||||
target: &BackendWorkerLaunchTarget,
|
|
||||||
) -> Result<(), BackendRuntimeClientError> {
|
|
||||||
if target.base_url.trim().is_empty() {
|
|
||||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
|
||||||
"Backend API base URL is required".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
|
|
||||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
|
||||||
"Backend API base URL must start with http:// or https://".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
match target.workspace_id.as_deref() {
|
|
||||||
Some("") => Err(BackendRuntimeClientError::InvalidTarget(
|
|
||||||
"workspace_id must not be empty".to_string(),
|
|
||||||
)),
|
|
||||||
None => Err(BackendRuntimeClientError::InvalidTarget(
|
|
||||||
"workspace selection is required before creating a Backend worker".to_string(),
|
|
||||||
)),
|
|
||||||
Some(_) => Ok(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_list_target(
|
fn validate_list_target(
|
||||||
target: &BackendRuntimeListTarget,
|
target: &BackendRuntimeListTarget,
|
||||||
) -> Result<(), BackendRuntimeClientError> {
|
) -> Result<(), BackendRuntimeClientError> {
|
||||||
@@ -619,17 +471,6 @@ fn validate_list_target(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backend_workspace_workers_path(workspace_id: &str) -> String {
|
|
||||||
format!("/api/w/{}/workers", path_segment_encode(workspace_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn backend_workspace_workers_launch_options_path(workspace_id: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"{}/launch-options",
|
|
||||||
backend_workspace_workers_path(workspace_id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn backend_runtimes_path(workspace_id: &str) -> String {
|
fn backend_runtimes_path(workspace_id: &str) -> String {
|
||||||
format!("/api/w/{}/runtimes", path_segment_encode(workspace_id))
|
format!("/api/w/{}/runtimes", path_segment_encode(workspace_id))
|
||||||
}
|
}
|
||||||
@@ -655,19 +496,6 @@ fn backend_runtime_worker_restore_path(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn protocol_ws_request(
|
|
||||||
target: &BackendRuntimeTarget,
|
|
||||||
api: &BackendApiClient,
|
|
||||||
) -> Result<tokio_tungstenite::tungstenite::http::Request<()>, String> {
|
|
||||||
let mut request = protocol_ws_url(target)
|
|
||||||
.into_client_request()
|
|
||||||
.map_err(|error| error.to_string())?;
|
|
||||||
let value = HeaderValue::from_str(&api.authorization_header_value())
|
|
||||||
.map_err(|_| "saved Backend token is not a valid Authorization header".to_string())?;
|
|
||||||
request.headers_mut().insert(AUTHORIZATION, value);
|
|
||||||
Ok(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
|
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
|
||||||
let path = format!(
|
let path = format!(
|
||||||
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
|
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
|
||||||
@@ -714,155 +542,6 @@ fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
|
|
||||||
async fn serve_json_once(body: serde_json::Value) -> (String, tokio::task::JoinHandle<String>) {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
|
||||||
let task = tokio::spawn(async move {
|
|
||||||
let (mut socket, _) = listener.accept().await.unwrap();
|
|
||||||
let mut request = Vec::new();
|
|
||||||
let header_end = loop {
|
|
||||||
let mut buffer = [0_u8; 4096];
|
|
||||||
let read = socket.read(&mut buffer).await.unwrap();
|
|
||||||
assert!(read > 0, "client closed before sending HTTP headers");
|
|
||||||
request.extend_from_slice(&buffer[..read]);
|
|
||||||
if let Some(position) = request.windows(4).position(|part| part == b"\r\n\r\n") {
|
|
||||||
break position + 4;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let headers = String::from_utf8_lossy(&request[..header_end]);
|
|
||||||
let content_length = headers
|
|
||||||
.lines()
|
|
||||||
.find_map(|line| {
|
|
||||||
let (name, value) = line.split_once(':')?;
|
|
||||||
name.eq_ignore_ascii_case("content-length")
|
|
||||||
.then(|| value.trim().parse::<usize>().unwrap())
|
|
||||||
})
|
|
||||||
.unwrap_or(0);
|
|
||||||
while request.len() < header_end + content_length {
|
|
||||||
let mut buffer = [0_u8; 4096];
|
|
||||||
let read = socket.read(&mut buffer).await.unwrap();
|
|
||||||
assert!(read > 0, "client closed before sending HTTP body");
|
|
||||||
request.extend_from_slice(&buffer[..read]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = serde_json::to_vec(&body).unwrap();
|
|
||||||
let response = format!(
|
|
||||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
|
||||||
body.len()
|
|
||||||
);
|
|
||||||
socket.write_all(response.as_bytes()).await.unwrap();
|
|
||||||
socket.write_all(&body).await.unwrap();
|
|
||||||
String::from_utf8(request).unwrap()
|
|
||||||
});
|
|
||||||
(base_url, task)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn launch_options_request_uses_workspace_path_and_bearer_auth() {
|
|
||||||
let (base_url, server) = serve_json_once(serde_json::json!({
|
|
||||||
"workspace_id": "team main",
|
|
||||||
"runtimes": [{
|
|
||||||
"runtime_id": "embedded",
|
|
||||||
"display_name": "Embedded",
|
|
||||||
"built_in": true,
|
|
||||||
"worker_creation_available": true,
|
|
||||||
"working_directory_required": false,
|
|
||||||
"status": "online",
|
|
||||||
"diagnostics": []
|
|
||||||
}],
|
|
||||||
"default_profile": "builtin:default",
|
|
||||||
"profiles": [{
|
|
||||||
"id": "builtin:default",
|
|
||||||
"label": "Default",
|
|
||||||
"description": ""
|
|
||||||
}],
|
|
||||||
"repositories": [],
|
|
||||||
"working_directories": [],
|
|
||||||
"diagnostics": []
|
|
||||||
}))
|
|
||||||
.await;
|
|
||||||
let target = BackendWorkerLaunchTarget::new(&base_url, Some("team main".to_string()));
|
|
||||||
let api = BackendApiClient::from_access_token_for_test(&base_url, "launch-secret").unwrap();
|
|
||||||
|
|
||||||
let response = get_backend_worker_launch_options_with_client(&target, &api)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.runtimes[0].runtime_id, "embedded");
|
|
||||||
let request = server.await.unwrap();
|
|
||||||
assert!(request.starts_with("GET /api/w/team%20main/workers/launch-options HTTP/1.1\r\n"));
|
|
||||||
assert!(
|
|
||||||
request
|
|
||||||
.to_ascii_lowercase()
|
|
||||||
.contains("authorization: bearer launch-secret\r\n")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn create_worker_posts_frontend_contract_to_workspace_path() {
|
|
||||||
let (base_url, server) = serve_json_once(serde_json::json!({
|
|
||||||
"workspace_id": "workspace-1",
|
|
||||||
"runtime_id": "embedded",
|
|
||||||
"worker_id": "worker-1",
|
|
||||||
"console_href": "/w/workspace-1/workers/embedded/worker-1",
|
|
||||||
"worker": {
|
|
||||||
"runtime_id": "embedded",
|
|
||||||
"worker_id": "worker-1",
|
|
||||||
"host_id": "host-1",
|
|
||||||
"display_name": "Coder one",
|
|
||||||
"label": "Coder one",
|
|
||||||
"profile": "builtin:coder",
|
|
||||||
"singleton_key": null,
|
|
||||||
"tags": [],
|
|
||||||
"workspace": {
|
|
||||||
"visibility": "workspace",
|
|
||||||
"identity": "workspace",
|
|
||||||
"workspace_id": "workspace-1"
|
|
||||||
},
|
|
||||||
"state": "idle",
|
|
||||||
"last_seen_at": null,
|
|
||||||
"pinned": false,
|
|
||||||
"retention_state": "resident",
|
|
||||||
"implementation": {"kind": "embedded", "display_hint": "Embedded"},
|
|
||||||
"capabilities": {"can_stop": true, "can_spawn_followup": false},
|
|
||||||
"diagnostics": []
|
|
||||||
},
|
|
||||||
"diagnostics": []
|
|
||||||
}))
|
|
||||||
.await;
|
|
||||||
let target = BackendWorkerLaunchTarget::new(&base_url, Some("workspace-1".to_string()));
|
|
||||||
let api = BackendApiClient::from_access_token_for_test(&base_url, "create-secret").unwrap();
|
|
||||||
let create = BackendCreateWorkerRequest {
|
|
||||||
runtime_id: "embedded".to_string(),
|
|
||||||
display_name: "Coder one".to_string(),
|
|
||||||
profile: Some("builtin:coder".to_string()),
|
|
||||||
ticket_assignment: None,
|
|
||||||
initial_submit: Vec::new(),
|
|
||||||
working_directory: None,
|
|
||||||
control_operation_id: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = create_backend_worker_with_client(&target, &create, &api)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(response.worker_id, "worker-1");
|
|
||||||
let request = server.await.unwrap();
|
|
||||||
assert!(request.starts_with("POST /api/w/workspace-1/workers HTTP/1.1\r\n"));
|
|
||||||
assert!(
|
|
||||||
request
|
|
||||||
.to_ascii_lowercase()
|
|
||||||
.contains("authorization: bearer create-secret\r\n")
|
|
||||||
);
|
|
||||||
let body = request.split_once("\r\n\r\n").unwrap().1;
|
|
||||||
let body: serde_json::Value = serde_json::from_str(body).unwrap();
|
|
||||||
assert_eq!(body["runtime_id"], "embedded");
|
|
||||||
assert_eq!(body["display_name"], "Coder one");
|
|
||||||
assert_eq!(body["profile"], "builtin:coder");
|
|
||||||
assert_eq!(body["initial_submit"], serde_json::json!([]));
|
|
||||||
assert_eq!(body["working_directory"], serde_json::Value::Null);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn protocol_url_uses_backend_runtime_worker_identity() {
|
fn protocol_url_uses_backend_runtime_worker_identity() {
|
||||||
@@ -878,26 +557,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn protocol_request_attaches_saved_bearer_authorization() {
|
|
||||||
let target = BackendRuntimeTarget::new(
|
|
||||||
"http://127.0.0.1:8787/",
|
|
||||||
"workspace alpha",
|
|
||||||
"runtime/one",
|
|
||||||
"worker one",
|
|
||||||
);
|
|
||||||
let api = BackendApiClient::from_access_token_for_test(
|
|
||||||
"http://127.0.0.1:8787",
|
|
||||||
"websocket-secret",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let request = protocol_ws_request(&target, &api).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
request.headers().get(AUTHORIZATION).unwrap(),
|
|
||||||
"Bearer websocket-secret"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_worker_summary_decodes_current_occupied_workdir_contract() {
|
fn backend_worker_summary_decodes_current_occupied_workdir_contract() {
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
@@ -913,8 +572,8 @@ mod tests {
|
|||||||
"capabilities": {"can_stop": true, "can_spawn_followup": false},
|
"capabilities": {"can_stop": true, "can_spawn_followup": false},
|
||||||
"working_directory": {
|
"working_directory": {
|
||||||
"working_directory_id": "wd-1",
|
"working_directory_id": "wd-1",
|
||||||
"repository_key": "main",
|
"repository_id": "main",
|
||||||
"materializer_kind": "runtime_git_clone",
|
"materializer_kind": "local_git_worktree",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"occupied_by": {
|
"occupied_by": {
|
||||||
"runtime_id": "arcadia",
|
"runtime_id": "arcadia",
|
||||||
@@ -926,11 +585,13 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let worker: BackendWorkerSummary = serde_json::from_value(payload.clone()).unwrap();
|
let worker: BackendWorkerSummary = serde_json::from_value(payload.clone()).unwrap();
|
||||||
let workdir = worker.working_directory.unwrap();
|
let occupied_by = worker
|
||||||
assert_eq!(workdir.repository_key, "main");
|
.working_directory
|
||||||
let occupied_by = workdir.occupied_by.expect("occupied Workdir");
|
.unwrap()
|
||||||
assert_eq!(occupied_by.runtime_id, "arcadia");
|
.occupied_by
|
||||||
assert_eq!(occupied_by.worker_id, "worker-opaque-64");
|
.expect("occupied Workdir");
|
||||||
|
assert_eq!(occupied_by.worker.runtime_id, "arcadia");
|
||||||
|
assert_eq!(occupied_by.worker.worker_id, "worker-opaque-64");
|
||||||
|
|
||||||
let mut stale = payload;
|
let mut stale = payload;
|
||||||
stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64);
|
stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64);
|
||||||
|
|||||||
@@ -1,18 +1,58 @@
|
|||||||
use crate::{BackendApiClient, BackendApiClientError};
|
use serde::{Deserialize, Serialize};
|
||||||
use reqwest::Method;
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use workspace_api::{
|
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
||||||
InitialRepositoryIntent, RepositoryListResponse, RepositorySummary,
|
|
||||||
WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse,
|
|
||||||
WorkspaceSummary,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
||||||
|
|
||||||
pub type BackendWorkspace = WorkspaceSummary;
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
|
pub struct BackendWorkspace {
|
||||||
pub type CreateBackendWorkspaceRequest = WorkspaceCreateRequest;
|
pub workspace_id: String,
|
||||||
pub type CreateBackendWorkspaceRepository = InitialRepositoryIntent;
|
pub owner_account_id: Option<String>,
|
||||||
|
pub display_name: String,
|
||||||
|
pub state: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CreateBackendWorkspaceRequest {
|
||||||
|
pub operation_key: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub repository: CreateBackendWorkspaceRepository,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CreateBackendWorkspaceRepository {
|
||||||
|
pub uri: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub default_ref: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct CreateBackendWorkspaceResponse {
|
||||||
|
pub workspace: BackendWorkspace,
|
||||||
|
pub repository: CreateBackendWorkspaceRepositoryRecord,
|
||||||
|
pub config_revision: u64,
|
||||||
|
pub request_fingerprint: String,
|
||||||
|
pub replayed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct CreateBackendWorkspaceRepositoryRecord {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub repository_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub provider: Option<String>,
|
||||||
|
pub source: RepositorySource,
|
||||||
|
pub default_ref: Option<String>,
|
||||||
|
pub source_revision: u64,
|
||||||
|
pub source_fingerprint: String,
|
||||||
|
pub observed_status: RepositoryObservedStatus,
|
||||||
|
pub observed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct BackendWorkspaceCatalogTarget {
|
pub struct BackendWorkspaceCatalogTarget {
|
||||||
@@ -30,7 +70,7 @@ impl BackendWorkspaceCatalogTarget {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BackendWorkspaceClientError {
|
pub enum BackendWorkspaceClientError {
|
||||||
InvalidTarget(String),
|
InvalidTarget(String),
|
||||||
Api(BackendApiClientError),
|
RequestFailed { status: u16, message: String },
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +78,9 @@ impl fmt::Display for BackendWorkspaceClientError {
|
|||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::InvalidTarget(message) => f.write_str(message),
|
Self::InvalidTarget(message) => f.write_str(message),
|
||||||
Self::Api(error) => write!(f, "{error}"),
|
Self::RequestFailed { status, message } => {
|
||||||
|
write!(f, "Backend request failed with HTTP {status}: {message}")
|
||||||
|
}
|
||||||
Self::Http(error) => write!(f, "{error}"),
|
Self::Http(error) => write!(f, "{error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,129 +88,65 @@ impl fmt::Display for BackendWorkspaceClientError {
|
|||||||
|
|
||||||
impl std::error::Error for BackendWorkspaceClientError {}
|
impl std::error::Error for BackendWorkspaceClientError {}
|
||||||
|
|
||||||
impl From<BackendApiClientError> for BackendWorkspaceClientError {
|
|
||||||
fn from(error: BackendApiClientError) -> Self {
|
|
||||||
Self::Api(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<reqwest::Error> for BackendWorkspaceClientError {
|
impl From<reqwest::Error> for BackendWorkspaceClientError {
|
||||||
fn from(error: reqwest::Error) -> Self {
|
fn from(error: reqwest::Error) -> Self {
|
||||||
Self::Http(error)
|
Self::Http(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_backend_workspaces_blocking(
|
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
|
||||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
|
||||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
|
||||||
let response = client
|
|
||||||
.blocking_request(
|
|
||||||
Method::GET,
|
|
||||||
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
|
|
||||||
)?
|
|
||||||
.send()?;
|
|
||||||
client.check_status(response.status())?;
|
|
||||||
Ok(response.json::<WorkspaceCatalogListResponse>()?.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_backend_workspace_repositories_blocking(
|
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
|
||||||
workspace_id: &str,
|
|
||||||
) -> Result<Vec<RepositorySummary>, BackendWorkspaceClientError> {
|
|
||||||
if workspace_id.is_empty()
|
|
||||||
|| workspace_id.len() > 200
|
|
||||||
|| !workspace_id
|
|
||||||
.bytes()
|
|
||||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
|
||||||
{
|
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Workspace id returned by Backend is invalid".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
|
||||||
let response = client
|
|
||||||
.blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))?
|
|
||||||
.send()?;
|
|
||||||
client.check_status(response.status())?;
|
|
||||||
let response = response.json::<RepositoryListResponse>()?;
|
|
||||||
if response.workspace_id != workspace_id {
|
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Repository catalog response does not match the requested Workspace".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(response.items)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn list_backend_workspaces(
|
pub async fn list_backend_workspaces(
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
validate_target(target)?;
|
||||||
list_backend_workspaces_with_client(&client).await
|
let url = format!(
|
||||||
}
|
"{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}",
|
||||||
|
target.base_url.trim_end_matches('/')
|
||||||
async fn list_backend_workspaces_with_client(
|
);
|
||||||
client: &BackendApiClient,
|
let response = reqwest::Client::new().get(url).send().await?;
|
||||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
let response = require_success(response).await?;
|
||||||
let response = client
|
Ok(response.json::<Vec<BackendWorkspace>>().await?)
|
||||||
.request(
|
|
||||||
Method::GET,
|
|
||||||
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
|
|
||||||
)?
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
client.check_status(response.status())?;
|
|
||||||
Ok(response.json::<WorkspaceCatalogListResponse>().await?.0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create_backend_workspace(
|
pub async fn create_backend_workspace(
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
request: &CreateBackendWorkspaceRequest,
|
request: &CreateBackendWorkspaceRequest,
|
||||||
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
|
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
|
||||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
validate_target(target)?;
|
||||||
let response = client
|
let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/'));
|
||||||
.request(Method::POST, "/api/workspaces")?
|
let response = reqwest::Client::new()
|
||||||
|
.post(url)
|
||||||
.json(request)
|
.json(request)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
client.check_status(response.status())?;
|
let response = require_success(response).await?;
|
||||||
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
|
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn require_success(
|
||||||
|
response: reqwest::Response,
|
||||||
|
) -> Result<reqwest::Response, BackendWorkspaceClientError> {
|
||||||
|
if response.status().is_success() {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let message = response.text().await.unwrap_or_default();
|
||||||
|
Err(BackendWorkspaceClientError::RequestFailed { status, message })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_target(
|
||||||
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
|
) -> Result<(), BackendWorkspaceClientError> {
|
||||||
|
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
|
||||||
|
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||||
|
"Backend API base URL must start with http:// or https://".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::net::TcpListener;
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn workspace_catalog_request_uses_shared_bearer_client() {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
||||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
|
||||||
let handle = thread::spawn(move || {
|
|
||||||
let (mut stream, _) = listener.accept().unwrap();
|
|
||||||
let mut request = vec![0; 4096];
|
|
||||||
let read = stream.read(&mut request).unwrap();
|
|
||||||
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
|
||||||
assert!(request.starts_with("get /api/workspaces?limit=200 "));
|
|
||||||
assert!(request.contains("authorization: bearer catalog-secret\r\n"));
|
|
||||||
stream
|
|
||||||
.write_all(
|
|
||||||
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
});
|
|
||||||
let client =
|
|
||||||
BackendApiClient::from_access_token_for_test(&base_url, "catalog-secret").unwrap();
|
|
||||||
assert!(
|
|
||||||
list_backend_workspaces_with_client(&client)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_request_keeps_operation_key_for_exact_retry() {
|
fn create_request_keeps_operation_key_for_exact_retry() {
|
||||||
@@ -176,8 +154,8 @@ mod tests {
|
|||||||
operation_key: "workspace-create-1".to_string(),
|
operation_key: "workspace-create-1".to_string(),
|
||||||
display_name: "Alpha".to_string(),
|
display_name: "Alpha".to_string(),
|
||||||
repository: CreateBackendWorkspaceRepository {
|
repository: CreateBackendWorkspaceRepository {
|
||||||
repository_key: "main".to_string(),
|
|
||||||
uri: "/srv/repos/alpha".to_string(),
|
uri: "/srv/repos/alpha".to_string(),
|
||||||
|
display_name: Some("Main".to_string()),
|
||||||
default_ref: Some("develop".to_string()),
|
default_ref: Some("develop".to_string()),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
use std::error::Error;
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
use protocol::stream::{decode_event, encode_method};
|
|
||||||
use protocol::{Event, Method};
|
|
||||||
|
|
||||||
use crate::transport::Socket;
|
|
||||||
|
|
||||||
/// Typed Worker protocol client over an injected message transport.
|
|
||||||
pub struct Client<T> {
|
|
||||||
socket: T,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum ClientError<E> {
|
|
||||||
Transport(E),
|
|
||||||
Protocol(serde_json::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T> Client<T> {
|
|
||||||
pub fn new(socket: T) -> Self {
|
|
||||||
Self { socket }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn into_inner(self) -> T {
|
|
||||||
self.socket
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<T: Socket> Client<T> {
|
|
||||||
pub async fn send(&mut self, method: &Method) -> Result<(), ClientError<T::Error>> {
|
|
||||||
let message = encode_method(method).map_err(ClientError::Protocol)?;
|
|
||||||
self.socket
|
|
||||||
.send(message)
|
|
||||||
.await
|
|
||||||
.map_err(ClientError::Transport)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn next_event(&mut self) -> Result<Option<Event>, ClientError<T::Error>> {
|
|
||||||
self.socket
|
|
||||||
.next()
|
|
||||||
.await
|
|
||||||
.map_err(ClientError::Transport)?
|
|
||||||
.map(|message| decode_event(&message).map_err(ClientError::Protocol))
|
|
||||||
.transpose()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn try_next_event(&mut self) -> Result<Option<Event>, ClientError<T::Error>> {
|
|
||||||
self.socket
|
|
||||||
.try_next()
|
|
||||||
.map_err(ClientError::Transport)?
|
|
||||||
.map(|message| decode_event(&message).map_err(ClientError::Protocol))
|
|
||||||
.transpose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<E: fmt::Display> fmt::Display for ClientError<E> {
|
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Transport(error) => write!(formatter, "Worker transport error: {error}"),
|
|
||||||
Self::Protocol(error) => write!(formatter, "Worker protocol error: {error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<E: Error + 'static> Error for ClientError<E> {
|
|
||||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
|
||||||
match self {
|
|
||||||
Self::Transport(error) => Some(error),
|
|
||||||
Self::Protocol(error) => Some(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::convert::Infallible;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use protocol::stream::{decode_method, encode_event};
|
|
||||||
use protocol::{Event, Method, WorkerStatus};
|
|
||||||
|
|
||||||
use super::Client;
|
|
||||||
use crate::transport::Socket;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct TestSocket {
|
|
||||||
sent: Vec<String>,
|
|
||||||
incoming: VecDeque<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Socket for TestSocket {
|
|
||||||
type Error = Infallible;
|
|
||||||
|
|
||||||
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
|
|
||||||
self.sent.push(message);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
|
|
||||||
Ok(self.incoming.pop_front())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
|
|
||||||
Ok(self.incoming.pop_front())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn encodes_methods_and_decodes_events_above_transport() {
|
|
||||||
let mut socket = TestSocket::default();
|
|
||||||
socket.incoming.push_back(
|
|
||||||
encode_event(&Event::WorkerState {
|
|
||||||
snapshot: WorkerStatus::Idle.into(),
|
|
||||||
})
|
|
||||||
.expect("encode event"),
|
|
||||||
);
|
|
||||||
let mut client = Client::new(socket);
|
|
||||||
|
|
||||||
client
|
|
||||||
.send(&Method::submit_text(
|
|
||||||
protocol::new_submission_request_id(),
|
|
||||||
"hello",
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("send method");
|
|
||||||
assert!(matches!(
|
|
||||||
decode_method(&client.socket.sent[0]),
|
|
||||||
Ok(Method::Submit { .. })
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
client.next_event().await,
|
|
||||||
Ok(Some(Event::WorkerState { .. }))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+36
-33
@@ -1,54 +1,57 @@
|
|||||||
//! Backend Workspace/Runtime と既存 Worker protocol へ接続するクライアント。
|
//! Worker プロトコルを喋るクライアント。
|
||||||
//!
|
//!
|
||||||
//! Standalone execution is owned by the `standalone` crate and does not spawn
|
//! - [`WorkerClient`]: 既存 worker の Unix ソケットへ接続して `Method` を送り、
|
||||||
//! a Worker subprocess through this crate.
|
//! `Event` を受け取る低レベル接続。
|
||||||
|
//! - [`spawn`]: worker バイナリをサブプロセスとして起動し、`YOI-READY`
|
||||||
|
//! ハンドシェイクが終わるまで待つフロー。subprocess を立ち上げる必要が
|
||||||
|
//! ない呼び出し側 (=既存 worker に attach する場合) は使わなくてよい。
|
||||||
|
//!
|
||||||
|
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
||||||
|
|
||||||
pub mod backend_api;
|
pub mod backend_auth;
|
||||||
mod backend_auth;
|
|
||||||
pub mod backend_runtime;
|
pub mod backend_runtime;
|
||||||
pub mod backend_workspace;
|
pub mod backend_workspace;
|
||||||
mod client;
|
pub mod runtime_command;
|
||||||
|
pub mod spawn;
|
||||||
pub mod target;
|
pub mod target;
|
||||||
pub mod transport;
|
pub mod ticket_role;
|
||||||
|
mod worker_client;
|
||||||
mod workspace_product;
|
mod workspace_product;
|
||||||
|
|
||||||
pub use backend_api::{
|
|
||||||
BackendApiClient, BackendApiClientError, BackendOrigin, backend_token_file_path,
|
|
||||||
save_backend_token,
|
|
||||||
};
|
|
||||||
pub use backend_auth::{
|
pub use backend_auth::{
|
||||||
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
||||||
poll_device_login, start_device_login, wait_for_device_login,
|
poll_device_login, start_device_login, wait_for_device_login,
|
||||||
};
|
};
|
||||||
pub use backend_runtime::{
|
pub use backend_runtime::{
|
||||||
BackendCreateWorkerRequest, BackendCreateWorkerResponse, BackendDiagnostic,
|
BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClient, BackendRuntimeClientError,
|
||||||
BackendDiagnosticSeverity, BackendRuntimeClientError, BackendRuntimeListResponse,
|
BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary,
|
||||||
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
||||||
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
|
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,
|
||||||
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
|
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers,
|
||||||
BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
|
list_backend_workers, restore_backend_worker,
|
||||||
BackendWorkerRestoreResult, BackendWorkerSummary, BackendWorkerWorkspaceSummary,
|
|
||||||
BackendWorkingDirectorySummary, connect_backend_runtime, create_backend_worker,
|
|
||||||
get_backend_worker_launch_options, list_backend_stopped_workers, list_backend_workers,
|
|
||||||
restore_backend_worker,
|
|
||||||
};
|
};
|
||||||
pub use backend_workspace::{
|
pub use backend_workspace::{
|
||||||
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
||||||
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
||||||
CreateBackendWorkspaceResponse, create_backend_workspace,
|
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
|
||||||
list_backend_workspace_repositories_blocking, list_backend_workspaces,
|
|
||||||
list_backend_workspaces_blocking,
|
|
||||||
};
|
};
|
||||||
pub use client::{Client, ClientError};
|
pub use runtime_command::WorkerRuntimeCommand;
|
||||||
pub use target::{
|
pub use target::{
|
||||||
BackendTarget, BackendWorkerLaunch, Dashboard, ResolvedTarget, StandaloneTarget,
|
BackendTarget, Dashboard, LocalTarget, ResolvedTarget, Target, TargetError, TargetKind,
|
||||||
StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind,
|
WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest,
|
||||||
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
|
WorkerResume, WorkerSpawn,
|
||||||
};
|
};
|
||||||
pub use workspace_api::{
|
|
||||||
CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition,
|
pub use spawn::{
|
||||||
CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse,
|
SpawnConfig, SpawnError, SpawnReady, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions,
|
||||||
CompanionTranscriptItem, CompanionTranscriptProjection, CompanionTranscriptRole,
|
spawn_worker, spawn_worker_with_options,
|
||||||
CompanionTransportSummary, ObjectiveDetail, ObjectiveSummary,
|
|
||||||
};
|
};
|
||||||
|
pub use ticket_role::{
|
||||||
|
TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchOptions,
|
||||||
|
TicketRoleLaunchPlan, TicketRoleLaunchResult, TicketRolePreRunWarning,
|
||||||
|
launch_ticket_role_worker, launch_ticket_role_worker_with_options, plan_ticket_role_launch,
|
||||||
|
plan_ticket_role_launch_with_config,
|
||||||
|
};
|
||||||
|
pub use worker_client::WorkerClient;
|
||||||
|
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary};
|
||||||
pub use workspace_product::BackendWorkspaceProductClient;
|
pub use workspace_product::BackendWorkspaceProductClient;
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
//! Worker runtime command をサブプロセスとして立ち上げ、`YOI-READY` を待つ
|
||||||
|
//! ハンドシェイク。
|
||||||
|
//!
|
||||||
|
//! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を
|
||||||
|
//! 指定してこの関数に渡す。worker はそれを受けて socket を bind し、stderr に
|
||||||
|
//! `YOI-READY\t<name>\t<socket>` を吐く。
|
||||||
|
//! - 待機中の stderr 行は `progress` コールバック越しに呼び出し側へ流す。
|
||||||
|
//! UI の進捗表示や E2E のログ収集はここで賄う。
|
||||||
|
//! - `kill_on_drop = false` + `process_group(0)` により、親プロセス
|
||||||
|
//! ライフサイクルから切り離した detached worker を作る。ready 後の lifecycle
|
||||||
|
//! 管理は runtime ディレクトリ / socket を介して行う。
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::WorkerRuntimeCommand;
|
||||||
|
use tokio::process::Command;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const READY_PREFIX: &str = "YOI-READY\t";
|
||||||
|
const READY_TIMEOUT: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WorkerProcessLaunchConfig {
|
||||||
|
pub runtime_command: WorkerRuntimeCommand,
|
||||||
|
/// `worker.name` として使う識別子。runtime ディレクトリ
|
||||||
|
/// (`manifest::paths::worker_runtime_dir`) の解決と、ready 行に乗る
|
||||||
|
/// 名前との突き合わせに使う。
|
||||||
|
pub worker_name: String,
|
||||||
|
/// Optional reusable Profile selector. Worker identity is always supplied
|
||||||
|
/// separately with `--worker`; profile selection must not imply a name.
|
||||||
|
pub profile: Option<String>,
|
||||||
|
/// Explicit runtime workspace root. The child receives it via
|
||||||
|
/// `--workspace` so startup does not infer workspace identity from the
|
||||||
|
/// parent process cwd.
|
||||||
|
pub workspace_root: PathBuf,
|
||||||
|
/// Optional child process cwd. This is not runtime workspace identity and
|
||||||
|
/// is not passed as a CLI argument; the child observes it as its ordinary
|
||||||
|
/// process current directory.
|
||||||
|
pub cwd: Option<PathBuf>,
|
||||||
|
/// `Some(id)` のとき `--session <id>` を付与し、当該セッションから
|
||||||
|
/// resume させる。
|
||||||
|
pub resume_from: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct WorkerProcessLaunchOptions {
|
||||||
|
/// Extra child CLI arguments supplied by an upper resolver layer. The
|
||||||
|
/// low-level launch config intentionally does not model Ticket IDs,
|
||||||
|
/// Ticket roles, orchestration roles, executable authority, or raw
|
||||||
|
/// browser-provided profile/cwd/workspace inputs.
|
||||||
|
pub extra_args: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerProcessLaunchOptions {
|
||||||
|
pub fn with_hidden_arg(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
|
||||||
|
self.extra_args.extend([name.into(), value.into()]);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.extra_args.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SpawnConfig = WorkerProcessLaunchConfig;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SpawnReady {
|
||||||
|
pub worker_name: String,
|
||||||
|
pub socket_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SpawnError {
|
||||||
|
Io(io::Error),
|
||||||
|
/// runtime ディレクトリが解決できなかった (環境変数未設定等)。
|
||||||
|
RuntimeDirUnavailable,
|
||||||
|
WorkerLaunchFailed {
|
||||||
|
command: WorkerRuntimeCommand,
|
||||||
|
source: io::Error,
|
||||||
|
},
|
||||||
|
WorkerExitedEarly {
|
||||||
|
stderr_tail: String,
|
||||||
|
},
|
||||||
|
Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SpawnError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(e) => write!(f, "io error: {e}"),
|
||||||
|
Self::RuntimeDirUnavailable => write!(
|
||||||
|
f,
|
||||||
|
"could not resolve runtime directory (set YOI_HOME, YOI_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)"
|
||||||
|
),
|
||||||
|
Self::WorkerLaunchFailed { command, source } => write!(
|
||||||
|
f,
|
||||||
|
"failed to launch worker runtime command `{command}`: {source}"
|
||||||
|
),
|
||||||
|
Self::WorkerExitedEarly { stderr_tail } => {
|
||||||
|
if stderr_tail.is_empty() {
|
||||||
|
write!(f, "worker exited before becoming ready")
|
||||||
|
} else {
|
||||||
|
write!(f, "worker exited before becoming ready: {stderr_tail}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::Timeout => write!(
|
||||||
|
f,
|
||||||
|
"worker did not become ready within {}s",
|
||||||
|
READY_TIMEOUT.as_secs()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for SpawnError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Io(error) | Self::WorkerLaunchFailed { source: error, .. } => Some(error),
|
||||||
|
Self::RuntimeDirUnavailable | Self::WorkerExitedEarly { .. } | Self::Timeout => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<io::Error> for SpawnError {
|
||||||
|
fn from(e: io::Error) -> Self {
|
||||||
|
Self::Io(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_args(
|
||||||
|
config: &WorkerProcessLaunchConfig,
|
||||||
|
options: &WorkerProcessLaunchOptions,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut args = vec![
|
||||||
|
"--workspace".to_string(),
|
||||||
|
config.workspace_root.display().to_string(),
|
||||||
|
];
|
||||||
|
if let Some(id) = config.resume_from {
|
||||||
|
args.extend([
|
||||||
|
"--session".to_string(),
|
||||||
|
id.to_string(),
|
||||||
|
"--worker".to_string(),
|
||||||
|
config.worker_name.clone(),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
args.extend(["--worker".to_string(), config.worker_name.clone()]);
|
||||||
|
if let Some(profile) = &config.profile {
|
||||||
|
args.extend(["--profile".to_string(), profile.clone()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args.extend(options.extra_args.clone());
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
/// worker を spawn し、`YOI-READY` ハンドシェイクが終わるまで待つ。
|
||||||
|
///
|
||||||
|
/// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる
|
||||||
|
/// (ready 行自体は除外される)。UI の表示更新や E2E ログ取得に使う。
|
||||||
|
pub async fn spawn_worker<F>(
|
||||||
|
config: WorkerProcessLaunchConfig,
|
||||||
|
progress: F,
|
||||||
|
) -> Result<SpawnReady, SpawnError>
|
||||||
|
where
|
||||||
|
F: FnMut(&str),
|
||||||
|
{
|
||||||
|
spawn_worker_with_options(config, WorkerProcessLaunchOptions::default(), progress).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn spawn_worker_with_options<F>(
|
||||||
|
config: WorkerProcessLaunchConfig,
|
||||||
|
options: WorkerProcessLaunchOptions,
|
||||||
|
mut progress: F,
|
||||||
|
) -> Result<SpawnReady, SpawnError>
|
||||||
|
where
|
||||||
|
F: FnMut(&str),
|
||||||
|
{
|
||||||
|
let worker_runtime_dir = manifest::paths::worker_runtime_dir(&config.worker_name)
|
||||||
|
.ok_or(SpawnError::RuntimeDirUnavailable)?;
|
||||||
|
std::fs::create_dir_all(&worker_runtime_dir).map_err(SpawnError::Io)?;
|
||||||
|
let stderr_path = worker_runtime_dir.join("stderr.log");
|
||||||
|
let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?;
|
||||||
|
|
||||||
|
let mut command = Command::new(config.runtime_command.program());
|
||||||
|
command
|
||||||
|
.args(config.runtime_command.prefix_args())
|
||||||
|
.current_dir(config.cwd.as_ref().unwrap_or(&config.workspace_root))
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::from(stderr_file))
|
||||||
|
.process_group(0);
|
||||||
|
for arg in runtime_args(&config, &options) {
|
||||||
|
command.arg(arg);
|
||||||
|
}
|
||||||
|
let mut child = command
|
||||||
|
.spawn()
|
||||||
|
.map_err(|source| SpawnError::WorkerLaunchFailed {
|
||||||
|
command: config.runtime_command.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Default `kill_on_drop = false` plus `process_group(0)` makes this
|
||||||
|
// a detached Worker once startup succeeds: dropping the handle does not
|
||||||
|
// terminate it, and terminal-generated signals for the parent's
|
||||||
|
// process group do not hit the Worker. Runtime state/socket files are
|
||||||
|
// the source of truth after that point.
|
||||||
|
let ready = match wait_for_ready_file(&mut progress, &stderr_path, &mut child).await {
|
||||||
|
Ok(ready) => ready,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = child.start_kill();
|
||||||
|
let _ = child.wait().await;
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = child.wait().await;
|
||||||
|
});
|
||||||
|
Ok(ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_ready_file<F>(
|
||||||
|
progress: &mut F,
|
||||||
|
stderr_path: &Path,
|
||||||
|
child: &mut tokio::process::Child,
|
||||||
|
) -> Result<SpawnReady, SpawnError>
|
||||||
|
where
|
||||||
|
F: FnMut(&str),
|
||||||
|
{
|
||||||
|
let mut tail = StderrTail::new();
|
||||||
|
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
|
||||||
|
let mut offset = 0usize;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let content = match tokio::fs::read_to_string(stderr_path).await {
|
||||||
|
Ok(content) => content,
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
|
||||||
|
Err(e) => return Err(SpawnError::Io(e)),
|
||||||
|
};
|
||||||
|
if content.len() > offset {
|
||||||
|
for line in content[offset..].lines() {
|
||||||
|
if let Some(rest) = line.strip_prefix(READY_PREFIX) {
|
||||||
|
let mut parts = rest.splitn(2, '\t');
|
||||||
|
let worker_name = parts.next().unwrap_or("").to_string();
|
||||||
|
let socket_str = parts.next().unwrap_or("").to_string();
|
||||||
|
if worker_name.is_empty() || socket_str.is_empty() {
|
||||||
|
return Err(SpawnError::WorkerExitedEarly {
|
||||||
|
stderr_tail: format!("malformed ready line: {line}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let socket_path = PathBuf::from(socket_str);
|
||||||
|
wait_for_socket(
|
||||||
|
&socket_path,
|
||||||
|
deadline,
|
||||||
|
child,
|
||||||
|
stderr_path,
|
||||||
|
&mut tail,
|
||||||
|
&mut offset,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(SpawnReady {
|
||||||
|
worker_name,
|
||||||
|
socket_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
tail.push(line);
|
||||||
|
progress(line);
|
||||||
|
}
|
||||||
|
offset = content.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return Err(SpawnError::Timeout);
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
status = child.wait() => {
|
||||||
|
let _ = status;
|
||||||
|
// Worker は exit 直前に最終 stderr 行を flush することがある。
|
||||||
|
// child.wait() が解決した後に再読みして、原因行を取りこ
|
||||||
|
// ぼさず WorkerExitedEarly に載せる。
|
||||||
|
drain_stderr_into_tail(stderr_path, &mut tail, &mut offset).await;
|
||||||
|
return Err(SpawnError::WorkerExitedEarly {
|
||||||
|
stderr_tail: tail.into_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ = tokio::time::sleep(Duration::from_millis(100)) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_socket(
|
||||||
|
socket_path: &Path,
|
||||||
|
deadline: tokio::time::Instant,
|
||||||
|
child: &mut tokio::process::Child,
|
||||||
|
stderr_path: &Path,
|
||||||
|
tail: &mut StderrTail,
|
||||||
|
offset: &mut usize,
|
||||||
|
) -> Result<(), SpawnError> {
|
||||||
|
loop {
|
||||||
|
match tokio::net::UnixStream::connect(socket_path).await {
|
||||||
|
Ok(_) => return Ok(()),
|
||||||
|
Err(e)
|
||||||
|
if e.kind() == io::ErrorKind::NotFound
|
||||||
|
|| e.kind() == io::ErrorKind::ConnectionRefused => {}
|
||||||
|
Err(e) => return Err(SpawnError::Io(e)),
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return Err(SpawnError::Timeout);
|
||||||
|
}
|
||||||
|
tokio::select! {
|
||||||
|
status = child.wait() => {
|
||||||
|
let _ = status;
|
||||||
|
drain_stderr_into_tail(stderr_path, tail, offset).await;
|
||||||
|
return Err(SpawnError::WorkerExitedEarly {
|
||||||
|
stderr_tail: tail.as_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ = tokio::time::sleep(Duration::from_millis(50)) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn drain_stderr_into_tail(stderr_path: &Path, tail: &mut StderrTail, offset: &mut usize) {
|
||||||
|
let Ok(content) = tokio::fs::read_to_string(stderr_path).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if content.len() <= *offset {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for line in content[*offset..].lines() {
|
||||||
|
if !line.starts_with(READY_PREFIX) {
|
||||||
|
tail.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*offset = content.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StderrTail {
|
||||||
|
lines: std::collections::VecDeque<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StderrTail {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
lines: std::collections::VecDeque::with_capacity(8),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn push(&mut self, line: &str) {
|
||||||
|
if self.lines.len() == 8 {
|
||||||
|
self.lines.pop_front();
|
||||||
|
}
|
||||||
|
self.lines.push_back(line.to_string());
|
||||||
|
}
|
||||||
|
fn as_string(&self) -> String {
|
||||||
|
self.lines.iter().cloned().collect::<Vec<_>>().join(" | ")
|
||||||
|
}
|
||||||
|
fn into_string(self) -> String {
|
||||||
|
self.lines.into_iter().collect::<Vec<_>>().join(" | ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::ffi::OsString;
|
||||||
|
|
||||||
|
fn base_config() -> WorkerProcessLaunchConfig {
|
||||||
|
WorkerProcessLaunchConfig {
|
||||||
|
runtime_command: WorkerRuntimeCommand::new("/bin/yoi", vec![OsString::from("worker")]),
|
||||||
|
worker_name: "explicit-worker".to_string(),
|
||||||
|
profile: Some("project:companion".to_string()),
|
||||||
|
workspace_root: PathBuf::from("/work/other-project"),
|
||||||
|
cwd: None,
|
||||||
|
resume_from: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_args_keep_workspace_worker_and_profile_separate() {
|
||||||
|
assert_eq!(
|
||||||
|
runtime_args(&base_config(), &WorkerProcessLaunchOptions::default()),
|
||||||
|
vec![
|
||||||
|
"--workspace",
|
||||||
|
"/work/other-project",
|
||||||
|
"--worker",
|
||||||
|
"explicit-worker",
|
||||||
|
"--profile",
|
||||||
|
"project:companion",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_args_use_session_mode_without_profile_identity_alias() {
|
||||||
|
let mut config = base_config();
|
||||||
|
config.resume_from = Some(Uuid::nil());
|
||||||
|
assert_eq!(
|
||||||
|
runtime_args(&config, &WorkerProcessLaunchOptions::default()),
|
||||||
|
vec![
|
||||||
|
"--workspace",
|
||||||
|
"/work/other-project",
|
||||||
|
"--session",
|
||||||
|
"00000000-0000-0000-0000-000000000000",
|
||||||
|
"--worker",
|
||||||
|
"explicit-worker",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_args_include_upper_resolver_extra_args_without_child_cwd() {
|
||||||
|
let mut config = base_config();
|
||||||
|
config.cwd = Some(PathBuf::from("/work/main/.worktree/orchestration/yoi"));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
runtime_args(
|
||||||
|
&config,
|
||||||
|
&WorkerProcessLaunchOptions::default()
|
||||||
|
.with_hidden_arg("--ticket-role", "orchestrator"),
|
||||||
|
),
|
||||||
|
vec![
|
||||||
|
"--workspace",
|
||||||
|
"/work/other-project",
|
||||||
|
"--worker",
|
||||||
|
"explicit-worker",
|
||||||
|
"--profile",
|
||||||
|
"project:companion",
|
||||||
|
"--ticket-role",
|
||||||
|
"orchestrator",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+193
-181
@@ -1,20 +1,16 @@
|
|||||||
use std::{fmt, path::PathBuf};
|
use std::fmt;
|
||||||
|
|
||||||
use crate::{
|
use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
|
||||||
BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget,
|
|
||||||
BackendRuntimeTarget, BackendWorkerLaunchTarget,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum TargetKind {
|
pub enum TargetKind {
|
||||||
/// One-process Standalone authority with no Runtime or Workspace backend.
|
Local,
|
||||||
Standalone,
|
|
||||||
Backend,
|
Backend,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ResolvedTarget {
|
pub enum ResolvedTarget {
|
||||||
Standalone,
|
Local,
|
||||||
Backend {
|
Backend {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
@@ -24,7 +20,7 @@ pub enum ResolvedTarget {
|
|||||||
impl ResolvedTarget {
|
impl ResolvedTarget {
|
||||||
pub fn kind(&self) -> TargetKind {
|
pub fn kind(&self) -> TargetKind {
|
||||||
match self {
|
match self {
|
||||||
Self::Standalone => TargetKind::Standalone,
|
Self::Local => TargetKind::Local,
|
||||||
Self::Backend { .. } => TargetKind::Backend,
|
Self::Backend { .. } => TargetKind::Backend,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,12 +29,31 @@ impl ResolvedTarget {
|
|||||||
impl fmt::Display for TargetKind {
|
impl fmt::Display for TargetKind {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::Standalone => f.write_str("Standalone"),
|
Self::Local => f.write_str("local"),
|
||||||
Self::Backend => f.write_str("Backend"),
|
Self::Backend => f.write_str("Backend"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct LocalTarget;
|
||||||
|
|
||||||
|
impl LocalTarget {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_command(&self) -> Result<WorkerRuntimeCommand, TargetError> {
|
||||||
|
WorkerRuntimeCommand::resolve().map_err(TargetError::local_runtime_command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LocalTarget {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct BackendTarget {
|
pub struct BackendTarget {
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
@@ -47,19 +62,11 @@ pub struct BackendTarget {
|
|||||||
|
|
||||||
impl BackendTarget {
|
impl BackendTarget {
|
||||||
pub fn new(base_url: impl Into<String>, workspace_id: Option<impl Into<String>>) -> Self {
|
pub fn new(base_url: impl Into<String>, workspace_id: Option<impl Into<String>>) -> Self {
|
||||||
let base_url = base_url.into();
|
|
||||||
let base_url = BackendOrigin::parse(&base_url)
|
|
||||||
.map(|origin| origin.to_string())
|
|
||||||
.unwrap_or(base_url);
|
|
||||||
Self {
|
Self {
|
||||||
base_url,
|
base_url: base_url.into(),
|
||||||
workspace_id: workspace_id.map(Into::into),
|
workspace_id: workspace_id.map(Into::into),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn authenticated_client(&self) -> Result<BackendApiClient, BackendApiClientError> {
|
|
||||||
BackendApiClient::from_stored_token(&self.base_url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -101,36 +108,34 @@ impl WorkerConnectionSelector {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct WorkerSpawn {
|
pub struct WorkerSpawn {
|
||||||
pub state_dir: PathBuf,
|
pub runtime_command: WorkerRuntimeCommand,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct StandaloneWorkerListIntent {
|
pub struct WorkerByName {
|
||||||
pub state_dir: PathBuf,
|
pub runtime_command: WorkerRuntimeCommand,
|
||||||
pub cwd: PathBuf,
|
|
||||||
pub include_all: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct StandaloneWorkerResumeIntent {
|
pub struct WorkerResume {
|
||||||
pub state_dir: PathBuf,
|
pub runtime_command: WorkerRuntimeCommand,
|
||||||
pub worker_id: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Dashboard {
|
pub enum Dashboard {
|
||||||
pub base_url: String,
|
Local {
|
||||||
pub workspace_id: String,
|
runtime_command: WorkerRuntimeCommand,
|
||||||
}
|
},
|
||||||
|
Backend {
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
base_url: String,
|
||||||
pub struct BackendWorkerLaunch {
|
workspace_id: String,
|
||||||
pub target: BackendWorkerLaunchTarget,
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct WorkerList {
|
pub struct WorkerList {
|
||||||
pub backend_target: BackendRuntimeListTarget,
|
pub local_runtime_command: Option<WorkerRuntimeCommand>,
|
||||||
|
pub backend_target: Option<BackendRuntimeListTarget>,
|
||||||
pub include_stopped: bool,
|
pub include_stopped: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +161,12 @@ impl TargetError {
|
|||||||
message: format!("invalid {target} target: {}", message.into()),
|
message: format!("invalid {target} target: {}", message.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn local_runtime_command(error: std::io::Error) -> Self {
|
||||||
|
Self {
|
||||||
|
message: format!("failed to resolve local Worker runtime command: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for TargetError {
|
impl fmt::Display for TargetError {
|
||||||
@@ -172,47 +183,71 @@ pub trait Target: fmt::Debug + Send + Sync {
|
|||||||
/// Resolve the target once for Workspace product-state operations.
|
/// Resolve the target once for Workspace product-state operations.
|
||||||
///
|
///
|
||||||
/// Backend targets must carry an explicit Workspace identity. Callers use
|
/// Backend targets must carry an explicit Workspace identity. Callers use
|
||||||
/// this value instead of rediscovering authority from cwd or process
|
/// this value instead of rediscovering Backend/local authority from cwd or
|
||||||
/// configuration after command dispatch.
|
/// process configuration after command dispatch.
|
||||||
fn resolve(&self) -> Result<ResolvedTarget, TargetError>;
|
fn resolve(&self) -> Result<ResolvedTarget, TargetError>;
|
||||||
|
|
||||||
|
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError>;
|
||||||
|
|
||||||
|
fn worker_by_name(&self) -> Result<WorkerByName, TargetError>;
|
||||||
|
|
||||||
|
fn resume_worker(&self) -> Result<WorkerResume, TargetError>;
|
||||||
|
|
||||||
|
fn dashboard(&self) -> Result<Dashboard, TargetError>;
|
||||||
|
|
||||||
|
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError>;
|
||||||
|
|
||||||
|
fn connect_worker(
|
||||||
|
&self,
|
||||||
|
selector: WorkerConnectionSelector,
|
||||||
|
) -> Result<WorkerConnection, TargetError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Target for LocalTarget {
|
||||||
|
fn kind(&self) -> TargetKind {
|
||||||
|
TargetKind::Local
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve(&self) -> Result<ResolvedTarget, TargetError> {
|
||||||
|
Ok(ResolvedTarget::Local)
|
||||||
|
}
|
||||||
|
|
||||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
||||||
Err(TargetError::unsupported("Worker spawn", self.kind()))
|
Ok(WorkerSpawn {
|
||||||
|
runtime_command: self.runtime_command()?,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn standalone_worker_list(
|
fn worker_by_name(&self) -> Result<WorkerByName, TargetError> {
|
||||||
&self,
|
Ok(WorkerByName {
|
||||||
_include_all: bool,
|
runtime_command: self.runtime_command()?,
|
||||||
) -> Result<StandaloneWorkerListIntent, TargetError> {
|
})
|
||||||
Err(TargetError::unsupported(
|
|
||||||
"standalone Worker listing",
|
|
||||||
self.kind(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn standalone_worker_resume(
|
fn resume_worker(&self) -> Result<WorkerResume, TargetError> {
|
||||||
&self,
|
Ok(WorkerResume {
|
||||||
_worker_id: String,
|
runtime_command: self.runtime_command()?,
|
||||||
) -> Result<StandaloneWorkerResumeIntent, TargetError> {
|
})
|
||||||
Err(TargetError::unsupported(
|
|
||||||
"standalone Worker restore",
|
|
||||||
self.kind(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
||||||
Err(TargetError::unsupported("Worker dashboard", self.kind()))
|
Ok(Dashboard::Local {
|
||||||
|
runtime_command: self.runtime_command()?,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn launch_backend_worker(&self) -> Result<BackendWorkerLaunch, TargetError> {
|
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
||||||
Err(TargetError::unsupported(
|
if request.runtime_id.is_some() {
|
||||||
"Backend Worker launch",
|
return Err(TargetError::unsupported(
|
||||||
self.kind(),
|
"Explicit runtime id for local worker listing",
|
||||||
))
|
self.kind(),
|
||||||
}
|
));
|
||||||
|
}
|
||||||
fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
Ok(WorkerList {
|
||||||
Err(TargetError::unsupported("Worker listing", self.kind()))
|
local_runtime_command: Some(self.runtime_command()?),
|
||||||
|
backend_target: None,
|
||||||
|
include_stopped: request.include_stopped,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn connect_worker(
|
fn connect_worker(
|
||||||
@@ -226,59 +261,6 @@ pub trait Target: fmt::Debug + Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct StandaloneTarget {
|
|
||||||
state_dir: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StandaloneTarget {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new(state_dir: impl Into<PathBuf>) -> Self {
|
|
||||||
Self {
|
|
||||||
state_dir: state_dir.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Target for StandaloneTarget {
|
|
||||||
fn kind(&self) -> TargetKind {
|
|
||||||
TargetKind::Standalone
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve(&self) -> Result<ResolvedTarget, TargetError> {
|
|
||||||
Ok(ResolvedTarget::Standalone)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
|
||||||
Ok(WorkerSpawn {
|
|
||||||
state_dir: self.state_dir.clone(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn standalone_worker_list(
|
|
||||||
&self,
|
|
||||||
include_all: bool,
|
|
||||||
) -> Result<StandaloneWorkerListIntent, TargetError> {
|
|
||||||
let cwd = std::env::current_dir()
|
|
||||||
.map_err(|error| TargetError::invalid(self.kind(), error.to_string()))?;
|
|
||||||
Ok(StandaloneWorkerListIntent {
|
|
||||||
state_dir: self.state_dir.clone(),
|
|
||||||
cwd,
|
|
||||||
include_all,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn standalone_worker_resume(
|
|
||||||
&self,
|
|
||||||
worker_id: String,
|
|
||||||
) -> Result<StandaloneWorkerResumeIntent, TargetError> {
|
|
||||||
Ok(StandaloneWorkerResumeIntent {
|
|
||||||
state_dir: self.state_dir.clone(),
|
|
||||||
worker_id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Target for BackendTarget {
|
impl Target for BackendTarget {
|
||||||
fn kind(&self) -> TargetKind {
|
fn kind(&self) -> TargetKind {
|
||||||
TargetKind::Backend
|
TargetKind::Backend
|
||||||
@@ -297,36 +279,42 @@ impl Target for BackendTarget {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
||||||
let ResolvedTarget::Backend {
|
Err(TargetError::unsupported("Worker spawn", self.kind()))
|
||||||
base_url,
|
|
||||||
workspace_id,
|
|
||||||
} = self.resolve()?
|
|
||||||
else {
|
|
||||||
unreachable!("BackendTarget resolves only Backend authority")
|
|
||||||
};
|
|
||||||
Ok(Dashboard {
|
|
||||||
base_url,
|
|
||||||
workspace_id,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn launch_backend_worker(&self) -> Result<BackendWorkerLaunch, TargetError> {
|
fn worker_by_name(&self) -> Result<WorkerByName, TargetError> {
|
||||||
Ok(BackendWorkerLaunch {
|
Err(TargetError::unsupported(
|
||||||
target: BackendWorkerLaunchTarget::new(
|
"Worker name attachment",
|
||||||
self.base_url.clone(),
|
self.kind(),
|
||||||
self.workspace_id.clone(),
|
))
|
||||||
),
|
}
|
||||||
})
|
|
||||||
|
fn resume_worker(&self) -> Result<WorkerResume, TargetError> {
|
||||||
|
Err(TargetError::unsupported("Worker resume", self.kind()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
||||||
|
match self.resolve()? {
|
||||||
|
ResolvedTarget::Backend {
|
||||||
|
base_url,
|
||||||
|
workspace_id,
|
||||||
|
} => Ok(Dashboard::Backend {
|
||||||
|
base_url,
|
||||||
|
workspace_id,
|
||||||
|
}),
|
||||||
|
ResolvedTarget::Local => unreachable!("BackendTarget cannot resolve as Local"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
||||||
Ok(WorkerList {
|
Ok(WorkerList {
|
||||||
backend_target: BackendRuntimeListTarget::new(
|
local_runtime_command: None,
|
||||||
|
backend_target: Some(BackendRuntimeListTarget::new(
|
||||||
self.base_url.clone(),
|
self.base_url.clone(),
|
||||||
self.workspace_id.clone(),
|
self.workspace_id.clone(),
|
||||||
request.runtime_id,
|
request.runtime_id,
|
||||||
),
|
)),
|
||||||
include_stopped: request.include_stopped,
|
include_stopped: request.include_stopped,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -383,34 +371,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn standalone_target_carries_in_process_state_without_runtime_command() {
|
fn local_target_resolves_local_product_state_authority() {
|
||||||
let target = StandaloneTarget::new("/tmp/yoi-standalone-state");
|
assert_eq!(LocalTarget::new().resolve().unwrap(), ResolvedTarget::Local);
|
||||||
|
|
||||||
assert_eq!(target.kind(), TargetKind::Standalone);
|
|
||||||
assert_eq!(target.resolve().unwrap(), ResolvedTarget::Standalone);
|
|
||||||
assert_eq!(
|
|
||||||
target.spawn_worker().unwrap(),
|
|
||||||
WorkerSpawn {
|
|
||||||
state_dir: PathBuf::from("/tmp/yoi-standalone-state"),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn standalone_target_never_exposes_workspace_worker_operations() {
|
|
||||||
let target = StandaloneTarget::new("/tmp/yoi-standalone-state");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
target
|
|
||||||
.list_workers(WorkerListRequest::new(None))
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string(),
|
|
||||||
"Worker listing is not supported by Standalone target"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
target.dashboard().unwrap_err().to_string(),
|
|
||||||
"Worker dashboard is not supported by Standalone target"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -419,13 +381,26 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
target.dashboard().unwrap(),
|
target.dashboard().unwrap(),
|
||||||
Dashboard {
|
Dashboard::Backend {
|
||||||
base_url: "http://127.0.0.1:8787".to_string(),
|
base_url: "http://127.0.0.1:8787".to_string(),
|
||||||
workspace_id: "workspace-a".to_string(),
|
workspace_id: "workspace-a".to_string(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_target_rejects_dashboard_without_workspace_selection() {
|
||||||
|
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
target
|
||||||
|
.dashboard()
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("workspace selection is required")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_target_builds_worker_list() {
|
fn backend_target_builds_worker_list() {
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
||||||
@@ -433,13 +408,26 @@ mod tests {
|
|||||||
.list_workers(WorkerListRequest::new(Some("runtime-a".to_string())))
|
.list_workers(WorkerListRequest::new(Some("runtime-a".to_string())))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(workers.backend_target.base_url, "http://127.0.0.1:8787");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workers.backend_target.workspace_id.as_deref(),
|
workers.backend_target.as_ref().unwrap().base_url,
|
||||||
|
"http://127.0.0.1:8787"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
workers
|
||||||
|
.backend_target
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.workspace_id
|
||||||
|
.as_deref(),
|
||||||
Some("workspace-a")
|
Some("workspace-a")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workers.backend_target.runtime_id.as_deref(),
|
workers
|
||||||
|
.backend_target
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.runtime_id
|
||||||
|
.as_deref(),
|
||||||
Some("runtime-a")
|
Some("runtime-a")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -458,17 +446,41 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn standalone_target_builds_explicit_worker_intents() {
|
fn backend_target_rejects_worker_connection_before_workspace_selection() {
|
||||||
let target = StandaloneTarget::new("/tmp/yoi-client-workers");
|
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
||||||
let list = target.standalone_worker_list(true).unwrap();
|
let error =
|
||||||
assert_eq!(list.state_dir, PathBuf::from("/tmp/yoi-client-workers"));
|
match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-b")) {
|
||||||
assert!(list.include_all);
|
Ok(_) => panic!("unscoped connection must fail"),
|
||||||
assert!(list.cwd.is_absolute());
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
|
||||||
let resume = target
|
assert!(
|
||||||
.standalone_worker_resume("019d1234-0000-7000-8000-000000000000".to_string())
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("workspace selection is required")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_target_rejects_local_worker_operations() {
|
||||||
|
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
||||||
|
let err = target.spawn_worker().unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
err.to_string(),
|
||||||
|
"Worker spawn is not supported by Backend target"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_target_builds_local_worker_list() {
|
||||||
|
let target = LocalTarget::new();
|
||||||
|
let workers = target
|
||||||
|
.list_workers(WorkerListRequest::with_stopped(None))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resume.state_dir, list.state_dir);
|
|
||||||
assert_eq!(resume.worker_id, "019d1234-0000-7000-8000-000000000000");
|
assert!(workers.local_runtime_command.is_some());
|
||||||
|
assert!(workers.backend_target.is_none());
|
||||||
|
assert!(workers.include_stopped);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,116 +0,0 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use thiserror::Error;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
|
|
||||||
use super::Socket as SocketContract;
|
|
||||||
|
|
||||||
const CHANNEL_CAPACITY: usize = 256;
|
|
||||||
|
|
||||||
pub struct Socket {
|
|
||||||
outgoing: mpsc::Sender<String>,
|
|
||||||
incoming: mpsc::Receiver<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Host-side endpoint paired with an in-process client transport.
|
|
||||||
pub struct Peer {
|
|
||||||
incoming: mpsc::Receiver<String>,
|
|
||||||
outgoing: mpsc::Sender<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
|
||||||
pub enum SocketError {
|
|
||||||
#[error("in-process Worker protocol transport closed")]
|
|
||||||
Closed,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Socket {
|
|
||||||
pub fn pair() -> (Self, Peer) {
|
|
||||||
let (client_tx, peer_rx) = mpsc::channel(CHANNEL_CAPACITY);
|
|
||||||
let (peer_tx, client_rx) = mpsc::channel(CHANNEL_CAPACITY);
|
|
||||||
(
|
|
||||||
Self {
|
|
||||||
outgoing: client_tx,
|
|
||||||
incoming: client_rx,
|
|
||||||
},
|
|
||||||
Peer {
|
|
||||||
incoming: peer_rx,
|
|
||||||
outgoing: peer_tx,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl SocketContract for Socket {
|
|
||||||
type Error = SocketError;
|
|
||||||
|
|
||||||
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
|
|
||||||
self.outgoing
|
|
||||||
.send(message)
|
|
||||||
.await
|
|
||||||
.map_err(|_| SocketError::Closed)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
|
|
||||||
Ok(self.incoming.recv().await)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
|
|
||||||
match self.incoming.try_recv() {
|
|
||||||
Ok(message) => Ok(Some(message)),
|
|
||||||
Err(mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected) => {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Peer {
|
|
||||||
pub async fn next(&mut self) -> Option<String> {
|
|
||||||
self.incoming.recv().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send(&self, message: String) -> Result<(), String> {
|
|
||||||
self.outgoing.send(message).await.map_err(|error| error.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use protocol::stream::{decode_method, encode_event};
|
|
||||||
use protocol::{Event, Method, WorkerStatus};
|
|
||||||
|
|
||||||
use super::Socket;
|
|
||||||
use crate::Client;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn pair_carries_typed_protocol_through_generic_client() {
|
|
||||||
let (socket, mut peer) = Socket::pair();
|
|
||||||
let mut client = Client::new(socket);
|
|
||||||
|
|
||||||
client
|
|
||||||
.send(&Method::submit_text(
|
|
||||||
protocol::new_submission_request_id(),
|
|
||||||
"hello",
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("send method");
|
|
||||||
assert!(matches!(
|
|
||||||
peer.next().await.as_deref().map(decode_method),
|
|
||||||
Some(Ok(Method::Submit { .. }))
|
|
||||||
));
|
|
||||||
|
|
||||||
peer.send(
|
|
||||||
encode_event(&Event::WorkerState {
|
|
||||||
snapshot: WorkerStatus::Idle.into(),
|
|
||||||
})
|
|
||||||
.expect("encode event"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("send event");
|
|
||||||
assert!(matches!(
|
|
||||||
client.next_event().await,
|
|
||||||
Ok(Some(Event::WorkerState { .. }))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
use std::error::Error;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
pub mod in_process;
|
|
||||||
pub mod unix_socket;
|
|
||||||
pub mod websocket;
|
|
||||||
|
|
||||||
/// Message-oriented transport for one Worker protocol connection.
|
|
||||||
///
|
|
||||||
/// Implementations own physical framing. `client::Client` owns the typed
|
|
||||||
/// Method/Event protocol encoding layered on top of these UTF-8 messages.
|
|
||||||
#[async_trait]
|
|
||||||
pub trait Socket {
|
|
||||||
type Error: Error + Send + Sync + 'static;
|
|
||||||
|
|
||||||
async fn send(&mut self, message: String) -> Result<(), Self::Error>;
|
|
||||||
|
|
||||||
async fn next(&mut self) -> Result<Option<String>, Self::Error>;
|
|
||||||
|
|
||||||
fn try_next(&mut self) -> Result<Option<String>, Self::Error>;
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
use std::io;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
||||||
use tokio::net::UnixStream;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
|
|
||||||
use super::Socket as SocketContract;
|
|
||||||
|
|
||||||
pub struct Socket {
|
|
||||||
writer: tokio::io::WriteHalf<UnixStream>,
|
|
||||||
messages: mpsc::Receiver<io::Result<String>>,
|
|
||||||
reader_task: JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Socket {
|
|
||||||
pub async fn connect(path: &Path) -> io::Result<Self> {
|
|
||||||
let stream = UnixStream::connect(path).await?;
|
|
||||||
let (reader, writer) = tokio::io::split(stream);
|
|
||||||
let (message_tx, messages) = mpsc::channel(256);
|
|
||||||
let reader_task = tokio::spawn(async move {
|
|
||||||
let mut lines = BufReader::new(reader).lines();
|
|
||||||
loop {
|
|
||||||
match lines.next_line().await {
|
|
||||||
Ok(Some(message)) if message.trim().is_empty() => {}
|
|
||||||
Ok(Some(message)) => {
|
|
||||||
if message_tx.send(Ok(message)).await.is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => return,
|
|
||||||
Err(error) => {
|
|
||||||
let _ = message_tx.send(Err(error)).await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Ok(Self {
|
|
||||||
writer,
|
|
||||||
messages,
|
|
||||||
reader_task,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl SocketContract for Socket {
|
|
||||||
type Error = io::Error;
|
|
||||||
|
|
||||||
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
|
|
||||||
self.writer.write_all(message.as_bytes()).await?;
|
|
||||||
self.writer.write_all(b"\n").await?;
|
|
||||||
self.writer.flush().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
|
|
||||||
match self.messages.recv().await {
|
|
||||||
Some(message) => message.map(Some),
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
|
|
||||||
match self.messages.try_recv() {
|
|
||||||
Ok(message) => message.map(Some),
|
|
||||||
Err(mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected) => {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for Socket {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.reader_task.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::io::ErrorKind;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use protocol::stream::{decode_method, encode_event};
|
|
||||||
use protocol::{Event, Method, WorkerStatus};
|
|
||||||
use tempfile::tempdir;
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::net::UnixListener;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::Client;
|
|
||||||
|
|
||||||
async fn assert_peer_closed(stream: &mut UnixStream, reason: &str) {
|
|
||||||
let mut buf = [0_u8; 1];
|
|
||||||
match tokio::time::timeout(Duration::from_secs(1), stream.read(&mut buf))
|
|
||||||
.await
|
|
||||||
.expect(reason)
|
|
||||||
{
|
|
||||||
Ok(0) => {}
|
|
||||||
Err(error) if error.kind() == ErrorKind::ConnectionReset => {}
|
|
||||||
Ok(n) => panic!("server should observe peer close, read {n} byte(s)"),
|
|
||||||
Err(error) => panic!("server read failed unexpectedly: {error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn client_receives_events_over_unix_socket() {
|
|
||||||
let socket_dir = tempdir().unwrap();
|
|
||||||
let socket_path = socket_dir.path().join("events.sock");
|
|
||||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let (mut stream, _) = listener.accept().await.unwrap();
|
|
||||||
let event = encode_event(&Event::WorkerState {
|
|
||||||
snapshot: WorkerStatus::Idle.into(),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
stream.write_all(event.as_bytes()).await.unwrap();
|
|
||||||
stream.write_all(b"\n").await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut client = Client::new(Socket::connect(&socket_path).await.unwrap());
|
|
||||||
let event = tokio::time::timeout(Duration::from_secs(1), client.next_event())
|
|
||||||
.await
|
|
||||||
.expect("client should receive event while alive")
|
|
||||||
.expect("transport should succeed");
|
|
||||||
assert!(matches!(event, Some(Event::WorkerState { .. })));
|
|
||||||
server.await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn client_sends_methods_over_unix_socket() {
|
|
||||||
let socket_dir = tempdir().unwrap();
|
|
||||||
let socket_path = socket_dir.path().join("send.sock");
|
|
||||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let (reader, _) = listener.accept().await.unwrap();
|
|
||||||
BufReader::new(reader).lines().next_line().await.unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut client = Client::new(Socket::connect(&socket_path).await.unwrap());
|
|
||||||
client
|
|
||||||
.send(&Method::submit_text(
|
|
||||||
protocol::new_submission_request_id(),
|
|
||||||
"hello",
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("send method");
|
|
||||||
|
|
||||||
let received = server.await.unwrap().expect("method message");
|
|
||||||
assert!(matches!(
|
|
||||||
decode_method(&received),
|
|
||||||
Ok(Method::Submit { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dropping_socket_closes_server_connection() {
|
|
||||||
let socket_dir = tempdir().unwrap();
|
|
||||||
let socket_path = socket_dir.path().join("drop.sock");
|
|
||||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let (mut stream, _) = listener.accept().await.unwrap();
|
|
||||||
assert_peer_closed(&mut stream, "dropped socket should close promptly").await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let socket = Socket::connect(&socket_path).await.unwrap();
|
|
||||||
drop(socket);
|
|
||||||
server.await.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
use async_trait::async_trait;
|
|
||||||
use futures::{SinkExt, StreamExt};
|
|
||||||
use thiserror::Error;
|
|
||||||
use tokio::net::TcpStream;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
use tokio_tungstenite::tungstenite::http::Request;
|
|
||||||
use tokio_tungstenite::tungstenite::{self, Message};
|
|
||||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
|
|
||||||
|
|
||||||
use super::Socket as SocketContract;
|
|
||||||
|
|
||||||
type Writer = futures::stream::SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>;
|
|
||||||
|
|
||||||
pub struct Socket {
|
|
||||||
writer: Writer,
|
|
||||||
messages: mpsc::Receiver<Result<String, SocketError>>,
|
|
||||||
reader_task: JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum SocketError {
|
|
||||||
#[error("WebSocket transport failed: {0}")]
|
|
||||||
WebSocket(#[from] tungstenite::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Socket {
|
|
||||||
pub async fn connect(request: Request<()>) -> Result<Self, SocketError> {
|
|
||||||
let (stream, _) = connect_async(request).await?;
|
|
||||||
let (writer, mut reader) = stream.split();
|
|
||||||
let (message_tx, messages) = mpsc::channel(256);
|
|
||||||
let reader_task = tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
match reader.next().await {
|
|
||||||
Some(Ok(Message::Text(message))) => {
|
|
||||||
if message_tx.send(Ok(message.to_string())).await.is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Ok(Message::Close(_))) | None => return,
|
|
||||||
Some(Ok(
|
|
||||||
Message::Binary(_)
|
|
||||||
| Message::Ping(_)
|
|
||||||
| Message::Pong(_)
|
|
||||||
| Message::Frame(_),
|
|
||||||
)) => {}
|
|
||||||
Some(Err(error)) => {
|
|
||||||
let _ = message_tx.send(Err(SocketError::WebSocket(error))).await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Ok(Self {
|
|
||||||
writer,
|
|
||||||
messages,
|
|
||||||
reader_task,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl SocketContract for Socket {
|
|
||||||
type Error = SocketError;
|
|
||||||
|
|
||||||
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
|
|
||||||
self.writer.send(Message::Text(message.into())).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
|
|
||||||
match self.messages.recv().await {
|
|
||||||
Some(message) => message.map(Some),
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
|
|
||||||
match self.messages.try_recv() {
|
|
||||||
Ok(message) => message.map(Some),
|
|
||||||
Err(mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected) => {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for Socket {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.reader_task.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use futures::{SinkExt, StreamExt};
|
|
||||||
use protocol::stream::{decode_method, encode_event};
|
|
||||||
use protocol::{Event, Method, WorkerStatus};
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
use tokio_tungstenite::accept_async;
|
|
||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::Client;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn carries_typed_protocol_through_generic_client() {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let address = listener.local_addr().unwrap();
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let (stream, _) = listener.accept().await.unwrap();
|
|
||||||
let mut socket = accept_async(stream).await.unwrap();
|
|
||||||
let message = socket.next().await.unwrap().unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
message,
|
|
||||||
Message::Text(ref text)
|
|
||||||
if matches!(decode_method(text), Ok(Method::Submit { .. }))
|
|
||||||
));
|
|
||||||
let event = encode_event(&Event::WorkerState {
|
|
||||||
snapshot: WorkerStatus::Idle.into(),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
socket.send(Message::Text(event.into())).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
let request = format!("ws://{address}").into_client_request().unwrap();
|
|
||||||
let mut client = Client::new(Socket::connect(request).await.unwrap());
|
|
||||||
client
|
|
||||||
.send(&Method::submit_text(
|
|
||||||
protocol::new_submission_request_id(),
|
|
||||||
"hello",
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("send method");
|
|
||||||
assert!(matches!(
|
|
||||||
client.next_event().await,
|
|
||||||
Ok(Some(Event::WorkerState { .. }))
|
|
||||||
));
|
|
||||||
server.await.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
use std::io;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||||
|
use protocol::{Event, Method};
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
pub struct WorkerClient {
|
||||||
|
writer: JsonLineWriter<tokio::io::WriteHalf<UnixStream>>,
|
||||||
|
event_rx: mpsc::Receiver<Event>,
|
||||||
|
reader_task: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerClient {
|
||||||
|
pub async fn connect(path: &Path) -> Result<Self, io::Error> {
|
||||||
|
let stream = UnixStream::connect(path).await?;
|
||||||
|
let (reader, writer) = tokio::io::split(stream);
|
||||||
|
let writer = JsonLineWriter::new(writer);
|
||||||
|
|
||||||
|
let (event_tx, event_rx) = mpsc::channel::<Event>(256);
|
||||||
|
|
||||||
|
let reader_task = tokio::spawn(async move {
|
||||||
|
let mut reader = JsonLineReader::new(reader);
|
||||||
|
while let Ok(Some(event)) = reader.next::<Event>().await {
|
||||||
|
if event_tx.send(event).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
writer,
|
||||||
|
event_rx,
|
||||||
|
reader_task,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send(&mut self, method: &Method) -> Result<(), io::Error> {
|
||||||
|
self.writer.write(method).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_next_event(&mut self) -> Option<Event> {
|
||||||
|
self.event_rx.try_recv().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn next_event(&mut self) -> Option<Event> {
|
||||||
|
self.event_rx.recv().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for WorkerClient {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.reader_task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use protocol::{Segment, WorkerStatus};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::UnixListener;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
async fn assert_peer_closed(stream: &mut UnixStream, reason: &str) {
|
||||||
|
let mut buf = [0_u8; 1];
|
||||||
|
match tokio::time::timeout(Duration::from_secs(1), stream.read(&mut buf))
|
||||||
|
.await
|
||||||
|
.expect(reason)
|
||||||
|
{
|
||||||
|
Ok(0) => {}
|
||||||
|
Err(error) if error.kind() == ErrorKind::ConnectionReset => {}
|
||||||
|
Ok(n) => panic!("server should observe peer close, read {n} byte(s)"),
|
||||||
|
Err(error) => panic!("server read failed unexpectedly: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn receives_events_while_client_is_alive() {
|
||||||
|
let socket_dir = tempdir().unwrap();
|
||||||
|
let socket_path = socket_dir.path().join("events.sock");
|
||||||
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let mut writer = JsonLineWriter::new(stream);
|
||||||
|
writer
|
||||||
|
.write(&Event::Status {
|
||||||
|
status: WorkerStatus::Idle,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut client = WorkerClient::connect(&socket_path).await.unwrap();
|
||||||
|
|
||||||
|
let event = tokio::time::timeout(Duration::from_secs(1), client.next_event())
|
||||||
|
.await
|
||||||
|
.expect("client should receive event while alive");
|
||||||
|
assert!(matches!(
|
||||||
|
event,
|
||||||
|
Some(Event::Status {
|
||||||
|
status: WorkerStatus::Idle
|
||||||
|
})
|
||||||
|
));
|
||||||
|
server.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_writes_methods_while_client_is_alive() {
|
||||||
|
let socket_dir = tempdir().unwrap();
|
||||||
|
let socket_path = socket_dir.path().join("send.sock");
|
||||||
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let mut reader = JsonLineReader::new(stream);
|
||||||
|
reader.next::<Method>().await.unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut client = WorkerClient::connect(&socket_path).await.unwrap();
|
||||||
|
let method = Method::Run {
|
||||||
|
input: vec![Segment::text("hello")],
|
||||||
|
};
|
||||||
|
client.send(&method).await.unwrap();
|
||||||
|
|
||||||
|
let received = tokio::time::timeout(Duration::from_secs(1), server)
|
||||||
|
.await
|
||||||
|
.expect("server should receive method while client is alive")
|
||||||
|
.unwrap();
|
||||||
|
match received {
|
||||||
|
Some(Method::Run { input }) => assert_eq!(input, vec![Segment::text("hello")]),
|
||||||
|
other => panic!("expected Run method, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dropping_repeated_clients_closes_server_connections() {
|
||||||
|
let socket_dir = tempdir().unwrap();
|
||||||
|
let socket_path = socket_dir.path().join("drop.sock");
|
||||||
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
for _ in 0..16 {
|
||||||
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
|
assert_peer_closed(
|
||||||
|
&mut stream,
|
||||||
|
"dropped client should close its socket promptly",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for _ in 0..16 {
|
||||||
|
let client = WorkerClient::connect(&socket_path).await.unwrap();
|
||||||
|
drop(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
server.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dropping_client_aborts_blocked_reader_task() {
|
||||||
|
let socket_dir = tempdir().unwrap();
|
||||||
|
let socket_path = socket_dir.path().join("blocked-reader.sock");
|
||||||
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
|
stream.write_all(b"{\"event\"").await.unwrap();
|
||||||
|
assert_peer_closed(
|
||||||
|
&mut stream,
|
||||||
|
"aborting the blocked client reader should close the socket",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let client = WorkerClient::connect(&socket_path).await.unwrap();
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
drop(client);
|
||||||
|
|
||||||
|
server.await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use serde::Serialize;
|
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use ticket::{
|
use ticket::{
|
||||||
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
||||||
OrchestrationPlanKind, OrchestrationPlanRecord, Ticket, TicketBackend, TicketDependencyCheck,
|
OrchestrationPlanKind, OrchestrationPlanRecord, Ticket, TicketBackend, TicketDependencyCheck,
|
||||||
@@ -9,27 +9,47 @@ use ticket::{
|
|||||||
TicketRelationKind, TicketRelationView, TicketStateChange, TicketStateSelector, TicketSummary,
|
TicketRelationKind, TicketRelationView, TicketStateChange, TicketStateSelector, TicketSummary,
|
||||||
};
|
};
|
||||||
use workspace_api::{
|
use workspace_api::{
|
||||||
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
ListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest,
|
||||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary,
|
||||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||||
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
|
|
||||||
RuntimeTrustKeyRevealResponse, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
|
||||||
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
|
|
||||||
WorkspaceRuntimeResource,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
use crate::BackendWorkspaceClientError;
|
||||||
|
|
||||||
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BackendWorkerLaunchOptions {
|
||||||
|
runtimes: Vec<BackendWorkerLaunchRuntime>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BackendWorkerLaunchRuntime {
|
||||||
|
runtime_id: String,
|
||||||
|
can_spawn_worker: bool,
|
||||||
|
working_directory_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BackendCreateWorkerResponse {
|
||||||
|
runtime_id: String,
|
||||||
|
worker_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BackendWorkspaceOrchestratorResponse {
|
||||||
|
disposition: String,
|
||||||
|
worker: Option<BackendCreateWorkerResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Workspace-scoped Backend client for Ticket and Objective product state.
|
/// Workspace-scoped Backend client for Ticket and Objective product state.
|
||||||
///
|
///
|
||||||
/// Construction requires both the selected Backend URL and Workspace identity.
|
/// Construction requires both the selected Backend URL and Workspace identity.
|
||||||
/// Callers should derive these once from `Target::resolve()` and must not retry
|
/// Callers should derive these once from `Target::resolve()` and must not retry
|
||||||
/// failed requests against repository-local state.
|
/// failed requests against repository-local state.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct BackendWorkspaceProductClient {
|
pub struct BackendWorkspaceProductClient {
|
||||||
api: BackendApiClient,
|
base_url: String,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,32 +58,22 @@ impl BackendWorkspaceProductClient {
|
|||||||
base_url: impl Into<String>,
|
base_url: impl Into<String>,
|
||||||
workspace_id: impl Into<String>,
|
workspace_id: impl Into<String>,
|
||||||
) -> Result<Self, BackendWorkspaceClientError> {
|
) -> Result<Self, BackendWorkspaceClientError> {
|
||||||
let base_url = base_url.into();
|
let base_url = base_url.into().trim_end_matches('/').to_string();
|
||||||
let api = BackendApiClient::from_stored_token(&base_url)?;
|
if base_url.is_empty() {
|
||||||
|
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||||
|
"Backend base URL must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
let workspace_id = workspace_id.into();
|
let workspace_id = workspace_id.into();
|
||||||
if workspace_id.trim().is_empty() {
|
if workspace_id.trim().is_empty() {
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||||
"Backend Workspace identity must not be empty".into(),
|
"Backend Workspace identity must not be empty".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(Self { api, workspace_id })
|
Ok(Self {
|
||||||
}
|
base_url,
|
||||||
|
workspace_id,
|
||||||
#[cfg(test)]
|
})
|
||||||
fn new_with_access_token(
|
|
||||||
base_url: impl Into<String>,
|
|
||||||
workspace_id: impl Into<String>,
|
|
||||||
access_token: &str,
|
|
||||||
) -> Result<Self, BackendWorkspaceClientError> {
|
|
||||||
let base_url = base_url.into();
|
|
||||||
let api = BackendApiClient::from_access_token_for_test(&base_url, access_token)?;
|
|
||||||
let workspace_id = workspace_id.into();
|
|
||||||
if workspace_id.trim().is_empty() {
|
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Backend Workspace identity must not be empty".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Self { api, workspace_id })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn workspace_id(&self) -> &str {
|
pub fn workspace_id(&self) -> &str {
|
||||||
@@ -243,80 +253,34 @@ impl BackendWorkspaceProductClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_runtimes(
|
|
||||||
&self,
|
|
||||||
) -> Result<ListResponse<WorkspaceRuntimeResource>, BackendWorkspaceClientError> {
|
|
||||||
self.get_json("/runtimes")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn runtime_detail(
|
|
||||||
&self,
|
|
||||||
runtime_id: &str,
|
|
||||||
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
|
||||||
self.get_json(&format!("/runtimes/{}", encode_path_segment(runtime_id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reveal_runtime_trust_key(
|
|
||||||
&self,
|
|
||||||
runtime_id: &str,
|
|
||||||
) -> Result<RuntimeTrustKeyRevealResponse, BackendWorkspaceClientError> {
|
|
||||||
self.get_json(&format!(
|
|
||||||
"/runtimes/{}/trust-key",
|
|
||||||
encode_path_segment(runtime_id)
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn revoke_runtime_trust_key(
|
|
||||||
&self,
|
|
||||||
runtime_id: &str,
|
|
||||||
request: &RevokeRuntimeTrustKeyRequest,
|
|
||||||
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
|
||||||
self.send_json(
|
|
||||||
Method::DELETE,
|
|
||||||
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
|
|
||||||
Some(request),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn memory_document(&self) -> Result<MemoryDocumentResponse, BackendWorkspaceClientError> {
|
|
||||||
self.get_json("/memory")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_memory_staging(
|
|
||||||
&self,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<MemoryStagingListResponse, BackendWorkspaceClientError> {
|
|
||||||
self.get_json(&format!("/memory/staging?limit={limit}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn launch_ticket_intake(
|
pub fn launch_ticket_intake(
|
||||||
&self,
|
&self,
|
||||||
ticket_id: &str,
|
ticket_id: &str,
|
||||||
) -> Result<String, BackendWorkspaceClientError> {
|
) -> Result<String, BackendWorkspaceClientError> {
|
||||||
let options: WorkerLaunchOptionsResponse = self.get_json("/workers/launch-options")?;
|
let options: BackendWorkerLaunchOptions = self.get_json("/workers/launch-options")?;
|
||||||
let runtime = options
|
let runtime = options
|
||||||
.runtimes
|
.runtimes
|
||||||
.iter()
|
.iter()
|
||||||
.find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required)
|
.find(|runtime| runtime.can_spawn_worker && !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"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let request = CreateWorkspaceWorkerRequest {
|
let response: BackendCreateWorkerResponse = self.send_json(
|
||||||
runtime_id: runtime.runtime_id.clone(),
|
Method::POST,
|
||||||
display_name: format!("intake-{ticket_id}"),
|
"/workers",
|
||||||
profile: Some("builtin:intake".to_string()),
|
Some(&serde_json::json!({
|
||||||
ticket_assignment: None,
|
"runtime_id": runtime.runtime_id,
|
||||||
initial_submit: vec![protocol::Segment::Text {
|
"display_name": format!("intake-{ticket_id}"),
|
||||||
content: format!("Please handle intake for Ticket {ticket_id}."),
|
"profile": "builtin:intake",
|
||||||
}],
|
"initial_submit": [{
|
||||||
working_directory: None,
|
"kind": "text",
|
||||||
control_operation_id: None,
|
"content": format!("Please handle intake for Ticket {ticket_id}.")
|
||||||
};
|
}]
|
||||||
let response: BrowserCreateWorkerResponse =
|
})),
|
||||||
self.send_json(Method::POST, "/workers", Some(&request))?;
|
)?;
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"Started Intake Worker {}/{} for Ticket {ticket_id}",
|
"Started Intake Worker {}/{} for Ticket {ticket_id}",
|
||||||
response.runtime_id, response.worker_id
|
response.runtime_id, response.worker_id
|
||||||
@@ -324,7 +288,7 @@ impl BackendWorkspaceProductClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_workspace_orchestrator(&self) -> Result<String, BackendWorkspaceClientError> {
|
pub fn start_workspace_orchestrator(&self) -> Result<String, BackendWorkspaceClientError> {
|
||||||
let response: BrowserWorkspaceOrchestratorResponse =
|
let response: BackendWorkspaceOrchestratorResponse =
|
||||||
self.send_json::<(), _>(Method::POST, "/orchestrator", None)?;
|
self.send_json::<(), _>(Method::POST, "/orchestrator", None)?;
|
||||||
let worker = response.worker.ok_or_else(|| {
|
let worker = response.worker.ok_or_else(|| {
|
||||||
BackendWorkspaceClientError::InvalidTarget(
|
BackendWorkspaceClientError::InvalidTarget(
|
||||||
@@ -352,7 +316,7 @@ impl BackendWorkspaceProductClient {
|
|||||||
body: Option<&B>,
|
body: Option<&B>,
|
||||||
) -> Result<R, BackendWorkspaceClientError> {
|
) -> Result<R, BackendWorkspaceClientError> {
|
||||||
let response = self.request(method, path, body)?.send()?;
|
let response = self.request(method, path, body)?.send()?;
|
||||||
self.api.check_status(response.status())?;
|
let response = ensure_success(response)?;
|
||||||
response.json().map_err(BackendWorkspaceClientError::Http)
|
response.json().map_err(BackendWorkspaceClientError::Http)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,8 +326,7 @@ impl BackendWorkspaceProductClient {
|
|||||||
path: &str,
|
path: &str,
|
||||||
body: Option<&B>,
|
body: Option<&B>,
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
) -> Result<(), BackendWorkspaceClientError> {
|
||||||
let response = self.request(method, path, body)?.send()?;
|
ensure_success(self.request(method, path, body)?.send()?)?;
|
||||||
self.api.check_status(response.status())?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,12 +336,14 @@ impl BackendWorkspaceProductClient {
|
|||||||
path: &str,
|
path: &str,
|
||||||
body: Option<&B>,
|
body: Option<&B>,
|
||||||
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
|
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
|
||||||
let path = format!(
|
let client = reqwest::blocking::Client::builder().build()?;
|
||||||
"/api/w/{}/{}",
|
let url = format!(
|
||||||
|
"{}/api/w/{}/{}",
|
||||||
|
self.base_url,
|
||||||
encode_path_segment(&self.workspace_id),
|
encode_path_segment(&self.workspace_id),
|
||||||
path.trim_start_matches('/')
|
path.trim_start_matches('/')
|
||||||
);
|
);
|
||||||
let request = self.api.blocking_request(method, &path)?;
|
let request = client.request(method, url);
|
||||||
Ok(match body {
|
Ok(match body {
|
||||||
Some(body) => request.json(body),
|
Some(body) => request.json(body),
|
||||||
None => request,
|
None => request,
|
||||||
@@ -623,6 +588,19 @@ fn ticket_client_error(error: BackendWorkspaceClientError) -> TicketError {
|
|||||||
TicketError::Sqlite(format!("Backend request failed: {error}"))
|
TicketError::Sqlite(format!("Backend request failed: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_success(
|
||||||
|
response: reqwest::blocking::Response,
|
||||||
|
) -> Result<reqwest::blocking::Response, BackendWorkspaceClientError> {
|
||||||
|
if response.status().is_success() {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let message = response
|
||||||
|
.text()
|
||||||
|
.unwrap_or_else(|_| "Backend request failed".to_string());
|
||||||
|
Err(BackendWorkspaceClientError::RequestFailed { status, message })
|
||||||
|
}
|
||||||
|
|
||||||
fn ticket_reference(id: &TicketIdOrSlug) -> String {
|
fn ticket_reference(id: &TicketIdOrSlug) -> String {
|
||||||
match id {
|
match id {
|
||||||
TicketIdOrSlug::Id(id) => id.to_string(),
|
TicketIdOrSlug::Id(id) => id.to_string(),
|
||||||
@@ -716,112 +694,28 @@ mod tests {
|
|||||||
(format!("http://{address}"), receiver, handle)
|
(format!("http://{address}"), receiver, handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn memory_document_uses_shared_workspace_scoped_response() {
|
|
||||||
let body = r##"{"body_md":"# Memory\\n","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-02T00:00:00Z","bytes":10,"record_source":"workspace-sqlite"}"##;
|
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
|
||||||
base_url,
|
|
||||||
"workspace-a",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let response = client.memory_document().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(response.record_source, "workspace-sqlite");
|
|
||||||
assert!(
|
|
||||||
request
|
|
||||||
.recv()
|
|
||||||
.unwrap()
|
|
||||||
.starts_with("GET /api/w/workspace-a/memory ")
|
|
||||||
);
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn memory_staging_uses_shared_dto_with_typed_origin() {
|
|
||||||
let body = r#"{"limit":10,"returned_count":1,"total_valid_count":1,"invalid_count":0,"truncated":false,"order":"imported_at_desc_candidate_id_asc","record_authority":"sqlite_workspace_authority.memory_staging","items":[{"id":"candidate-1","byte_len":128,"record":{"schema_version":1,"id":"candidate-1","extract_run_id":"run-1","source":{"segment_id":"segment-1","range":[1,2]},"kind":"decision","claim":"Keep typed provenance.","why_useful":"Prevents trust loss.","staleness":null,"evidence":[],"source_refs":[{"session_id":"session-1","segment_id":"segment-1","entry_range":[1,2],"evidence_id":"evidence-1","origin":{"kind":"worker_input","workspace_id":"workspace-a","runtime_id":"runtime-1","worker_id":"worker-1"},"evidence_kind":"worker_session_entry","label":null,"summary":null}]}}],"diagnostics":[]}"#;
|
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
|
||||||
base_url,
|
|
||||||
"workspace-a",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let response = client.list_memory_staging(10).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
response.items[0].record.source_refs[0]
|
|
||||||
.origin
|
|
||||||
.as_ref()
|
|
||||||
.unwrap()
|
|
||||||
.kind,
|
|
||||||
workspace_api::MemoryEvidenceOriginKind::WorkerInput
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
request
|
|
||||||
.recv()
|
|
||||||
.unwrap()
|
|
||||||
.starts_with("GET /api/w/workspace-a/memory/staging?limit=10 ")
|
|
||||||
);
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn memory_staging_rejects_unknown_origin_kind() {
|
|
||||||
let body = r#"{"limit":10,"returned_count":1,"total_valid_count":1,"invalid_count":0,"truncated":false,"order":"order","record_authority":"authority","items":[{"id":"candidate-1","byte_len":1,"record":{"schema_version":1,"id":"candidate-1","extract_run_id":"run-1","source":{"segment_id":"segment-1","range":[1,2]},"kind":"decision","claim":"claim","why_useful":"useful","staleness":null,"evidence":[],"source_refs":[{"session_id":null,"segment_id":null,"entry_range":null,"evidence_id":null,"origin":{"kind":"future_origin"},"evidence_kind":null,"label":null,"summary":null}]}}],"diagnostics":[]}"#;
|
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
|
||||||
base_url,
|
|
||||||
"workspace-a",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let error = client.list_memory_staging(10).unwrap_err();
|
|
||||||
|
|
||||||
assert!(matches!(error, BackendWorkspaceClientError::Http(_)));
|
|
||||||
assert!(
|
|
||||||
request
|
|
||||||
.recv()
|
|
||||||
.unwrap()
|
|
||||||
.starts_with("GET /api/w/workspace-a/memory/staging?limit=10 ")
|
|
||||||
);
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
fn objective_list_uses_workspace_scoped_backend_route() {
|
||||||
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
let (base_url, request, handle) = one_response_server("200 OK", body);
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||||
base_url,
|
|
||||||
"workspace-a",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let response = client.list_objectives(1_000).unwrap();
|
let response = client.list_objectives(1_000).unwrap();
|
||||||
|
|
||||||
assert!(response.items.is_empty());
|
assert!(response.items.is_empty());
|
||||||
let request = request.recv().unwrap();
|
assert!(
|
||||||
assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 "));
|
request
|
||||||
assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
|
.recv()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("GET /api/w/workspace-a/objectives?limit=1000 ")
|
||||||
|
);
|
||||||
handle.join().unwrap();
|
handle.join().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_mutation_failure_is_returned_without_local_fallback() {
|
fn backend_mutation_failure_is_returned_without_local_fallback() {
|
||||||
let (base_url, request, handle) =
|
let (base_url, request, handle) = one_response_server("403 Forbidden", "denied");
|
||||||
one_response_server("403 Forbidden", "test-backend-token");
|
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
|
||||||
base_url,
|
|
||||||
"workspace-a",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let error = client
|
let error = client
|
||||||
.create_objective(&ObjectiveCreateRequest {
|
.create_objective(&ObjectiveCreateRequest {
|
||||||
@@ -833,7 +727,6 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(error.to_string().contains("403"));
|
assert!(error.to_string().contains("403"));
|
||||||
assert!(!error.to_string().contains("test-backend-token"));
|
|
||||||
assert!(
|
assert!(
|
||||||
request
|
request
|
||||||
.recv()
|
.recv()
|
||||||
@@ -846,12 +739,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ticket_relation_query_uses_workspace_scoped_backend_route() {
|
fn ticket_relation_query_uses_workspace_scoped_backend_route() {
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||||
base_url,
|
|
||||||
"workspace-a",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let relations = client
|
let relations = client
|
||||||
.query_ticket_relations(
|
.query_ticket_relations(
|
||||||
@@ -870,12 +758,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
|
fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||||
base_url,
|
|
||||||
"workspace-a",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap();
|
let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap();
|
||||||
|
|
||||||
@@ -894,19 +777,14 @@ 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#"{"workspace_id":"workspace-a","runtimes":[{"runtime_id":"embedded","display_name":"Embedded","built_in":true,"worker_creation_available":true,"working_directory_required":false,"status":"connected","diagnostics":[]}],"default_profile":null,"profiles":[],"repositories":[],"working_directories":[],"diagnostics":[]}"#,
|
r#"{"runtimes":[{"runtime_id":"embedded","can_spawn_worker":true,"working_directory_required":false}]}"#,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"200 OK",
|
"200 OK",
|
||||||
r#"{"workspace_id":"workspace-a","runtime_id":"embedded","worker_id":"worker-1","console_href":"/w/workspace-a/workers/worker-1","worker":{"runtime_id":"embedded","worker_id":"worker-1","host_id":"embedded","display_name":"Intake","label":"worker-1","profile":"builtin:intake","singleton_key":null,"tags":[],"workspace":{"visibility":"workspace","identity":"workspace-a","workspace_id":"workspace-a"},"state":"idle","last_seen_at":null,"pinned":false,"retention_state":"active","implementation":{"kind":"runtime","display_hint":"Runtime Worker"},"capabilities":{"can_stop":true,"can_spawn_followup":false},"diagnostics":[]},"diagnostics":[]}"#,
|
r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||||
base_url,
|
|
||||||
"workspace-a",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let status = client.launch_ticket_intake("T-1").unwrap();
|
let status = client.launch_ticket_intake("T-1").unwrap();
|
||||||
|
|
||||||
@@ -926,14 +804,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
|
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
|
||||||
let body = r#"{"workspace_id":"workspace-a","online":true,"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2","host_id":"embedded","display_name":"Orchestrator","label":"worker-2","profile":"builtin:orchestrator","singleton_key":"workspace-orchestrator","tags":[],"workspace":{"visibility":"workspace","identity":"workspace-a","workspace_id":"workspace-a"},"state":"idle","last_seen_at":null,"pinned":true,"retention_state":"active","implementation":{"kind":"runtime","display_hint":"Runtime Worker"},"capabilities":{"can_stop":true,"can_spawn_followup":false},"diagnostics":[]},"diagnostics":[]}"#;
|
let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#;
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
let (base_url, request, handle) = one_response_server("200 OK", body);
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||||
base_url,
|
|
||||||
"workspace-a",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let status = client.start_workspace_orchestrator().unwrap();
|
let status = client.start_workspace_orchestrator().unwrap();
|
||||||
|
|
||||||
@@ -949,12 +822,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn product_client_requires_workspace_identity() {
|
fn product_client_requires_workspace_identity() {
|
||||||
let error = BackendWorkspaceProductClient::new_with_access_token(
|
let error = BackendWorkspaceProductClient::new("http://127.0.0.1:8787", "").unwrap_err();
|
||||||
"http://127.0.0.1:8787",
|
|
||||||
"",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(error.to_string().contains("Workspace identity"));
|
assert!(error.to_string().contains("Workspace identity"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,21 +9,14 @@ fn workspace_creation_request_preserves_operation_key_for_retry() {
|
|||||||
operation_key: "workspace-create-1".to_string(),
|
operation_key: "workspace-create-1".to_string(),
|
||||||
display_name: "Alpha".to_string(),
|
display_name: "Alpha".to_string(),
|
||||||
repository: CreateBackendWorkspaceRepository {
|
repository: CreateBackendWorkspaceRepository {
|
||||||
repository_key: "main".to_string(),
|
|
||||||
uri: "/srv/repos/alpha".to_string(),
|
uri: "/srv/repos/alpha".to_string(),
|
||||||
|
display_name: Some("Main".to_string()),
|
||||||
default_ref: Some("develop".to_string()),
|
default_ref: Some("develop".to_string()),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(request.clone(), request);
|
assert_eq!(request.clone(), request);
|
||||||
assert_eq!(request.operation_key, "workspace-create-1");
|
assert_eq!(request.operation_key, "workspace-create-1");
|
||||||
let json = serde_json::to_value(&request).unwrap();
|
|
||||||
assert_eq!(json["operation_key"], "workspace-create-1");
|
|
||||||
assert_eq!(json["repository"]["repository_key"], "main");
|
|
||||||
assert_eq!(json["repository"]["uri"], "/srv/repos/alpha");
|
|
||||||
assert!(json.get("operation_id").is_none());
|
|
||||||
assert!(json["repository"].get("display_name").is_none());
|
|
||||||
assert!(json["repository"].get("source").is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -101,24 +101,20 @@ pub fn complete_current(
|
|||||||
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
|
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
|
||||||
let result = session_environment(snapshot.clone())
|
let result = session_environment(snapshot.clone())
|
||||||
.complete_config(&entrypoint, &source, utf8_byte_offset, explicit)
|
.complete_config(&entrypoint, &source, utf8_byte_offset, explicit)
|
||||||
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?;
|
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?
|
||||||
let result = result
|
.map(|result| WasmCompletionResult {
|
||||||
.map(|result| {
|
from: result.from,
|
||||||
Ok::<WasmCompletionResult, JsValue>(WasmCompletionResult {
|
items: result
|
||||||
from: utf8_to_utf16_offset(&source, result.from)?,
|
.items
|
||||||
items: result
|
.into_iter()
|
||||||
.items
|
.map(|item| WasmCompletionItem {
|
||||||
.into_iter()
|
label: item.label,
|
||||||
.map(|item| WasmCompletionItem {
|
kind: format!("{:?}", item.kind).to_lowercase(),
|
||||||
label: item.label,
|
detail: item.detail,
|
||||||
kind: format!("{:?}", item.kind).to_lowercase(),
|
priority: item.priority,
|
||||||
detail: item.detail,
|
})
|
||||||
priority: item.priority,
|
.collect(),
|
||||||
})
|
});
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.transpose()?;
|
|
||||||
encode(result)
|
encode(result)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -181,16 +177,6 @@ fn utf16_to_utf8_offset(source: &str, utf16_offset: usize) -> Result<usize, JsVa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn utf8_to_utf16_offset(source: &str, utf8_offset: usize) -> Result<usize, JsValue> {
|
|
||||||
if utf8_offset > source.len() {
|
|
||||||
return Err(JsValue::from_str("UTF-8 offset is outside the source"));
|
|
||||||
}
|
|
||||||
if !source.is_char_boundary(utf8_offset) {
|
|
||||||
return Err(JsValue::from_str("UTF-8 offset splits a character"));
|
|
||||||
}
|
|
||||||
Ok(source[..utf8_offset].encode_utf16().count())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode<T: serde::de::DeserializeOwned>(value: JsValue) -> Result<T, JsValue> {
|
fn decode<T: serde::de::DeserializeOwned>(value: JsValue) -> Result<T, JsValue> {
|
||||||
from_value(value).map_err(|error| JsValue::from_str(&error.to_string()))
|
from_value(value).map_err(|error| JsValue::from_str(&error.to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1203,9 +1203,6 @@ impl SnapshotEnvironment {
|
|||||||
{
|
{
|
||||||
let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}.");
|
let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}.");
|
||||||
member_source.push_str(&context.schema_path.join("."));
|
member_source.push_str(&context.schema_path.join("."));
|
||||||
if !context.schema_path.is_empty() && context.from == utf8_byte_offset {
|
|
||||||
member_source.push('.');
|
|
||||||
}
|
|
||||||
let mut completion = LanguageService::new(self).complete(
|
let mut completion = LanguageService::new(self).complete(
|
||||||
entrypoint.as_str(),
|
entrypoint.as_str(),
|
||||||
&member_source,
|
&member_source,
|
||||||
@@ -1964,31 +1961,6 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|item| item.label == "default_profile")
|
.any(|item| item.label == "default_profile")
|
||||||
);
|
);
|
||||||
|
|
||||||
let blank_nested_source = "{ profile = { } } as WorkspaceConfigSchema";
|
|
||||||
let blank_nested_cursor = blank_nested_source.find("{ }").unwrap() + 2;
|
|
||||||
let blank_nested = environment
|
|
||||||
.complete_config(
|
|
||||||
&path("main.dcdl"),
|
|
||||||
blank_nested_source,
|
|
||||||
blank_nested_cursor,
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(blank_nested.from, blank_nested_cursor);
|
|
||||||
assert!(
|
|
||||||
blank_nested
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.any(|item| item.label == "default_profile")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!blank_nested
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.any(|item| item.label == "profile")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
|||||||
match slug {
|
match slug {
|
||||||
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
|
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
|
||||||
slug: CODER_REVIEW_FLOW_SLUG,
|
slug: CODER_REVIEW_FLOW_SLUG,
|
||||||
revision: 4,
|
revision: 3,
|
||||||
path: "builtin/flows/coder-review.dcdl",
|
path: "builtin/flows/coder-review.dcdl",
|
||||||
content: CODER_REVIEW_FLOW_SOURCE,
|
content: CODER_REVIEW_FLOW_SOURCE,
|
||||||
}),
|
}),
|
||||||
@@ -35,7 +35,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
|
|||||||
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
||||||
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
|
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
|
||||||
slug: CODER_REVIEW_FLOW_SLUG,
|
slug: CODER_REVIEW_FLOW_SLUG,
|
||||||
revision: 4,
|
revision: 3,
|
||||||
path: "builtin/flows/coder-review.dcdl",
|
path: "builtin/flows/coder-review.dcdl",
|
||||||
content: CODER_REVIEW_FLOW_SOURCE,
|
content: CODER_REVIEW_FLOW_SOURCE,
|
||||||
}];
|
}];
|
||||||
@@ -46,30 +46,6 @@ pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn coder_review_flow_uses_current_selector_ref_review_contract() {
|
|
||||||
let source = builtin_flow_source(CODER_REVIEW_FLOW_SLUG).expect("coder review Flow");
|
|
||||||
for required in [
|
|
||||||
"OpenMergeRequest",
|
|
||||||
"ShowMergeRequest",
|
|
||||||
"ReviewMergeRequest",
|
|
||||||
"CompleteMergeRequest",
|
|
||||||
"existing Merge Request `selector_from`",
|
|
||||||
"Target-only movement does not invalidate",
|
|
||||||
] {
|
|
||||||
assert!(source.content.contains(required), "missing {required}");
|
|
||||||
}
|
|
||||||
for stale in [
|
|
||||||
"MergeRequestOpen",
|
|
||||||
"MergeRequestShow",
|
|
||||||
"MergeRequestReview",
|
|
||||||
"MergeRequestComplete",
|
|
||||||
"new immutable revision",
|
|
||||||
] {
|
|
||||||
assert!(!source.content.contains(stale), "stale contract {stale}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_builtin_flow_compiles_and_matches_catalog_identity() {
|
fn every_builtin_flow_compiles_and_matches_catalog_identity() {
|
||||||
assert!(!builtin_flow_sources().is_empty());
|
assert!(!builtin_flow_sources().is_empty());
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use globset::Glob;
|
use globset::Glob;
|
||||||
use ignore::WalkBuilder;
|
use ignore::WalkBuilder;
|
||||||
|
|
||||||
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, resolve_access_path};
|
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, direct_symlink};
|
||||||
|
|
||||||
/// Execute a bounded glob entirely inside the provider process.
|
/// Execute a bounded glob entirely inside the provider process.
|
||||||
pub fn run_glob(
|
pub fn run_glob(
|
||||||
@@ -15,24 +15,26 @@ pub fn run_glob(
|
|||||||
if !root.is_absolute() {
|
if !root.is_absolute() {
|
||||||
return Err(FsError::RelativePath(root.to_path_buf()));
|
return Err(FsError::RelativePath(root.to_path_buf()));
|
||||||
}
|
}
|
||||||
let base_resolved = resolve_access_path(base).map_err(|error| FsError::Io {
|
if !access.is_readable(base) {
|
||||||
path: PathBuf::from(request.path.as_str()),
|
|
||||||
source: error,
|
|
||||||
})?;
|
|
||||||
if !access.is_readable_paths(base, &base_resolved) {
|
|
||||||
return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str())));
|
return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str())));
|
||||||
}
|
}
|
||||||
|
if let Some(info) = direct_symlink(base)
|
||||||
|
&& info.target_exists
|
||||||
|
&& info.resolved_path.is_dir()
|
||||||
|
{
|
||||||
|
return Err(FsError::SymlinkDirectoryNotTraversed {
|
||||||
|
tool: "Glob",
|
||||||
|
path: PathBuf::from(request.path.as_str()),
|
||||||
|
target: PathBuf::from("<provider-internal target>"),
|
||||||
|
});
|
||||||
|
}
|
||||||
let matcher = Glob::new(&request.pattern)
|
let matcher = Glob::new(&request.pattern)
|
||||||
.map_err(|error| FsError::InvalidGlob(error.to_string()))?
|
.map_err(|error| FsError::InvalidGlob(error.to_string()))?
|
||||||
.compile_matcher();
|
.compile_matcher();
|
||||||
let mut matches = Vec::new();
|
let mut matches = Vec::new();
|
||||||
let mut walker = WalkBuilder::new(base);
|
for entry in WalkBuilder::new(base).hidden(false).build().flatten() {
|
||||||
walker.hidden(false).follow_links(false);
|
|
||||||
for entry in walker.build().flatten() {
|
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
let readable = resolve_access_path(path)
|
if !path.is_file() || !access.is_readable(path) {
|
||||||
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
|
|
||||||
if !path.is_file() || !readable {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let relative = path.strip_prefix(base).unwrap_or(path);
|
let relative = path.strip_prefix(base).unwrap_or(path);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
pub use glob::run_glob;
|
pub use glob::run_glob;
|
||||||
pub use local::{resolve_access_path, run_edit, run_list, run_read, run_stat, run_write};
|
pub use local::{run_edit, run_list, run_read, run_stat, run_write};
|
||||||
pub use operation::*;
|
pub use operation::*;
|
||||||
pub use search::run_grep;
|
pub use search::run_grep;
|
||||||
|
|
||||||
@@ -22,19 +22,6 @@ pub use search::run_grep;
|
|||||||
pub trait FsAccessPolicy: Send + Sync {
|
pub trait FsAccessPolicy: Send + Sync {
|
||||||
fn is_readable(&self, path: &Path) -> bool;
|
fn is_readable(&self, path: &Path) -> bool;
|
||||||
fn is_writable(&self, path: &Path) -> bool;
|
fn is_writable(&self, path: &Path) -> bool;
|
||||||
|
|
||||||
/// Authorize both the Workdir-visible path and its provider-resolved
|
|
||||||
/// target. Implementations that do not distinguish symbolic-link identity
|
|
||||||
/// retain resolved-target semantics through the defaults.
|
|
||||||
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
|
||||||
let _ = logical;
|
|
||||||
self.is_readable(resolved)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
|
||||||
let _ = logical;
|
|
||||||
self.is_writable(resolved)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// First symlink encountered while resolving a provider path.
|
/// First symlink encountered while resolving a provider path.
|
||||||
@@ -170,28 +157,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn grep_request(path: &str, pattern: &str) -> GrepRequest {
|
|
||||||
GrepRequest {
|
|
||||||
pattern: pattern.to_string(),
|
|
||||||
path: FsPath::new(path).unwrap(),
|
|
||||||
glob: None,
|
|
||||||
file_type: None,
|
|
||||||
case_insensitive: false,
|
|
||||||
before_context: 0,
|
|
||||||
after_context: 0,
|
|
||||||
multiline: false,
|
|
||||||
output_mode: GrepOutputMode::Content,
|
|
||||||
limit: 10,
|
|
||||||
offset: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn logical_paths_reject_absolute_parent_and_backslash_forms() {
|
fn logical_paths_reject_absolute_parent_and_backslash_forms() {
|
||||||
assert!(FsPath::new("src/lib.rs").is_ok());
|
assert!(FsPath::new("src/lib.rs").is_ok());
|
||||||
assert!(FsPath::new("/tmp/file").is_err());
|
assert!(FsPath::new("/tmp/file").is_err());
|
||||||
assert!(FsPath::new_scoped("/tmp/file").is_ok());
|
|
||||||
assert!(FsPath::new_scoped("/tmp/../secret").is_err());
|
|
||||||
assert!(FsPath::new("../file").is_err());
|
assert!(FsPath::new("../file").is_err());
|
||||||
assert!(FsPath::new("src\\lib.rs").is_err());
|
assert!(FsPath::new("src\\lib.rs").is_err());
|
||||||
}
|
}
|
||||||
@@ -310,331 +279,4 @@ 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_accepts_a_direct_file_without_searching_siblings() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let selected = temp.path().join("selected.txt");
|
|
||||||
std::fs::write(&selected, "before\nneedle selected\nafter\n").unwrap();
|
|
||||||
std::fs::write(temp.path().join("sibling.txt"), "needle sibling\n").unwrap();
|
|
||||||
let root = temp.path().canonicalize().unwrap();
|
|
||||||
let readable = RootAccess(root.clone());
|
|
||||||
|
|
||||||
let mut request = grep_request("selected.txt", "needle");
|
|
||||||
request.before_context = 1;
|
|
||||||
request.after_context = 1;
|
|
||||||
let direct = run_grep(&root, selected, request, &readable).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(direct.match_count, 1);
|
|
||||||
assert_eq!(direct.matched_files, 1);
|
|
||||||
assert_eq!(
|
|
||||||
direct.output,
|
|
||||||
concat!(
|
|
||||||
"selected.txt\n",
|
|
||||||
" 1 │ before\n",
|
|
||||||
" > 2 │ needle selected\n",
|
|
||||||
" 3 │ after\n",
|
|
||||||
)
|
|
||||||
);
|
|
||||||
assert!(!direct.output.contains("sibling"));
|
|
||||||
|
|
||||||
let directory = run_grep(
|
|
||||||
&root,
|
|
||||||
root.clone(),
|
|
||||||
GrepRequest {
|
|
||||||
pattern: "needle".to_string(),
|
|
||||||
path: FsPath::root(),
|
|
||||||
glob: None,
|
|
||||||
file_type: None,
|
|
||||||
case_insensitive: false,
|
|
||||||
before_context: 0,
|
|
||||||
after_context: 0,
|
|
||||||
multiline: false,
|
|
||||||
output_mode: GrepOutputMode::Content,
|
|
||||||
limit: 10,
|
|
||||||
offset: 0,
|
|
||||||
},
|
|
||||||
&readable,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(directory.match_count, 2);
|
|
||||||
assert_eq!(directory.matched_files, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn grep_direct_file_applies_glob_and_type_filters_for_every_output_mode() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let nested = temp.path().join("nested");
|
|
||||||
std::fs::create_dir(&nested).unwrap();
|
|
||||||
let selected = nested.join("selected.rs");
|
|
||||||
std::fs::write(&selected, "needle one\nneedle two\n").unwrap();
|
|
||||||
let root = temp.path().canonicalize().unwrap();
|
|
||||||
let readable = RootAccess(root.clone());
|
|
||||||
|
|
||||||
for mode in [
|
|
||||||
GrepOutputMode::Content,
|
|
||||||
GrepOutputMode::FilesWithMatches,
|
|
||||||
GrepOutputMode::Count,
|
|
||||||
] {
|
|
||||||
for (glob, file_type) in [(Some("other/*.rs"), None), (None, Some("python"))] {
|
|
||||||
let mut request = grep_request("nested/selected.rs", "needle");
|
|
||||||
request.output_mode = mode;
|
|
||||||
request.glob = glob.map(str::to_string);
|
|
||||||
request.file_type = file_type.map(str::to_string);
|
|
||||||
|
|
||||||
let excluded = run_grep(&root, selected.clone(), request, &readable).unwrap();
|
|
||||||
assert_eq!(excluded.output, "", "mode {mode:?}");
|
|
||||||
assert_eq!(excluded.match_count, 0, "mode {mode:?}");
|
|
||||||
assert_eq!(excluded.matched_files, 0, "mode {mode:?}");
|
|
||||||
assert!(!excluded.truncated, "mode {mode:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut request = grep_request("nested/selected.rs", "needle");
|
|
||||||
request.output_mode = mode;
|
|
||||||
request.glob = Some("nested/*.rs".to_string());
|
|
||||||
request.file_type = Some("rust".to_string());
|
|
||||||
let matched = run_grep(&root, selected.clone(), request, &readable).unwrap();
|
|
||||||
|
|
||||||
match mode {
|
|
||||||
GrepOutputMode::Content => {
|
|
||||||
assert_eq!(matched.match_count, 2);
|
|
||||||
assert_eq!(matched.matched_files, 1);
|
|
||||||
assert!(matched.output.starts_with("nested/selected.rs\n"));
|
|
||||||
assert!(matched.output.contains("> 1 │ needle one"));
|
|
||||||
assert!(matched.output.contains("> 2 │ needle two"));
|
|
||||||
}
|
|
||||||
GrepOutputMode::FilesWithMatches => {
|
|
||||||
assert_eq!(matched.match_count, 1);
|
|
||||||
assert_eq!(matched.matched_files, 1);
|
|
||||||
assert_eq!(matched.output, "nested/selected.rs\n");
|
|
||||||
}
|
|
||||||
GrepOutputMode::Count => {
|
|
||||||
assert_eq!(matched.match_count, 2);
|
|
||||||
assert_eq!(matched.matched_files, 1);
|
|
||||||
assert_eq!(matched.output, "nested/selected.rs:2\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(!matched.truncated, "mode {mode:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn grep_direct_file_preserves_explicit_hidden_and_gitignored_behavior() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let hidden = temp.path().join(".hidden.rs");
|
|
||||||
let ignored = temp.path().join("ignored.rs");
|
|
||||||
std::fs::write(&hidden, "needle hidden\n").unwrap();
|
|
||||||
std::fs::write(&ignored, "needle ignored\n").unwrap();
|
|
||||||
std::fs::write(temp.path().join(".gitignore"), "ignored.rs\n").unwrap();
|
|
||||||
let root = temp.path().canonicalize().unwrap();
|
|
||||||
let readable = RootAccess(root.clone());
|
|
||||||
|
|
||||||
for (path, expected) in [
|
|
||||||
(".hidden.rs", "needle hidden"),
|
|
||||||
("ignored.rs", "needle ignored"),
|
|
||||||
] {
|
|
||||||
let result = run_grep(
|
|
||||||
&root,
|
|
||||||
root.join(path),
|
|
||||||
grep_request(path, "needle"),
|
|
||||||
&readable,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(result.match_count, 1, "path {path}");
|
|
||||||
assert!(result.output.contains(expected), "path {path}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn grep_direct_file_preserves_case_multiline_and_bounds() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let selected = temp.path().join("selected.txt");
|
|
||||||
std::fs::write(&selected, "NEEDLE first\nstart\nfinish\nneedle last\n").unwrap();
|
|
||||||
let root = temp.path().canonicalize().unwrap();
|
|
||||||
let readable = RootAccess(root.clone());
|
|
||||||
|
|
||||||
let mut case_request = grep_request("selected.txt", "needle");
|
|
||||||
case_request.case_insensitive = true;
|
|
||||||
case_request.offset = 1;
|
|
||||||
case_request.limit = 1;
|
|
||||||
let bounded = run_grep(&root, selected.clone(), case_request, &readable).unwrap();
|
|
||||||
assert_eq!(bounded.match_count, 1);
|
|
||||||
assert!(!bounded.output.contains("NEEDLE first"));
|
|
||||||
assert!(bounded.output.contains("needle last"));
|
|
||||||
assert!(bounded.truncated);
|
|
||||||
|
|
||||||
let mut multiline_request = grep_request("selected.txt", "start\\nfinish");
|
|
||||||
multiline_request.multiline = true;
|
|
||||||
let multiline = run_grep(&root, selected, multiline_request, &readable).unwrap();
|
|
||||||
assert_eq!(multiline.match_count, 1);
|
|
||||||
assert!(multiline.output.contains("start\nfinish"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn grep_returns_not_found_for_a_missing_direct_path() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let root = temp.path().canonicalize().unwrap();
|
|
||||||
let missing = root.join("missing.txt");
|
|
||||||
let readable = RootAccess(root.clone());
|
|
||||||
|
|
||||||
let error = run_grep(
|
|
||||||
&root,
|
|
||||||
missing.clone(),
|
|
||||||
grep_request("missing.txt", "needle"),
|
|
||||||
&readable,
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert!(matches!(error, FsError::NotFound(path) if path == missing));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn grep_traverses_a_direct_symlink_directory_and_rejects_a_broken_path() {
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let root = temp.path().canonicalize().unwrap();
|
|
||||||
let readable = RootAccess(root.clone());
|
|
||||||
std::fs::create_dir(root.join("target-dir")).unwrap();
|
|
||||||
std::fs::write(root.join("target-dir/nested.rs"), "needle nested\n").unwrap();
|
|
||||||
std::fs::write(root.join("target-file.rs"), "needle file\n").unwrap();
|
|
||||||
symlink(root.join("target-file.rs"), root.join("file-link.rs")).unwrap();
|
|
||||||
symlink(root.join("target-dir"), root.join("directory-link")).unwrap();
|
|
||||||
symlink(root.join("missing-target"), root.join("broken-link")).unwrap();
|
|
||||||
|
|
||||||
let request = |path: &str| grep_request(path, "needle");
|
|
||||||
|
|
||||||
let file_result = run_grep(
|
|
||||||
&root,
|
|
||||||
root.join("file-link.rs"),
|
|
||||||
request("file-link.rs"),
|
|
||||||
&readable,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(file_result.match_count, 1);
|
|
||||||
assert!(file_result.output.starts_with("file-link.rs\n"));
|
|
||||||
|
|
||||||
let directory_result = run_grep(
|
|
||||||
&root,
|
|
||||||
root.join("directory-link"),
|
|
||||||
request("directory-link"),
|
|
||||||
&readable,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(directory_result.match_count, 1);
|
|
||||||
assert!(
|
|
||||||
directory_result
|
|
||||||
.output
|
|
||||||
.starts_with("directory-link/nested.rs\n")
|
|
||||||
);
|
|
||||||
|
|
||||||
let glob_result = run_glob(
|
|
||||||
&root,
|
|
||||||
&root.join("directory-link"),
|
|
||||||
GlobRequest {
|
|
||||||
pattern: "**/*.rs".to_string(),
|
|
||||||
path: FsPath::new("directory-link").unwrap(),
|
|
||||||
limit: 10,
|
|
||||||
},
|
|
||||||
&readable,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
glob_result.paths,
|
|
||||||
vec![FsPath::new("directory-link/nested.rs").unwrap()]
|
|
||||||
);
|
|
||||||
|
|
||||||
let broken_error = run_grep(
|
|
||||||
&root,
|
|
||||||
root.join("broken-link"),
|
|
||||||
request("broken-link"),
|
|
||||||
&readable,
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(
|
|
||||||
broken_error,
|
|
||||||
FsError::BrokenSymlink { path, .. } if path == root.join("broken-link")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn grep_rejects_a_direct_special_file_as_invalid_argument() {
|
|
||||||
use std::os::unix::net::UnixListener;
|
|
||||||
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let socket = temp.path().join("grep.sock");
|
|
||||||
let _listener = UnixListener::bind(&socket).unwrap();
|
|
||||||
let root = temp.path().canonicalize().unwrap();
|
|
||||||
let readable = RootAccess(root.clone());
|
|
||||||
|
|
||||||
let error = run_grep(
|
|
||||||
&root,
|
|
||||||
socket,
|
|
||||||
grep_request("grep.sock", "needle"),
|
|
||||||
&readable,
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
error,
|
|
||||||
FsError::InvalidArgument(message)
|
|
||||||
if message.contains("must be a regular file or directory")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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,4 +1,3 @@
|
|||||||
use std::ffi::OsString;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -19,8 +18,7 @@ pub fn run_stat(
|
|||||||
) -> Result<StatResult, FsError> {
|
) -> Result<StatResult, FsError> {
|
||||||
let logical = request.path;
|
let logical = request.path;
|
||||||
let path = resolve(root, &logical)?;
|
let path = resolve(root, &logical)?;
|
||||||
let resolved = resolve_access_path(&path).map_err(|error| map_io(&logical, error))?;
|
if !access.is_readable(&path) {
|
||||||
if !access.is_readable_paths(&path, &resolved) {
|
|
||||||
return Err(FsError::OutOfScope(PathBuf::from(logical.as_str())));
|
return Err(FsError::OutOfScope(PathBuf::from(logical.as_str())));
|
||||||
}
|
}
|
||||||
let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?;
|
let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||||
@@ -47,7 +45,7 @@ pub fn run_read(
|
|||||||
) -> Result<ReadResult, FsError> {
|
) -> Result<ReadResult, FsError> {
|
||||||
let logical = request.path;
|
let logical = request.path;
|
||||||
let path = resolve(root, &logical)?;
|
let path = resolve(root, &logical)?;
|
||||||
let path = require_access(&path, &logical, access, false, false)?;
|
let path = require_access(&path, &logical, access, false)?;
|
||||||
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||||
if metadata.is_dir() {
|
if metadata.is_dir() {
|
||||||
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
||||||
@@ -101,7 +99,7 @@ pub fn run_write(
|
|||||||
let path = resolve(root, &logical)?;
|
let path = resolve(root, &logical)?;
|
||||||
let created = !path.exists();
|
let created = !path.exists();
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
let target = require_access(&path, &logical, access, true, false)?;
|
let target = require_access(&path, &logical, access, true)?;
|
||||||
let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?;
|
let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?;
|
||||||
if metadata.is_dir() {
|
if metadata.is_dir() {
|
||||||
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
||||||
@@ -115,8 +113,12 @@ pub fn run_write(
|
|||||||
if request.expected_hash.is_some() {
|
if request.expected_hash.is_some() {
|
||||||
return Err(FsError::Conflict(logical.as_str().to_string()));
|
return Err(FsError::Conflict(logical.as_str().to_string()));
|
||||||
}
|
}
|
||||||
let target = require_access(&path, &logical, access, true, true)?;
|
let parent = path.parent().ok_or_else(|| {
|
||||||
atomic_write(&target, &request.content, &logical)?;
|
FsError::InvalidArgument(format!("{} has no parent", logical.as_str()))
|
||||||
|
})?;
|
||||||
|
let parent_logical = logical_parent(&logical);
|
||||||
|
require_access(parent, &parent_logical, access, true)?;
|
||||||
|
atomic_write(&path, &request.content, &logical)?;
|
||||||
}
|
}
|
||||||
Ok(WriteResult {
|
Ok(WriteResult {
|
||||||
bytes_written: request.content.len(),
|
bytes_written: request.content.len(),
|
||||||
@@ -131,7 +133,7 @@ pub fn run_edit(
|
|||||||
) -> Result<EditResult, FsError> {
|
) -> Result<EditResult, FsError> {
|
||||||
let logical = request.path;
|
let logical = request.path;
|
||||||
let path = resolve(root, &logical)?;
|
let path = resolve(root, &logical)?;
|
||||||
let target = require_access(&path, &logical, access, true, false)?;
|
let target = require_access(&path, &logical, access, true)?;
|
||||||
let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?;
|
let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?;
|
||||||
let actual_hash = hash_bytes(&bytes);
|
let actual_hash = hash_bytes(&bytes);
|
||||||
if actual_hash != request.expected_hash {
|
if actual_hash != request.expected_hash {
|
||||||
@@ -171,8 +173,7 @@ pub fn run_list(
|
|||||||
) -> Result<ListResult, FsError> {
|
) -> Result<ListResult, FsError> {
|
||||||
let logical = request.path;
|
let logical = request.path;
|
||||||
let path = resolve(root, &logical)?;
|
let path = resolve(root, &logical)?;
|
||||||
let logical_base = path.clone();
|
let path = require_access(&path, &logical, access, false)?;
|
||||||
let path = require_access(&path, &logical, access, false, true)?;
|
|
||||||
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||||
if !metadata.is_dir() {
|
if !metadata.is_dir() {
|
||||||
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
|
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
|
||||||
@@ -182,15 +183,7 @@ pub fn run_list(
|
|||||||
for entry in read_dir {
|
for entry in read_dir {
|
||||||
let entry = entry.map_err(|error| map_io(&logical, error))?;
|
let entry = entry.map_err(|error| map_io(&logical, error))?;
|
||||||
let absolute = entry.path();
|
let absolute = entry.path();
|
||||||
let relative_to_base = absolute.strip_prefix(&path).map_err(|_| {
|
if !access.is_readable(&absolute) {
|
||||||
FsError::InvalidArgument("provider returned a path outside its list base".to_string())
|
|
||||||
})?;
|
|
||||||
let logical_absolute = logical_base.join(relative_to_base);
|
|
||||||
let resolved = match resolve_access_path(&absolute) {
|
|
||||||
Ok(resolved) => resolved,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
if !access.is_readable_paths(&logical_absolute, &resolved) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let link_metadata =
|
let link_metadata =
|
||||||
@@ -210,7 +203,7 @@ pub fn run_list(
|
|||||||
} else {
|
} else {
|
||||||
EntryKind::Other
|
EntryKind::Other
|
||||||
};
|
};
|
||||||
let relative = logical_absolute.strip_prefix(root).map_err(|_| {
|
let relative = absolute.strip_prefix(root).map_err(|_| {
|
||||||
FsError::InvalidArgument("provider returned a path outside its root".to_string())
|
FsError::InvalidArgument("provider returned a path outside its root".to_string())
|
||||||
})?;
|
})?;
|
||||||
entries.push(ListEntry {
|
entries.push(ListEntry {
|
||||||
@@ -254,24 +247,19 @@ fn require_access(
|
|||||||
logical: &FsPath,
|
logical: &FsPath,
|
||||||
access: &dyn FsAccessPolicy,
|
access: &dyn FsAccessPolicy,
|
||||||
write: bool,
|
write: bool,
|
||||||
allow_symlink_directory: bool,
|
|
||||||
) -> Result<PathBuf, FsError> {
|
) -> Result<PathBuf, FsError> {
|
||||||
let symlink = direct_symlink(path);
|
if let Some(info) = direct_symlink(path) {
|
||||||
if let Some(info) = symlink.as_ref()
|
if !info.target_exists {
|
||||||
&& !info.target_exists
|
return Err(FsError::BrokenSymlink {
|
||||||
{
|
path: PathBuf::from(logical.as_str()),
|
||||||
return Err(FsError::BrokenSymlink {
|
link: PathBuf::from(logical.as_str()),
|
||||||
path: PathBuf::from(logical.as_str()),
|
target: PathBuf::from("<provider-internal target>"),
|
||||||
link: PathBuf::from(logical.as_str()),
|
});
|
||||||
target: PathBuf::from("<provider-internal target>"),
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
let resolved = resolve_access_path(path).map_err(|error| map_io(logical, error))?;
|
|
||||||
if let Some(info) = symlink {
|
|
||||||
let allowed = if write {
|
let allowed = if write {
|
||||||
access.is_writable_paths(path, &resolved)
|
access.is_writable(&info.resolved_path)
|
||||||
} else {
|
} else {
|
||||||
access.is_readable_paths(path, &resolved)
|
access.is_readable(&info.resolved_path)
|
||||||
};
|
};
|
||||||
if !allowed {
|
if !allowed {
|
||||||
return Err(FsError::SymlinkOutOfScope {
|
return Err(FsError::SymlinkOutOfScope {
|
||||||
@@ -280,21 +268,21 @@ fn require_access(
|
|||||||
required_permission: if write { "write" } else { "read" },
|
required_permission: if write { "write" } else { "read" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if !allow_symlink_directory && info.resolved_path.is_dir() {
|
if write && info.resolved_path.is_dir() {
|
||||||
return Err(FsError::SymlinkTargetIsDirectory {
|
return Err(FsError::SymlinkTargetIsDirectory {
|
||||||
path: PathBuf::from(logical.as_str()),
|
path: PathBuf::from(logical.as_str()),
|
||||||
target: PathBuf::from("<provider-internal target>"),
|
target: PathBuf::from("<provider-internal target>"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Ok(resolved);
|
return Ok(info.resolved_path);
|
||||||
}
|
}
|
||||||
let allowed = if write {
|
let allowed = if write {
|
||||||
access.is_writable_paths(path, &resolved)
|
access.is_writable(path)
|
||||||
} else {
|
} else {
|
||||||
access.is_readable_paths(path, &resolved)
|
access.is_readable(path)
|
||||||
};
|
};
|
||||||
if allowed {
|
if allowed {
|
||||||
Ok(resolved)
|
Ok(path.to_path_buf())
|
||||||
} else if write {
|
} else if write {
|
||||||
Err(FsError::ReadOnly(PathBuf::from(logical.as_str())))
|
Err(FsError::ReadOnly(PathBuf::from(logical.as_str())))
|
||||||
} else {
|
} else {
|
||||||
@@ -302,38 +290,12 @@ fn require_access(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve every existing component of an absolute provider path while
|
fn logical_parent(path: &FsPath) -> FsPath {
|
||||||
/// retaining a missing final tail for create operations. Dangling symlinks are
|
let parent = Path::new(path.as_str())
|
||||||
/// rejected because no resolved authority identity can be established.
|
.parent()
|
||||||
pub fn resolve_access_path(path: &Path) -> std::io::Result<PathBuf> {
|
.unwrap_or_else(|| Path::new(""))
|
||||||
let mut cursor = path;
|
.to_string_lossy();
|
||||||
let mut missing = Vec::<OsString>::new();
|
FsPath::new(parent).unwrap_or_else(|_| FsPath::root())
|
||||||
loop {
|
|
||||||
match fs::canonicalize(cursor) {
|
|
||||||
Ok(mut resolved) => {
|
|
||||||
for component in missing.iter().rev() {
|
|
||||||
resolved.push(component);
|
|
||||||
}
|
|
||||||
return Ok(resolved);
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
||||||
if fs::symlink_metadata(cursor)
|
|
||||||
.is_ok_and(|metadata| metadata.file_type().is_symlink())
|
|
||||||
{
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
let name = cursor.file_name().ok_or(error)?;
|
|
||||||
missing.push(name.to_os_string());
|
|
||||||
cursor = cursor.parent().ok_or_else(|| {
|
|
||||||
std::io::Error::new(
|
|
||||||
std::io::ErrorKind::NotFound,
|
|
||||||
"path has no existing ancestor",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> {
|
fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> {
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::FsError;
|
use crate::FsError;
|
||||||
|
|
||||||
/// Scope-checked filesystem path. Relative paths resolve below the bound
|
/// Logical path relative to the bound Workdir root.
|
||||||
/// Workdir root; absolute paths require an explicit matching scope rule.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
pub struct FsPath(String);
|
pub struct FsPath(String);
|
||||||
@@ -17,30 +16,11 @@ impl<'de> Deserialize<'de> for FsPath {
|
|||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
{
|
{
|
||||||
let value = String::deserialize(deserializer)?;
|
let value = String::deserialize(deserializer)?;
|
||||||
Self::new_scoped(&value).map_err(serde::de::Error::custom)
|
Self::new(&value).map_err(serde::de::Error::custom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FsPath {
|
impl FsPath {
|
||||||
/// Construct a path for a scope-checked operation that may target an
|
|
||||||
/// explicitly granted absolute path outside the provider root.
|
|
||||||
pub fn new_scoped(value: impl Into<String>) -> Result<Self, FsError> {
|
|
||||||
let value = value.into();
|
|
||||||
if !Path::new(&value).is_absolute() {
|
|
||||||
return Self::new(value);
|
|
||||||
}
|
|
||||||
if value.contains('\\') {
|
|
||||||
return Err(FsError::InvalidPath(value));
|
|
||||||
}
|
|
||||||
if Path::new(&value)
|
|
||||||
.components()
|
|
||||||
.any(|component| component == Component::ParentDir)
|
|
||||||
{
|
|
||||||
return Err(FsError::InvalidPath(value));
|
|
||||||
}
|
|
||||||
Ok(Self(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn root() -> Self {
|
pub fn root() -> Self {
|
||||||
Self(String::new())
|
Self(String::new())
|
||||||
}
|
}
|
||||||
|
|||||||
+120
-224
@@ -1,5 +1,3 @@
|
|||||||
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;
|
||||||
@@ -7,12 +5,10 @@ use grep_regex::RegexMatcherBuilder;
|
|||||||
use grep_searcher::sinks::UTF8 as UTF8Sink;
|
use grep_searcher::sinks::UTF8 as UTF8Sink;
|
||||||
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch};
|
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch};
|
||||||
use ignore::WalkBuilder;
|
use ignore::WalkBuilder;
|
||||||
use ignore::overrides::{Override, OverrideBuilder};
|
use ignore::overrides::OverrideBuilder;
|
||||||
use ignore::types::{Types, TypesBuilder};
|
use ignore::types::TypesBuilder;
|
||||||
|
|
||||||
use crate::{
|
use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink};
|
||||||
FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink, resolve_access_path,
|
|
||||||
};
|
|
||||||
|
|
||||||
struct ContentLine {
|
struct ContentLine {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
@@ -61,11 +57,20 @@ impl GrepReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
GrepOutputMode::Content => {
|
GrepOutputMode::Content => {
|
||||||
output.push_str(&render_content_lines(
|
for line in &self.lines {
|
||||||
root,
|
let separator = if line.is_match { ':' } else { '-' };
|
||||||
&self.lines,
|
let path = logical_display(root, &line.path);
|
||||||
self.show_line_numbers,
|
if 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 {
|
||||||
@@ -77,48 +82,6 @@ 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)
|
||||||
@@ -128,38 +91,6 @@ fn logical_display(root: &Path, path: &Path) -> String {
|
|||||||
|
|
||||||
const DEFAULT_HEAD_LIMIT: usize = 250;
|
const DEFAULT_HEAD_LIMIT: usize = 250;
|
||||||
|
|
||||||
fn build_overrides(base: &Path, glob: Option<&str>) -> Result<Option<Override>, FsError> {
|
|
||||||
let Some(glob) = glob else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let mut builder = OverrideBuilder::new(base);
|
|
||||||
builder
|
|
||||||
.add(glob)
|
|
||||||
.map_err(|error| FsError::InvalidGlob(error.to_string()))?;
|
|
||||||
builder
|
|
||||||
.build()
|
|
||||||
.map(Some)
|
|
||||||
.map_err(|error| FsError::InvalidGlob(error.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_types(file_type: Option<&str>) -> Result<Option<Types>, FsError> {
|
|
||||||
let Some(file_type) = file_type else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let mut builder = TypesBuilder::new();
|
|
||||||
builder.add_defaults();
|
|
||||||
builder.select(file_type);
|
|
||||||
builder
|
|
||||||
.build()
|
|
||||||
.map(Some)
|
|
||||||
.map_err(|error| FsError::InvalidArgument(format!("invalid type {file_type}: {error}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn direct_file_selected(path: &Path, overrides: Option<&Override>, types: Option<&Types>) -> bool {
|
|
||||||
!overrides.is_some_and(|filter| filter.matched(path, false).is_ignore())
|
|
||||||
&& !types.is_some_and(|filter| filter.matched(path, false).is_ignore())
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GrepParams {
|
struct GrepParams {
|
||||||
pattern: String,
|
pattern: String,
|
||||||
path: Option<PathBuf>,
|
path: Option<PathBuf>,
|
||||||
@@ -222,28 +153,14 @@ pub fn run_grep(
|
|||||||
return Err(FsError::RelativePath(base));
|
return Err(FsError::RelativePath(base));
|
||||||
}
|
}
|
||||||
let symlink = direct_symlink(&base);
|
let symlink = direct_symlink(&base);
|
||||||
if let Some(info) = symlink.as_ref()
|
if !access.is_readable(&base) {
|
||||||
&& !info.target_exists
|
|
||||||
{
|
|
||||||
return Err(FsError::BrokenSymlink {
|
|
||||||
path: base.clone(),
|
|
||||||
link: info.link_path.clone(),
|
|
||||||
target: info.resolved_path.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let resolved_base = resolve_access_path(&base).map_err(|error| FsError::io(&base, error))?;
|
|
||||||
if !access.is_readable_paths(&base, &resolved_base) {
|
|
||||||
return Err(if let Some(info) = symlink.as_ref() {
|
return Err(if let Some(info) = symlink.as_ref() {
|
||||||
let link_parent_readable = info
|
let link_parent_readable = info
|
||||||
.link_path
|
.link_path
|
||||||
.parent()
|
.parent()
|
||||||
.and_then(|parent| {
|
.map(|parent| access.is_readable(parent))
|
||||||
resolve_access_path(parent)
|
|
||||||
.ok()
|
|
||||||
.map(|resolved| access.is_readable_paths(parent, &resolved))
|
|
||||||
})
|
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if link_parent_readable {
|
if info.target_exists && link_parent_readable {
|
||||||
FsError::SymlinkOutOfScope {
|
FsError::SymlinkOutOfScope {
|
||||||
path: base.clone(),
|
path: base.clone(),
|
||||||
target: info.resolved_path.clone(),
|
target: info.resolved_path.clone(),
|
||||||
@@ -256,19 +173,59 @@ pub fn run_grep(
|
|||||||
FsError::OutOfScope(base.clone())
|
FsError::OutOfScope(base.clone())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if let Some(info) = symlink.as_ref() {
|
||||||
|
if !info.target_exists {
|
||||||
|
return Err(FsError::BrokenSymlink {
|
||||||
|
path: base.clone(),
|
||||||
|
link: info.link_path.clone(),
|
||||||
|
target: info.target_path.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() {
|
let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() {
|
||||||
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
|
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
|
||||||
_ => FsError::io(&base, e),
|
_ => FsError::io(&base, e),
|
||||||
})?;
|
})?;
|
||||||
if !base_meta.is_file() && !base_meta.is_dir() {
|
if !base_meta.is_dir() {
|
||||||
return Err(FsError::InvalidArgument(format!(
|
return Err(FsError::InvalidArgument(format!(
|
||||||
"grep search path must be a regular file or directory: {}",
|
"grep search path is not a directory: {}",
|
||||||
base.display()
|
base.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let filter_base = if base_meta.is_file() { root } else { &base };
|
if let Some(info) = symlink.as_ref() {
|
||||||
let types = build_types(p.file_type.as_deref())?;
|
return Err(FsError::SymlinkDirectoryNotTraversed {
|
||||||
let overrides = build_overrides(filter_base, p.glob.as_deref())?;
|
tool: "Grep",
|
||||||
|
path: base.clone(),
|
||||||
|
target: info.resolved_path.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut wb = WalkBuilder::new(&base);
|
||||||
|
wb.hidden(true)
|
||||||
|
.git_ignore(true)
|
||||||
|
.git_global(true)
|
||||||
|
.git_exclude(true)
|
||||||
|
.ignore(true)
|
||||||
|
.parents(true)
|
||||||
|
.follow_links(false);
|
||||||
|
|
||||||
|
if let Some(t) = p.file_type.as_deref() {
|
||||||
|
let mut tb = TypesBuilder::new();
|
||||||
|
tb.add_defaults();
|
||||||
|
tb.select(t);
|
||||||
|
let types = tb
|
||||||
|
.build()
|
||||||
|
.map_err(|e| FsError::InvalidArgument(format!("invalid type {t}: {e}")))?;
|
||||||
|
wb.types(types);
|
||||||
|
}
|
||||||
|
if let Some(g) = p.glob.as_deref() {
|
||||||
|
let mut ob = OverrideBuilder::new(&base);
|
||||||
|
ob.add(g).map_err(|e| FsError::InvalidGlob(e.to_string()))?;
|
||||||
|
let ov = ob
|
||||||
|
.build()
|
||||||
|
.map_err(|e| FsError::InvalidGlob(e.to_string()))?;
|
||||||
|
wb.overrides(ov);
|
||||||
|
}
|
||||||
|
|
||||||
let mode = p.output_mode.unwrap_or_default();
|
let mode = p.output_mode.unwrap_or_default();
|
||||||
let head_limit = p.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
let head_limit = p.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
||||||
@@ -283,135 +240,74 @@ pub fn run_grep(
|
|||||||
lines: Vec::new(),
|
lines: Vec::new(),
|
||||||
truncated: false,
|
truncated: false,
|
||||||
};
|
};
|
||||||
let mut matching_files_seen = 0;
|
|
||||||
let mut matches_seen = 0;
|
|
||||||
|
|
||||||
if base_meta.is_file() {
|
// Per-mode walker state.
|
||||||
if direct_file_selected(&base, overrides.as_ref(), types.as_ref()) {
|
let mut matching_files_seen: usize = 0;
|
||||||
scan_path(
|
let mut matches_seen: usize = 0;
|
||||||
&mut searcher,
|
|
||||||
&matcher,
|
|
||||||
&base,
|
|
||||||
mode,
|
|
||||||
&mut report,
|
|
||||||
&mut matching_files_seen,
|
|
||||||
&mut matches_seen,
|
|
||||||
offset,
|
|
||||||
head_limit,
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
return Ok(report.into_result(root));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut walker = WalkBuilder::new(&base);
|
'walker: for entry in wb.build().flatten() {
|
||||||
walker
|
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
|
||||||
.hidden(true)
|
|
||||||
.git_ignore(true)
|
|
||||||
.git_global(true)
|
|
||||||
.git_exclude(true)
|
|
||||||
.ignore(true)
|
|
||||||
.parents(true)
|
|
||||||
.follow_links(false);
|
|
||||||
if let Some(types) = types {
|
|
||||||
walker.types(types);
|
|
||||||
}
|
|
||||||
if let Some(overrides) = overrides {
|
|
||||||
walker.overrides(overrides);
|
|
||||||
}
|
|
||||||
|
|
||||||
for entry in walker.build().flatten() {
|
|
||||||
if !entry
|
|
||||||
.file_type()
|
|
||||||
.map(|kind| kind.is_file())
|
|
||||||
.unwrap_or(false)
|
|
||||||
{
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
let readable = resolve_access_path(path)
|
if !access.is_readable(path) {
|
||||||
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
|
|
||||||
if !readable {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if scan_path(
|
|
||||||
&mut searcher,
|
match mode {
|
||||||
&matcher,
|
GrepOutputMode::FilesWithMatches => {
|
||||||
path,
|
let hit = scan_any_match(&mut searcher, &matcher, path)?;
|
||||||
mode,
|
if !hit {
|
||||||
&mut report,
|
continue;
|
||||||
&mut matching_files_seen,
|
}
|
||||||
&mut matches_seen,
|
if matching_files_seen >= offset {
|
||||||
offset,
|
report.files.push(path.to_path_buf());
|
||||||
head_limit,
|
if report.files.len() >= head_limit {
|
||||||
)? {
|
report.truncated = true;
|
||||||
break;
|
break 'walker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matching_files_seen += 1;
|
||||||
|
}
|
||||||
|
GrepOutputMode::Count => {
|
||||||
|
let count = scan_count(&mut searcher, &matcher, path)?;
|
||||||
|
if count == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if matching_files_seen >= offset {
|
||||||
|
report.counts.push((path.to_path_buf(), count));
|
||||||
|
if report.counts.len() >= head_limit {
|
||||||
|
report.truncated = true;
|
||||||
|
break 'walker;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matching_files_seen += 1;
|
||||||
|
}
|
||||||
|
GrepOutputMode::Content => {
|
||||||
|
let before_count = matches_seen;
|
||||||
|
let mut sink = ContentSink {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
lines: &mut report.lines,
|
||||||
|
matches_seen: &mut matches_seen,
|
||||||
|
offset,
|
||||||
|
head_limit,
|
||||||
|
};
|
||||||
|
searcher
|
||||||
|
.search_path(&matcher, path, &mut sink)
|
||||||
|
.map_err(|e| FsError::io(path, e))?;
|
||||||
|
// If we hit head_limit during this file, stop walking.
|
||||||
|
if matches_seen >= offset.saturating_add(head_limit) && matches_seen > before_count
|
||||||
|
{
|
||||||
|
report.truncated = true;
|
||||||
|
break 'walker;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(report.into_result(root))
|
Ok(report.into_result(root))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn scan_path(
|
|
||||||
searcher: &mut Searcher,
|
|
||||||
matcher: &grep_regex::RegexMatcher,
|
|
||||||
path: &Path,
|
|
||||||
mode: GrepOutputMode,
|
|
||||||
report: &mut GrepReport,
|
|
||||||
matching_files_seen: &mut usize,
|
|
||||||
matches_seen: &mut usize,
|
|
||||||
offset: usize,
|
|
||||||
head_limit: usize,
|
|
||||||
) -> Result<bool, FsError> {
|
|
||||||
match mode {
|
|
||||||
GrepOutputMode::FilesWithMatches => {
|
|
||||||
if !scan_any_match(searcher, matcher, path)? {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
if *matching_files_seen >= offset {
|
|
||||||
report.files.push(path.to_path_buf());
|
|
||||||
if report.files.len() >= head_limit {
|
|
||||||
report.truncated = true;
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*matching_files_seen += 1;
|
|
||||||
}
|
|
||||||
GrepOutputMode::Count => {
|
|
||||||
let count = scan_count(searcher, matcher, path)?;
|
|
||||||
if count == 0 {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
if *matching_files_seen >= offset {
|
|
||||||
report.counts.push((path.to_path_buf(), count));
|
|
||||||
if report.counts.len() >= head_limit {
|
|
||||||
report.truncated = true;
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*matching_files_seen += 1;
|
|
||||||
}
|
|
||||||
GrepOutputMode::Content => {
|
|
||||||
let before_count = *matches_seen;
|
|
||||||
let mut sink = ContentSink {
|
|
||||||
path: path.to_path_buf(),
|
|
||||||
lines: &mut report.lines,
|
|
||||||
matches_seen,
|
|
||||||
offset,
|
|
||||||
head_limit,
|
|
||||||
};
|
|
||||||
searcher
|
|
||||||
.search_path(matcher, path, &mut sink)
|
|
||||||
.map_err(|error| FsError::io(path, error))?;
|
|
||||||
if *matches_seen >= offset.saturating_add(head_limit) && *matches_seen > before_count {
|
|
||||||
report.truncated = true;
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn scan_any_match(
|
fn scan_any_match(
|
||||||
searcher: &mut Searcher,
|
searcher: &mut Searcher,
|
||||||
matcher: &grep_regex::RegexMatcher,
|
matcher: &grep_regex::RegexMatcher,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ license.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
arc-swap = "1"
|
arc-swap = "1"
|
||||||
agen = { workspace = true }
|
agen = { workspace = true }
|
||||||
decodal.workspace = true
|
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -1,318 +0,0 @@
|
|||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
use decodal::{Data, Engine, ImportLoader, LoadedImport};
|
|
||||||
use serde_json::{Map, Number, Value};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
use crate::profile::ProfileError;
|
|
||||||
|
|
||||||
pub const BUILTIN_PROFILE_CATALOG_ID: &str = "builtin-profiles-v2";
|
|
||||||
pub const BUILTIN_DEFAULT_PROFILE: &str = "builtin:default";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct BuiltinProfileImport {
|
|
||||||
pub specifier: &'static str,
|
|
||||||
pub resolved_path: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct BuiltinProfileResource {
|
|
||||||
pub selector: Option<&'static str>,
|
|
||||||
pub path: &'static str,
|
|
||||||
pub source: &'static str,
|
|
||||||
pub description: &'static str,
|
|
||||||
pub imports: &'static [BuiltinProfileImport],
|
|
||||||
}
|
|
||||||
|
|
||||||
const BASE_PATH: &str = "profiles/base.dcdl";
|
|
||||||
const BASE_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport {
|
|
||||||
specifier: "./base.dcdl",
|
|
||||||
resolved_path: BASE_PATH,
|
|
||||||
}];
|
|
||||||
const NO_IMPORTS: &[BuiltinProfileImport] = &[];
|
|
||||||
|
|
||||||
pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
|
||||||
BuiltinProfileResource {
|
|
||||||
selector: None,
|
|
||||||
path: BASE_PATH,
|
|
||||||
source: include_str!("../../../resources/profiles/base.dcdl"),
|
|
||||||
description: "Shared built-in Profile defaults.",
|
|
||||||
imports: NO_IMPORTS,
|
|
||||||
},
|
|
||||||
BuiltinProfileResource {
|
|
||||||
selector: Some(BUILTIN_DEFAULT_PROFILE),
|
|
||||||
path: "profiles/default.dcdl",
|
|
||||||
source: include_str!("../../../resources/profiles/default.dcdl"),
|
|
||||||
description: "Standalone Yoi coding profile.",
|
|
||||||
imports: BASE_IMPORT,
|
|
||||||
},
|
|
||||||
BuiltinProfileResource {
|
|
||||||
selector: Some("builtin:coder"),
|
|
||||||
path: "profiles/coder.dcdl",
|
|
||||||
source: include_str!("../../../resources/profiles/coder.dcdl"),
|
|
||||||
description: "Ticket implementation with direct Reviewer SubWorkers.",
|
|
||||||
imports: BASE_IMPORT,
|
|
||||||
},
|
|
||||||
BuiltinProfileResource {
|
|
||||||
selector: Some("builtin:companion"),
|
|
||||||
path: "profiles/companion.dcdl",
|
|
||||||
source: include_str!("../../../resources/profiles/companion.dcdl"),
|
|
||||||
description: "General assistance with Workspace tools.",
|
|
||||||
imports: BASE_IMPORT,
|
|
||||||
},
|
|
||||||
BuiltinProfileResource {
|
|
||||||
selector: Some("builtin:intake"),
|
|
||||||
path: "profiles/intake.dcdl",
|
|
||||||
source: include_str!("../../../resources/profiles/intake.dcdl"),
|
|
||||||
description: "Read-only intake and planning.",
|
|
||||||
imports: BASE_IMPORT,
|
|
||||||
},
|
|
||||||
BuiltinProfileResource {
|
|
||||||
selector: Some("builtin:reviewer"),
|
|
||||||
path: "profiles/reviewer.dcdl",
|
|
||||||
source: include_str!("../../../resources/profiles/reviewer.dcdl"),
|
|
||||||
description: "Independent review of a published Merge Request source.",
|
|
||||||
imports: BASE_IMPORT,
|
|
||||||
},
|
|
||||||
BuiltinProfileResource {
|
|
||||||
selector: Some("builtin:orchestrator"),
|
|
||||||
path: "profiles/orchestrator.dcdl",
|
|
||||||
source: include_str!("../../../resources/profiles/orchestrator.dcdl"),
|
|
||||||
description: "Workspace orchestration and Worker control.",
|
|
||||||
imports: BASE_IMPORT,
|
|
||||||
},
|
|
||||||
BuiltinProfileResource {
|
|
||||||
selector: Some("builtin:memory-consolidation"),
|
|
||||||
path: "profiles/memory-consolidation.dcdl",
|
|
||||||
source: include_str!("../../../resources/profiles/memory-consolidation.dcdl"),
|
|
||||||
description: "Internal Memory consolidation service.",
|
|
||||||
imports: BASE_IMPORT,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct BuiltinProfileCatalogSnapshot {
|
|
||||||
pub id: &'static str,
|
|
||||||
pub sources: BTreeMap<String, String>,
|
|
||||||
pub entrypoints: BTreeMap<String, String>,
|
|
||||||
pub imports: BTreeMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BuiltinProfileCatalogSnapshot {
|
|
||||||
pub fn digest(&self) -> String {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(self.id.as_bytes());
|
|
||||||
for (path, source) in &self.sources {
|
|
||||||
hasher.update((path.len() as u64).to_le_bytes());
|
|
||||||
hasher.update(path.as_bytes());
|
|
||||||
hasher.update((source.len() as u64).to_le_bytes());
|
|
||||||
hasher.update(source.as_bytes());
|
|
||||||
}
|
|
||||||
for (selector, path) in &self.entrypoints {
|
|
||||||
hasher.update((selector.len() as u64).to_le_bytes());
|
|
||||||
hasher.update(selector.as_bytes());
|
|
||||||
hasher.update((path.len() as u64).to_le_bytes());
|
|
||||||
hasher.update(path.as_bytes());
|
|
||||||
}
|
|
||||||
for (request, resolved_path) in &self.imports {
|
|
||||||
hasher.update((request.len() as u64).to_le_bytes());
|
|
||||||
hasher.update(request.as_bytes());
|
|
||||||
hasher.update((resolved_path.len() as u64).to_le_bytes());
|
|
||||||
hasher.update(resolved_path.as_bytes());
|
|
||||||
}
|
|
||||||
format!("sha256:{:x}", hasher.finalize())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn builtin_profile_catalog_snapshot() -> BuiltinProfileCatalogSnapshot {
|
|
||||||
let mut sources = BTreeMap::new();
|
|
||||||
let mut entrypoints = BTreeMap::new();
|
|
||||||
let mut imports = BTreeMap::new();
|
|
||||||
|
|
||||||
for resource in BUILTIN_PROFILE_RESOURCES {
|
|
||||||
sources.insert(resource.path.to_owned(), resource.source.to_owned());
|
|
||||||
for import in resource.imports {
|
|
||||||
imports.insert(
|
|
||||||
format!("{}\0{}", resource.path, import.specifier),
|
|
||||||
import.resolved_path.to_owned(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(selector) = resource.selector {
|
|
||||||
entrypoints.insert(selector.to_owned(), resource.path.to_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BuiltinProfileCatalogSnapshot {
|
|
||||||
id: BUILTIN_PROFILE_CATALOG_ID,
|
|
||||||
sources,
|
|
||||||
entrypoints,
|
|
||||||
imports,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn builtin_profile_entrypoints() -> impl Iterator<Item = &'static BuiltinProfileResource> {
|
|
||||||
BUILTIN_PROFILE_RESOURCES
|
|
||||||
.iter()
|
|
||||||
.filter(|resource| resource.selector.is_some())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn resolve_builtin_profile_artifact(
|
|
||||||
selector: &str,
|
|
||||||
) -> Result<Option<Value>, ProfileError> {
|
|
||||||
let catalog = builtin_profile_catalog_snapshot();
|
|
||||||
let Some(entrypoint) = catalog.entrypoints.get(selector) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let source = catalog
|
|
||||||
.sources
|
|
||||||
.get(entrypoint)
|
|
||||||
.expect("built-in Profile entrypoint must name a source")
|
|
||||||
.clone();
|
|
||||||
let mut engine = Engine::new(BuiltinProfileImportLoader {
|
|
||||||
sources: catalog.sources,
|
|
||||||
});
|
|
||||||
let module = engine
|
|
||||||
.add_root_source(entrypoint, entrypoint, &source)
|
|
||||||
.map_err(|error| ProfileError::BuiltinProfileEvaluation {
|
|
||||||
selector: selector.to_owned(),
|
|
||||||
message: format!("{error:?}"),
|
|
||||||
})?;
|
|
||||||
let value =
|
|
||||||
engine
|
|
||||||
.eval_module(module)
|
|
||||||
.map_err(|error| ProfileError::BuiltinProfileEvaluation {
|
|
||||||
selector: selector.to_owned(),
|
|
||||||
message: format!("{error:?}"),
|
|
||||||
})?;
|
|
||||||
let data =
|
|
||||||
engine
|
|
||||||
.materialize(&value)
|
|
||||||
.map_err(|error| ProfileError::BuiltinProfileEvaluation {
|
|
||||||
selector: selector.to_owned(),
|
|
||||||
message: format!("{error:?}"),
|
|
||||||
})?;
|
|
||||||
Ok(Some(data_to_json(&data)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct BuiltinProfileImportLoader {
|
|
||||||
sources: BTreeMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImportLoader for BuiltinProfileImportLoader {
|
|
||||||
fn load(
|
|
||||||
&mut self,
|
|
||||||
current_key: Option<&str>,
|
|
||||||
specifier: &str,
|
|
||||||
) -> decodal::Result<LoadedImport> {
|
|
||||||
let current_key = current_key.ok_or_else(|| {
|
|
||||||
decodal::Diagnostic::new(
|
|
||||||
decodal::DiagnosticKind::Import,
|
|
||||||
decodal::Span::default(),
|
|
||||||
format!("built-in Profile import `{specifier}` has no source context"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let resolved = resolve_import_path(current_key, specifier).ok_or_else(|| {
|
|
||||||
decodal::Diagnostic::new(
|
|
||||||
decodal::DiagnosticKind::Import,
|
|
||||||
decodal::Span::default(),
|
|
||||||
format!("built-in Profile import `{specifier}` from `{current_key}` is invalid"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let source = self.sources.get(&resolved).ok_or_else(|| {
|
|
||||||
decodal::Diagnostic::new(
|
|
||||||
decodal::DiagnosticKind::Import,
|
|
||||||
decodal::Span::default(),
|
|
||||||
format!("built-in Profile import `{specifier}` from `{current_key}` was not found"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(LoadedImport::source(
|
|
||||||
resolved.clone(),
|
|
||||||
resolved,
|
|
||||||
source.clone(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_import_path(current_key: &str, specifier: &str) -> Option<String> {
|
|
||||||
let current_parent = current_key
|
|
||||||
.rsplit_once('/')
|
|
||||||
.map_or("", |(parent, _)| parent);
|
|
||||||
let joined = if let Some(relative) = specifier.strip_prefix("./") {
|
|
||||||
format!("{current_parent}/{relative}")
|
|
||||||
} else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
if joined
|
|
||||||
.split('/')
|
|
||||||
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(joined)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn data_to_json(data: &Data) -> Value {
|
|
||||||
match data {
|
|
||||||
Data::Bool(value) => Value::Bool(*value),
|
|
||||||
Data::Int(value) => Value::Number(Number::from(*value)),
|
|
||||||
Data::Float(value) => Number::from_f64(*value)
|
|
||||||
.map(Value::Number)
|
|
||||||
.unwrap_or(Value::Null),
|
|
||||||
Data::String(value) => Value::String(value.clone()),
|
|
||||||
Data::Array(values) => Value::Array(values.iter().map(data_to_json).collect()),
|
|
||||||
Data::Object(fields) => Value::Object(
|
|
||||||
fields
|
|
||||||
.iter()
|
|
||||||
.map(|field| (field.name.clone(), data_to_json(&field.value)))
|
|
||||||
.collect::<Map<_, _>>(),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn catalog_has_one_explicit_entrypoint_for_each_builtin_profile() {
|
|
||||||
let catalog = builtin_profile_catalog_snapshot();
|
|
||||||
assert_eq!(catalog.sources.len(), BUILTIN_PROFILE_RESOURCES.len());
|
|
||||||
assert_eq!(catalog.entrypoints.len() + 1, catalog.sources.len());
|
|
||||||
assert_eq!(
|
|
||||||
catalog.entrypoints.get(BUILTIN_DEFAULT_PROFILE),
|
|
||||||
Some(&"profiles/default.dcdl".to_owned())
|
|
||||||
);
|
|
||||||
assert!(catalog.digest().starts_with("sha256:"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn default_profile_evaluates_from_the_shared_resource_graph() {
|
|
||||||
let value = resolve_builtin_profile_artifact(BUILTIN_DEFAULT_PROFILE)
|
|
||||||
.expect("evaluate built-in default")
|
|
||||||
.expect("default exists");
|
|
||||||
assert_eq!(value["slug"], "default");
|
|
||||||
assert_eq!(value["feature"]["task"]["enabled"], true);
|
|
||||||
assert_eq!(value["feature"]["sub_worker"]["enabled"], true);
|
|
||||||
assert_eq!(value["feature"]["memory"]["enabled"], false);
|
|
||||||
assert_eq!(value["feature"]["ticket"]["enabled"], false);
|
|
||||||
assert_eq!(value["feature"]["worker"]["enabled"], false);
|
|
||||||
assert_eq!(value["feature"]["manage_workdir"]["enabled"], false);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn imports_cannot_escape_the_builtin_resource_catalog() {
|
|
||||||
assert_eq!(
|
|
||||||
resolve_import_path("profiles/default.dcdl", "./base.dcdl").as_deref(),
|
|
||||||
Some("profiles/base.dcdl")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
resolve_import_path("profiles/default.dcdl", "../outside.dcdl"),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
resolve_import_path("profiles/default.dcdl", "/outside.dcdl"),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+110
-209
@@ -15,13 +15,13 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::defaults;
|
use crate::defaults;
|
||||||
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
|
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
|
||||||
|
use crate::plugin::PluginConfig;
|
||||||
use crate::{
|
use crate::{
|
||||||
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
||||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
|
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConfig, MemoryFeatureConfig,
|
||||||
MemoryExtractionProfileConfig, MemoryFeatureProfileConfig, MemoryResidentProfileConfig,
|
MergeRequestFeatureConfig, ScopeConfig, SessionConfig, SkillsConfig, TicketFeatureConfig,
|
||||||
MergeRequestFeatureConfig, ResolvedMemoryFeatureConfig, ScopeConfig, SessionConfig,
|
ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule, WebConfig, WorkerFeatureConfig,
|
||||||
SkillsConfig, TicketFeatureConfig, ToolOutputLimits, ToolPermissionConfig, ToolPermissionRule,
|
WorkerManifest, WorkerMeta,
|
||||||
WebConfig, WorkerFeatureConfig, WorkerManifest, WorkerMeta,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Partial-form Worker manifest. Every field is optional; one or more
|
/// Partial-form Worker manifest. Every field is optional; one or more
|
||||||
@@ -54,6 +54,10 @@ pub struct WorkerManifestConfig {
|
|||||||
/// disabled after cascade merge.
|
/// disabled after cascade merge.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub feature: FeatureConfigPartial,
|
pub feature: FeatureConfigPartial,
|
||||||
|
/// Explicit plugin package enablement entries. Discovery/resolution is a
|
||||||
|
/// separate step and does not run during config merge.
|
||||||
|
#[serde(default)]
|
||||||
|
pub plugins: PluginConfig,
|
||||||
/// Explicit Model Context Protocol provider declarations. Config parsing
|
/// Explicit Model Context Protocol provider declarations. Config parsing
|
||||||
/// never starts a local MCP subprocess.
|
/// never starts a local MCP subprocess.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -63,13 +67,15 @@ pub struct WorkerManifestConfig {
|
|||||||
/// First-class web tool opt-in. See [`WebConfig`].
|
/// First-class web tool opt-in. See [`WebConfig`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub web: Option<WebConfig>,
|
pub web: Option<WebConfig>,
|
||||||
|
/// Memory subsystem opt-in. See [`MemoryConfig`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub memory: Option<MemoryConfig>,
|
||||||
/// External Agent Skills directories. See [`crate::SkillsConfig`].
|
/// External Agent Skills directories. See [`crate::SkillsConfig`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub skills: Option<SkillsConfig>,
|
pub skills: Option<SkillsConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct FeatureConfigPartial {
|
pub struct FeatureConfigPartial {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub task: Option<FeatureFlagConfigPartial>,
|
pub task: Option<FeatureFlagConfigPartial>,
|
||||||
@@ -86,8 +92,6 @@ pub struct FeatureConfigPartial {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub worker: Option<WorkerFeatureConfigPartial>,
|
pub worker: Option<WorkerFeatureConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub workspace_worker_discovery: Option<FeatureFlagConfigPartial>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub objective: Option<FeatureFlagConfigPartial>,
|
pub objective: Option<FeatureFlagConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub manage_workdir: Option<FeatureFlagConfigPartial>,
|
pub manage_workdir: Option<FeatureFlagConfigPartial>,
|
||||||
@@ -97,6 +101,8 @@ pub struct FeatureConfigPartial {
|
|||||||
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
|
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub orchestration: Option<FeatureFlagConfigPartial>,
|
pub orchestration: Option<FeatureFlagConfigPartial>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub plugins: Option<FeatureFlagConfigPartial>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeatureConfigPartial {
|
impl FeatureConfigPartial {
|
||||||
@@ -113,11 +119,6 @@ impl FeatureConfigPartial {
|
|||||||
),
|
),
|
||||||
flow: merge_option(self.flow, other.flow, FeatureFlagConfigPartial::merge),
|
flow: merge_option(self.flow, other.flow, FeatureFlagConfigPartial::merge),
|
||||||
worker: merge_option(self.worker, other.worker, WorkerFeatureConfigPartial::merge),
|
worker: merge_option(self.worker, other.worker, WorkerFeatureConfigPartial::merge),
|
||||||
workspace_worker_discovery: merge_option(
|
|
||||||
self.workspace_worker_discovery,
|
|
||||||
other.workspace_worker_discovery,
|
|
||||||
FeatureFlagConfigPartial::merge,
|
|
||||||
),
|
|
||||||
objective: merge_option(
|
objective: merge_option(
|
||||||
self.objective,
|
self.objective,
|
||||||
other.objective,
|
other.objective,
|
||||||
@@ -139,6 +140,7 @@ impl FeatureConfigPartial {
|
|||||||
other.orchestration,
|
other.orchestration,
|
||||||
FeatureFlagConfigPartial::merge,
|
FeatureFlagConfigPartial::merge,
|
||||||
),
|
),
|
||||||
|
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,86 +186,18 @@ impl From<WorkerFeatureConfigPartial> for WorkerFeatureConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct MemoryFeatureConfigPartial {
|
pub struct MemoryFeatureConfigPartial {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub staging_tools: Option<bool>,
|
pub staging: Option<bool>,
|
||||||
#[serde(default)]
|
|
||||||
pub resident: Option<MemoryResidentProfileConfigPartial>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub consolidation: Option<MemoryConsolidationProfileConfigPartial>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct MemoryResidentProfileConfigPartial {
|
|
||||||
#[serde(default)]
|
|
||||||
pub inject_summary: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct MemoryExtractionProfileConfigPartial {
|
|
||||||
#[serde(default)]
|
|
||||||
pub enabled: Option<bool>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub model: Option<ModelManifest>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub threshold: Option<u64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub worker_max_turns: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct MemoryConsolidationProfileConfigPartial {
|
|
||||||
#[serde(default)]
|
|
||||||
pub request_enabled: Option<bool>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryFeatureConfigPartial {
|
impl MemoryFeatureConfigPartial {
|
||||||
fn merge(self, other: Self) -> Self {
|
fn merge(self, other: Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: other.enabled.or(self.enabled),
|
enabled: other.enabled.or(self.enabled),
|
||||||
staging_tools: other.staging_tools.or(self.staging_tools),
|
staging: other.staging.or(self.staging),
|
||||||
resident: merge_option(
|
|
||||||
self.resident,
|
|
||||||
other.resident,
|
|
||||||
MemoryResidentProfileConfigPartial::merge,
|
|
||||||
),
|
|
||||||
extraction: merge_option(
|
|
||||||
self.extraction,
|
|
||||||
other.extraction,
|
|
||||||
MemoryExtractionProfileConfigPartial::merge,
|
|
||||||
),
|
|
||||||
consolidation: merge_option(
|
|
||||||
self.consolidation,
|
|
||||||
other.consolidation,
|
|
||||||
MemoryConsolidationProfileConfigPartial::merge,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MemoryResidentProfileConfigPartial {
|
|
||||||
fn merge(self, other: Self) -> Self {
|
|
||||||
Self {
|
|
||||||
inject_summary: other.inject_summary.or(self.inject_summary),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MemoryExtractionProfileConfigPartial {
|
|
||||||
fn merge(self, other: Self) -> Self {
|
|
||||||
Self {
|
|
||||||
enabled: other.enabled.or(self.enabled),
|
|
||||||
model: other.model.or(self.model),
|
|
||||||
threshold: other.threshold.or(self.threshold),
|
|
||||||
worker_max_turns: other.worker_max_turns.or(self.worker_max_turns),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,21 +246,13 @@ impl MergeRequestFeatureConfigPartial {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryConsolidationProfileConfigPartial {
|
|
||||||
fn merge(self, other: Self) -> Self {
|
|
||||||
Self {
|
|
||||||
request_enabled: other.request_enabled.or(self.request_enabled),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<FeatureConfigPartial> for FeatureConfig {
|
impl From<FeatureConfigPartial> for FeatureConfig {
|
||||||
fn from(value: FeatureConfigPartial) -> Self {
|
fn from(value: FeatureConfigPartial) -> Self {
|
||||||
Self {
|
Self {
|
||||||
task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(),
|
task: value.task.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||||
memory: value
|
memory: value
|
||||||
.memory
|
.memory
|
||||||
.map(ResolvedMemoryFeatureConfig::from)
|
.map(MemoryFeatureConfig::from)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
|
web: value.web.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||||
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
|
image: value.image.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||||
@@ -339,10 +265,6 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
|||||||
.worker
|
.worker
|
||||||
.map(WorkerFeatureConfig::from)
|
.map(WorkerFeatureConfig::from)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
workspace_worker_discovery: value
|
|
||||||
.workspace_worker_discovery
|
|
||||||
.map(FeatureFlagConfig::from)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
objective: value
|
objective: value
|
||||||
.objective
|
.objective
|
||||||
.map(FeatureFlagConfig::from)
|
.map(FeatureFlagConfig::from)
|
||||||
@@ -363,6 +285,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
|||||||
.orchestration
|
.orchestration
|
||||||
.map(FeatureFlagConfig::from)
|
.map(FeatureFlagConfig::from)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
|
plugins: value
|
||||||
|
.plugins
|
||||||
|
.map(FeatureFlagConfig::from)
|
||||||
|
.unwrap_or_default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,52 +318,20 @@ impl From<WorkerFeatureConfig> for WorkerFeatureConfigPartial {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<MemoryFeatureConfigPartial> for ResolvedMemoryFeatureConfig {
|
impl From<MemoryFeatureConfigPartial> for MemoryFeatureConfig {
|
||||||
fn from(value: MemoryFeatureConfigPartial) -> Self {
|
fn from(value: MemoryFeatureConfigPartial) -> Self {
|
||||||
let resident = value.resident.unwrap_or_default();
|
|
||||||
let extraction = value.extraction.unwrap_or_default();
|
|
||||||
let consolidation = value.consolidation.unwrap_or_default();
|
|
||||||
Self {
|
Self {
|
||||||
profile: MemoryFeatureProfileConfig {
|
enabled: value.enabled.unwrap_or_default(),
|
||||||
enabled: value.enabled.unwrap_or_default(),
|
staging: value.staging.unwrap_or_default(),
|
||||||
staging_tools: value.staging_tools.unwrap_or_default(),
|
|
||||||
resident: MemoryResidentProfileConfig {
|
|
||||||
inject_summary: resident.inject_summary.unwrap_or(true),
|
|
||||||
},
|
|
||||||
extraction: MemoryExtractionProfileConfig {
|
|
||||||
enabled: extraction.enabled.unwrap_or(true),
|
|
||||||
model: extraction.model,
|
|
||||||
threshold: extraction.threshold.or(Some(50_000)),
|
|
||||||
worker_max_turns: extraction
|
|
||||||
.worker_max_turns
|
|
||||||
.or(defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS),
|
|
||||||
},
|
|
||||||
consolidation: MemoryConsolidationProfileConfig {
|
|
||||||
request_enabled: consolidation.request_enabled.unwrap_or(true),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
workspace_settings: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ResolvedMemoryFeatureConfig> for MemoryFeatureConfigPartial {
|
impl From<MemoryFeatureConfig> for MemoryFeatureConfigPartial {
|
||||||
fn from(value: ResolvedMemoryFeatureConfig) -> Self {
|
fn from(value: MemoryFeatureConfig) -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: Some(value.profile.enabled),
|
enabled: Some(value.enabled),
|
||||||
staging_tools: Some(value.profile.staging_tools),
|
staging: Some(value.staging),
|
||||||
resident: Some(MemoryResidentProfileConfigPartial {
|
|
||||||
inject_summary: Some(value.profile.resident.inject_summary),
|
|
||||||
}),
|
|
||||||
extraction: Some(MemoryExtractionProfileConfigPartial {
|
|
||||||
enabled: Some(value.profile.extraction.enabled),
|
|
||||||
model: value.profile.extraction.model,
|
|
||||||
threshold: value.profile.extraction.threshold,
|
|
||||||
worker_max_turns: value.profile.extraction.worker_max_turns,
|
|
||||||
}),
|
|
||||||
consolidation: Some(MemoryConsolidationProfileConfigPartial {
|
|
||||||
request_enabled: Some(value.profile.consolidation.request_enabled),
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,12 +394,12 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
|||||||
sub_worker: Some(value.sub_worker.into()),
|
sub_worker: Some(value.sub_worker.into()),
|
||||||
flow: Some(value.flow.into()),
|
flow: Some(value.flow.into()),
|
||||||
worker: Some(value.worker.into()),
|
worker: Some(value.worker.into()),
|
||||||
workspace_worker_discovery: Some(value.workspace_worker_discovery.into()),
|
|
||||||
objective: Some(value.objective.into()),
|
objective: Some(value.objective.into()),
|
||||||
manage_workdir: Some(value.manage_workdir.into()),
|
manage_workdir: Some(value.manage_workdir.into()),
|
||||||
ticket: Some(value.ticket.into()),
|
ticket: Some(value.ticket.into()),
|
||||||
merge_request: Some(value.merge_request.into()),
|
merge_request: Some(value.merge_request.into()),
|
||||||
orchestration: Some(value.orchestration.into()),
|
orchestration: Some(value.orchestration.into()),
|
||||||
|
plugins: Some(value.plugins.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -637,23 +531,13 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
|
|||||||
(removed; use compaction.prune_protected_tokens)",
|
(removed; use compaction.prune_protected_tokens)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if value.get("memory").is_some() {
|
|
||||||
return Err(toml::de::Error::custom(
|
|
||||||
"unknown field in manifest: memory (removed; configure feature.memory)",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if value.get("plugins").is_some() {
|
|
||||||
return Err(toml::de::Error::custom(
|
|
||||||
"unknown field in manifest: plugins (dynamic Plugins are not supported)",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if value
|
if value
|
||||||
.get("feature")
|
.get("memory")
|
||||||
.and_then(toml::Value::as_table)
|
.and_then(toml::Value::as_table)
|
||||||
.is_some_and(|table| table.contains_key("plugins"))
|
.is_some_and(|table| table.contains_key("extract_worker_max_input_tokens"))
|
||||||
{
|
{
|
||||||
return Err(toml::de::Error::custom(
|
return Err(toml::de::Error::custom(
|
||||||
"unknown field in manifest: feature.plugins (dynamic Plugins are not supported)",
|
"unknown field in manifest: memory.extract_worker_max_input_tokens (removed)",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if value
|
if value
|
||||||
@@ -682,16 +566,15 @@ impl WorkerManifestConfig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Base config populated with the in-code per-field defaults listed in
|
/// Base config populated with the in-code defaults listed in
|
||||||
/// [`crate::defaults`]. This is not a selectable Profile and does not
|
/// [`crate::defaults`]. Profile and one-file Manifest resolvers start
|
||||||
/// enable a launch capability surface. Profile and one-file Manifest
|
/// from this layer so every per-field default lives at exactly one
|
||||||
/// resolvers start from this layer so every per-field default lives at
|
/// call site (the `defaults` module).
|
||||||
/// exactly one call site (the `defaults` module).
|
|
||||||
///
|
///
|
||||||
/// `TryFrom<WorkerManifestConfig>` also reads the same constants as a
|
/// `TryFrom<WorkerManifestConfig>` also reads the same constants as a
|
||||||
/// belt-and-suspenders fallback, so a manually-constructed config
|
/// belt-and-suspenders fallback, so a manually-constructed config
|
||||||
/// that skips this layer still resolves to the same values.
|
/// that skips this layer still resolves to the same values.
|
||||||
pub fn resolution_defaults() -> Self {
|
pub fn builtin_defaults() -> Self {
|
||||||
Self {
|
Self {
|
||||||
engine: EngineManifestConfig {
|
engine: EngineManifestConfig {
|
||||||
tool_output: ToolOutputLimitsPartial {
|
tool_output: ToolOutputLimitsPartial {
|
||||||
@@ -737,6 +620,11 @@ impl WorkerManifestConfig {
|
|||||||
for rule in &mut self.delegation_scope.deny {
|
for rule in &mut self.delegation_scope.deny {
|
||||||
rule.target = join_if_relative(base, &rule.target);
|
rule.target = join_if_relative(base, &rule.target);
|
||||||
}
|
}
|
||||||
|
if let Some(ref mut memory) = self.memory
|
||||||
|
&& let Some(ref mut root) = memory.workspace_root
|
||||||
|
{
|
||||||
|
*root = join_if_relative(base, root);
|
||||||
|
}
|
||||||
if let Some(ref mut compaction) = self.compaction
|
if let Some(ref mut compaction) = self.compaction
|
||||||
&& let Some(ref mut cp) = compaction.model
|
&& let Some(ref mut cp) = compaction.model
|
||||||
{
|
{
|
||||||
@@ -773,6 +661,7 @@ impl WorkerManifestConfig {
|
|||||||
PermissionConfigPartial::merge,
|
PermissionConfigPartial::merge,
|
||||||
),
|
),
|
||||||
feature: self.feature.merge(upper.feature),
|
feature: self.feature.merge(upper.feature),
|
||||||
|
plugins: merge_plugin_config(self.plugins, upper.plugins),
|
||||||
mcp: merge_mcp_config(self.mcp, upper.mcp),
|
mcp: merge_mcp_config(self.mcp, upper.mcp),
|
||||||
compaction: merge_option(
|
compaction: merge_option(
|
||||||
self.compaction,
|
self.compaction,
|
||||||
@@ -780,6 +669,7 @@ impl WorkerManifestConfig {
|
|||||||
CompactionConfigPartial::merge,
|
CompactionConfigPartial::merge,
|
||||||
),
|
),
|
||||||
web: merge_option(self.web, upper.web, WebConfig::merge),
|
web: merge_option(self.web, upper.web, WebConfig::merge),
|
||||||
|
memory: merge_option(self.memory, upper.memory, MemoryConfig::merge),
|
||||||
skills: merge_option(self.skills, upper.skills, SkillsConfig::merge),
|
skills: merge_option(self.skills, upper.skills, SkillsConfig::merge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -792,6 +682,16 @@ impl SkillsConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginConfig {
|
||||||
|
let upper_has_resolved_plan = upper.has_resolved_plan();
|
||||||
|
base.enabled.extend(upper.enabled);
|
||||||
|
if upper_has_resolved_plan {
|
||||||
|
base.resolved = upper.resolved;
|
||||||
|
base.diagnostics = upper.diagnostics;
|
||||||
|
}
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig {
|
fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig {
|
||||||
base.stdio_servers.extend(upper.stdio_servers);
|
base.stdio_servers.extend(upper.stdio_servers);
|
||||||
base
|
base
|
||||||
@@ -841,6 +741,32 @@ impl crate::WebFetchConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl MemoryConfig {
|
||||||
|
fn merge(self, upper: Self) -> Self {
|
||||||
|
Self {
|
||||||
|
workspace_root: upper.workspace_root.or(self.workspace_root),
|
||||||
|
query_result_limit: upper.query_result_limit.or(self.query_result_limit),
|
||||||
|
query_excerpt_lines: upper.query_excerpt_lines.or(self.query_excerpt_lines),
|
||||||
|
inject_summary: upper.inject_summary.or(self.inject_summary),
|
||||||
|
workspace_id: upper.workspace_id.or(self.workspace_id),
|
||||||
|
settings_revision: upper.settings_revision.or(self.settings_revision),
|
||||||
|
language: upper.language.or(self.language),
|
||||||
|
extract_model: upper.extract_model.or(self.extract_model),
|
||||||
|
extract_threshold: upper.extract_threshold.or(self.extract_threshold),
|
||||||
|
extract_worker_max_turns: upper
|
||||||
|
.extract_worker_max_turns
|
||||||
|
.or(self.extract_worker_max_turns),
|
||||||
|
consolidation_model: upper.consolidation_model.or(self.consolidation_model),
|
||||||
|
consolidation_threshold_files: upper
|
||||||
|
.consolidation_threshold_files
|
||||||
|
.or(self.consolidation_threshold_files),
|
||||||
|
consolidation_threshold_bytes: upper
|
||||||
|
.consolidation_threshold_bytes
|
||||||
|
.or(self.consolidation_threshold_bytes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl WorkerMetaConfig {
|
impl WorkerMetaConfig {
|
||||||
fn merge(self, upper: Self) -> Self {
|
fn merge(self, upper: Self) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -1280,9 +1206,11 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
|||||||
session,
|
session,
|
||||||
permissions,
|
permissions,
|
||||||
feature: FeatureConfig::from(cfg.feature),
|
feature: FeatureConfig::from(cfg.feature),
|
||||||
|
plugins: cfg.plugins,
|
||||||
mcp: cfg.mcp,
|
mcp: cfg.mcp,
|
||||||
compaction,
|
compaction,
|
||||||
web: cfg.web,
|
web: cfg.web,
|
||||||
|
memory: cfg.memory,
|
||||||
skills: cfg.skills,
|
skills: cfg.skills,
|
||||||
profile: None,
|
profile: None,
|
||||||
})
|
})
|
||||||
@@ -1319,17 +1247,18 @@ mod tests {
|
|||||||
target: abs("/worker"),
|
target: abs("/worker"),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
},
|
},
|
||||||
delegation_scope: ScopeConfig::default(),
|
delegation_scope: ScopeConfig::default(),
|
||||||
permissions: None,
|
permissions: None,
|
||||||
feature: FeatureConfigPartial::default(),
|
feature: FeatureConfigPartial::default(),
|
||||||
|
plugins: PluginConfig::default(),
|
||||||
mcp: McpConfig::default(),
|
mcp: McpConfig::default(),
|
||||||
session: None,
|
session: None,
|
||||||
compaction: None,
|
compaction: None,
|
||||||
web: None,
|
web: None,
|
||||||
|
memory: None,
|
||||||
skills: None,
|
skills: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1565,7 +1494,6 @@ mod tests {
|
|||||||
target: PathBuf::from("secrets"),
|
target: PathBuf::from("secrets"),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
});
|
});
|
||||||
let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
|
let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
|
||||||
assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj"));
|
assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj"));
|
||||||
@@ -1703,7 +1631,6 @@ mod tests {
|
|||||||
target: abs("/a"),
|
target: abs("/a"),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
},
|
},
|
||||||
@@ -1715,13 +1642,11 @@ mod tests {
|
|||||||
target: abs("/b"),
|
target: abs("/b"),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}],
|
}],
|
||||||
deny: vec![ScopeRule {
|
deny: vec![ScopeRule {
|
||||||
target: abs("/a/secret"),
|
target: abs("/a/secret"),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: false,
|
recursive: false,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -1908,50 +1833,29 @@ prune_protected_turns = 3
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_toml_accepts_memory_extraction_settings_only_under_feature_memory() {
|
fn from_toml_rejects_removed_extract_worker_max_input_tokens_field() {
|
||||||
let cfg = WorkerManifestConfig::from_toml(
|
let bad = r#"
|
||||||
r#"
|
[memory]
|
||||||
[feature.memory]
|
extract_worker_max_input_tokens = 30000
|
||||||
enabled = true
|
"#;
|
||||||
staging_tools = false
|
let err = WorkerManifestConfig::from_toml(bad).unwrap_err();
|
||||||
|
assert!(
|
||||||
[feature.memory.resident]
|
err.to_string()
|
||||||
inject_summary = false
|
.contains("memory.extract_worker_max_input_tokens"),
|
||||||
|
"unexpected error: {err}"
|
||||||
[feature.memory.extraction]
|
);
|
||||||
enabled = true
|
|
||||||
threshold = 42000
|
|
||||||
worker_max_turns = 2
|
|
||||||
|
|
||||||
[feature.memory.consolidation]
|
|
||||||
request_enabled = false
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let memory = cfg.feature.memory.unwrap();
|
|
||||||
assert_eq!(memory.enabled, Some(true));
|
|
||||||
assert_eq!(memory.staging_tools, Some(false));
|
|
||||||
assert_eq!(memory.resident.unwrap().inject_summary, Some(false));
|
|
||||||
assert_eq!(memory.consolidation.unwrap().request_enabled, Some(false));
|
|
||||||
let extraction = memory.extraction.unwrap();
|
|
||||||
assert_eq!(extraction.enabled, Some(true));
|
|
||||||
assert_eq!(extraction.threshold, Some(42_000));
|
|
||||||
assert_eq!(extraction.worker_max_turns, Some(2));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn from_toml_rejects_legacy_top_level_memory_authority() {
|
fn from_toml_accepts_extract_worker_max_turns() {
|
||||||
let err = WorkerManifestConfig::from_toml(
|
let cfg = WorkerManifestConfig::from_toml(
|
||||||
r#"
|
r#"
|
||||||
[memory]
|
[memory]
|
||||||
extract_worker_max_turns = 2
|
extract_worker_max_turns = 2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap();
|
||||||
assert!(
|
assert_eq!(cfg.memory.unwrap().extract_worker_max_turns, Some(2));
|
||||||
err.to_string().contains("memory"),
|
|
||||||
"unexpected error: {err}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2031,7 +1935,7 @@ worker_max_turns = 7
|
|||||||
fn feature_flags_default_disabled_in_resolved_manifest() {
|
fn feature_flags_default_disabled_in_resolved_manifest() {
|
||||||
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||||
assert!(!manifest.feature.task.enabled);
|
assert!(!manifest.feature.task.enabled);
|
||||||
assert!(!manifest.feature.memory.profile.enabled);
|
assert!(!manifest.feature.memory.enabled);
|
||||||
assert!(!manifest.feature.web.enabled);
|
assert!(!manifest.feature.web.enabled);
|
||||||
assert!(!manifest.feature.sub_worker.enabled);
|
assert!(!manifest.feature.sub_worker.enabled);
|
||||||
assert!(!manifest.feature.objective.enabled);
|
assert!(!manifest.feature.objective.enabled);
|
||||||
@@ -2069,7 +1973,7 @@ enabled = false
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let manifest: WorkerManifest = WorkerManifestConfig::resolution_defaults()
|
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
|
||||||
.merge(cfg)
|
.merge(cfg)
|
||||||
.merge(WorkerManifestConfig {
|
.merge(WorkerManifestConfig {
|
||||||
worker: WorkerMetaConfig {
|
worker: WorkerMetaConfig {
|
||||||
@@ -2085,7 +1989,6 @@ enabled = false
|
|||||||
target: abs("/worker"),
|
target: abs("/worker"),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
},
|
},
|
||||||
@@ -2109,8 +2012,8 @@ enabled = false
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert!(!manifest.feature.orchestration.enabled);
|
assert!(!manifest.feature.orchestration.enabled);
|
||||||
assert!(!manifest.feature.memory.profile.enabled);
|
assert!(!manifest.feature.memory.enabled);
|
||||||
assert!(!manifest.feature.memory.profile.staging_tools);
|
assert!(!manifest.feature.memory.staging);
|
||||||
assert!(!manifest.feature.objective.enabled);
|
assert!(!manifest.feature.objective.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2158,7 +2061,7 @@ readiness_check = true
|
|||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
[feature.memory]
|
[feature.memory]
|
||||||
staging_tools = true
|
staging = true
|
||||||
|
|
||||||
[feature.manage_workdir]
|
[feature.manage_workdir]
|
||||||
enabled = true
|
enabled = true
|
||||||
@@ -2171,7 +2074,7 @@ enabled = true
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let manifest: WorkerManifest = WorkerManifestConfig::resolution_defaults()
|
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
|
||||||
.merge(base)
|
.merge(base)
|
||||||
.merge(upper)
|
.merge(upper)
|
||||||
.merge(WorkerManifestConfig {
|
.merge(WorkerManifestConfig {
|
||||||
@@ -2188,7 +2091,6 @@ enabled = true
|
|||||||
target: abs("/worker"),
|
target: abs("/worker"),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
},
|
},
|
||||||
@@ -2196,8 +2098,8 @@ enabled = true
|
|||||||
})
|
})
|
||||||
.try_into()
|
.try_into()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(manifest.feature.memory.profile.enabled);
|
assert!(manifest.feature.memory.enabled);
|
||||||
assert!(manifest.feature.memory.profile.staging_tools);
|
assert!(manifest.feature.memory.staging);
|
||||||
assert!(manifest.feature.manage_workdir.enabled);
|
assert!(manifest.feature.manage_workdir.enabled);
|
||||||
assert!(manifest.feature.ticket.enabled);
|
assert!(manifest.feature.ticket.enabled);
|
||||||
assert!(!manifest.feature.ticket.authoring);
|
assert!(!manifest.feature.ticket.authoring);
|
||||||
@@ -2235,7 +2137,7 @@ permission = "write"
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_defaults_populates_worker_limit_defaults() {
|
fn builtin_defaults_populates_worker_limit_defaults() {
|
||||||
let cfg = WorkerManifestConfig::resolution_defaults();
|
let cfg = WorkerManifestConfig::builtin_defaults();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.engine.tool_output.default_max_bytes,
|
cfg.engine.tool_output.default_max_bytes,
|
||||||
Some(defaults::TOOL_OUTPUT_MAX_BYTES)
|
Some(defaults::TOOL_OUTPUT_MAX_BYTES)
|
||||||
@@ -2265,13 +2167,12 @@ permission = "write"
|
|||||||
target: abs("/worker"),
|
target: abs("/worker"),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let merged = WorkerManifestConfig::resolution_defaults().merge(overlay);
|
let merged = WorkerManifestConfig::builtin_defaults().merge(overlay);
|
||||||
let manifest: WorkerManifest = merged.try_into().unwrap();
|
let manifest: WorkerManifest = merged.try_into().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
manifest.engine.tool_output.default_max_bytes,
|
manifest.engine.tool_output.default_max_bytes,
|
||||||
|
|||||||
@@ -93,5 +93,5 @@ pub const COMPACT_RESULT_CONTEXT_MAX_TOKENS: u64 = 60_000;
|
|||||||
pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5;
|
pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5;
|
||||||
|
|
||||||
/// Optional maximum extract-worker tool-loop depth. `None` means unlimited.
|
/// Optional maximum extract-worker tool-loop depth. `None` means unlimited.
|
||||||
/// See [`crate::MemoryExtractionProfileConfig::worker_max_turns`].
|
/// See [`crate::MemoryConfig::extract_worker_max_turns`].
|
||||||
pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
|
pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
|
||||||
|
|||||||
+172
-673
@@ -1,4 +1,3 @@
|
|||||||
mod builtin_profile;
|
|
||||||
mod config;
|
mod config;
|
||||||
pub mod defaults;
|
pub mod defaults;
|
||||||
mod model;
|
mod model;
|
||||||
@@ -8,11 +7,6 @@ pub mod plugin;
|
|||||||
mod profile;
|
mod profile;
|
||||||
mod scope;
|
mod scope;
|
||||||
|
|
||||||
pub use builtin_profile::{
|
|
||||||
BUILTIN_DEFAULT_PROFILE, BUILTIN_PROFILE_CATALOG_ID, BUILTIN_PROFILE_RESOURCES,
|
|
||||||
BuiltinProfileCatalogSnapshot, BuiltinProfileImport, BuiltinProfileResource,
|
|
||||||
builtin_profile_catalog_snapshot, builtin_profile_entrypoints,
|
|
||||||
};
|
|
||||||
pub use config::{
|
pub use config::{
|
||||||
CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial,
|
CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial,
|
||||||
PermissionConfigPartial, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial,
|
PermissionConfigPartial, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial,
|
||||||
@@ -23,13 +17,12 @@ pub use model::{
|
|||||||
};
|
};
|
||||||
pub use paths::user_profiles_path;
|
pub use paths::user_profiles_path;
|
||||||
pub use profile::{
|
pub use profile::{
|
||||||
ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
|
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
|
||||||
ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
|
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
|
||||||
ProfileResolveOptions, ProfileResolver, ProfileSelector, ProfileSource, ResolvedProfile,
|
ProfileSelector, ProfileSource, ResolvedProfile, resolve_profile_artifact,
|
||||||
WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value,
|
resolve_profile_artifact_value,
|
||||||
validate_profile_execution_target,
|
|
||||||
};
|
};
|
||||||
pub use protocol::{Permission, ScopeRule, SymlinkPolicy};
|
pub use protocol::{Permission, ScopeRule};
|
||||||
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
@@ -47,7 +40,6 @@ use serde::{Deserialize, Serialize};
|
|||||||
/// part of the manifest — it is the process's `std::env::current_dir()`
|
/// part of the manifest — it is the process's `std::env::current_dir()`
|
||||||
/// at construction time.
|
/// at construction time.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkerManifest {
|
pub struct WorkerManifest {
|
||||||
pub worker: WorkerMeta,
|
pub worker: WorkerMeta,
|
||||||
pub model: ModelManifest,
|
pub model: ModelManifest,
|
||||||
@@ -69,6 +61,10 @@ pub struct WorkerManifest {
|
|||||||
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
|
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub feature: FeatureConfig,
|
pub feature: FeatureConfig,
|
||||||
|
/// Explicit plugin package enablement. Discovery remains read-only; only
|
||||||
|
/// source-qualified entries listed here may resolve to active plugin metadata.
|
||||||
|
#[serde(default)]
|
||||||
|
pub plugins: plugin::PluginConfig,
|
||||||
/// Explicit external Model Context Protocol provider configuration. This
|
/// Explicit external Model Context Protocol provider configuration. This
|
||||||
/// is config data only: declaring a server never starts a subprocess or
|
/// is config data only: declaring a server never starts a subprocess or
|
||||||
/// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate
|
/// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate
|
||||||
@@ -77,6 +73,11 @@ pub struct WorkerManifest {
|
|||||||
pub mcp: McpConfig,
|
pub mcp: McpConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub compaction: Option<CompactionConfig>,
|
pub compaction: Option<CompactionConfig>,
|
||||||
|
/// Memory subsystem configuration. Presence of `[memory]` configures memory
|
||||||
|
/// storage, extraction, consolidation, and resident injection, but memory
|
||||||
|
/// tools are surfaced only when `[feature.memory].enabled = true`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub memory: Option<MemoryConfig>,
|
||||||
/// First-class web tools configuration. Network access remains fail-closed
|
/// First-class web tools configuration. Network access remains fail-closed
|
||||||
/// under this config; WebSearch/WebFetch schemas are surfaced only when
|
/// under this config; WebSearch/WebFetch schemas are surfaced only when
|
||||||
/// `[feature.web].enabled = true`.
|
/// `[feature.web].enabled = true`.
|
||||||
@@ -101,13 +102,12 @@ pub struct WorkerManifest {
|
|||||||
/// profile/config data only: they do not carry runtime Worker names, sockets,
|
/// profile/config data only: they do not carry runtime Worker names, sockets,
|
||||||
/// sessions, secrets, or resolved host state. Tool registration still applies
|
/// sessions, secrets, or resolved host state. Tool registration still applies
|
||||||
/// the normal scope, host-authority, backend, memory, and network checks.
|
/// the normal scope, host-authority, backend, memory, and network checks.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct FeatureConfig {
|
pub struct FeatureConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub task: FeatureFlagConfig,
|
pub task: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub memory: ResolvedMemoryFeatureConfig,
|
pub memory: MemoryFeatureConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub web: FeatureFlagConfig,
|
pub web: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -118,10 +118,6 @@ pub struct FeatureConfig {
|
|||||||
pub flow: FeatureFlagConfig,
|
pub flow: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub worker: WorkerFeatureConfig,
|
pub worker: WorkerFeatureConfig,
|
||||||
/// Privileged read-only discovery of visible Workspace Workers. Backend
|
|
||||||
/// source proof remains required for every listing operation.
|
|
||||||
#[serde(default)]
|
|
||||||
pub workspace_worker_discovery: FeatureFlagConfig,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub objective: FeatureFlagConfig,
|
pub objective: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -132,24 +128,26 @@ pub struct FeatureConfig {
|
|||||||
pub merge_request: MergeRequestFeatureConfig,
|
pub merge_request: MergeRequestFeatureConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub orchestration: FeatureFlagConfig,
|
pub orchestration: FeatureFlagConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub plugins: FeatureFlagConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for FeatureConfig {
|
impl Default for FeatureConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
task: FeatureFlagConfig::disabled(),
|
task: FeatureFlagConfig::disabled(),
|
||||||
memory: ResolvedMemoryFeatureConfig::default(),
|
memory: MemoryFeatureConfig::disabled(),
|
||||||
web: FeatureFlagConfig::disabled(),
|
web: FeatureFlagConfig::disabled(),
|
||||||
image: FeatureFlagConfig::disabled(),
|
image: FeatureFlagConfig::disabled(),
|
||||||
sub_worker: FeatureFlagConfig::disabled(),
|
sub_worker: FeatureFlagConfig::disabled(),
|
||||||
flow: FeatureFlagConfig::disabled(),
|
flow: FeatureFlagConfig::disabled(),
|
||||||
worker: WorkerFeatureConfig::disabled(),
|
worker: WorkerFeatureConfig::disabled(),
|
||||||
workspace_worker_discovery: FeatureFlagConfig::disabled(),
|
|
||||||
objective: FeatureFlagConfig::disabled(),
|
objective: FeatureFlagConfig::disabled(),
|
||||||
manage_workdir: FeatureFlagConfig::disabled(),
|
manage_workdir: FeatureFlagConfig::disabled(),
|
||||||
ticket: TicketFeatureConfig::default(),
|
ticket: TicketFeatureConfig::default(),
|
||||||
merge_request: MergeRequestFeatureConfig::default(),
|
merge_request: MergeRequestFeatureConfig::default(),
|
||||||
orchestration: FeatureFlagConfig::disabled(),
|
orchestration: FeatureFlagConfig::disabled(),
|
||||||
|
plugins: FeatureFlagConfig::disabled(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,139 +210,34 @@ const fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(default, deny_unknown_fields)]
|
pub struct MemoryFeatureConfig {
|
||||||
pub struct MemoryFeatureProfileConfig {
|
#[serde(default)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Exposes Memory staging queue tools in addition to normal Memory CRUD/query tools.
|
/// Exposes Memory staging queue tools in addition to normal Memory CRUD/query tools.
|
||||||
pub staging_tools: bool,
|
#[serde(default)]
|
||||||
pub resident: MemoryResidentProfileConfig,
|
pub staging: bool,
|
||||||
pub extraction: MemoryExtractionProfileConfig,
|
|
||||||
pub consolidation: MemoryConsolidationProfileConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MemoryFeatureProfileConfig {
|
impl MemoryFeatureConfig {
|
||||||
pub fn disabled() -> Self {
|
pub const fn disabled() -> Self {
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enabled() -> Self {
|
|
||||||
Self {
|
|
||||||
enabled: true,
|
|
||||||
..Self::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MemoryFeatureProfileConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
staging_tools: false,
|
staging: false,
|
||||||
resident: MemoryResidentProfileConfig::default(),
|
|
||||||
extraction: MemoryExtractionProfileConfig::default(),
|
|
||||||
consolidation: MemoryConsolidationProfileConfig::default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
pub const fn enabled() -> Self {
|
||||||
#[serde(default, deny_unknown_fields)]
|
|
||||||
pub struct MemoryResidentProfileConfig {
|
|
||||||
pub inject_summary: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MemoryResidentProfileConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
inject_summary: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[serde(default, deny_unknown_fields)]
|
|
||||||
pub struct MemoryExtractionProfileConfig {
|
|
||||||
pub enabled: bool,
|
|
||||||
pub model: Option<ModelManifest>,
|
|
||||||
pub threshold: Option<u64>,
|
|
||||||
pub worker_max_turns: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MemoryExtractionProfileConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
model: None,
|
staging: false,
|
||||||
threshold: Some(50_000),
|
|
||||||
worker_max_turns: defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
impl Default for MemoryFeatureConfig {
|
||||||
#[serde(default, deny_unknown_fields)]
|
|
||||||
pub struct MemoryConsolidationProfileConfig {
|
|
||||||
pub request_enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for MemoryConsolidationProfileConfig {
|
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self::disabled()
|
||||||
request_enabled: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Immutable Memory execution configuration persisted in a resolved Worker Manifest.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
|
||||||
#[serde(default, deny_unknown_fields)]
|
|
||||||
pub struct ResolvedMemoryFeatureConfig {
|
|
||||||
pub profile: MemoryFeatureProfileConfig,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub workspace_settings: Option<WorkspaceMemorySettingsSnapshot>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ResolvedMemoryFeatureConfig {
|
|
||||||
pub fn enabled(&self) -> bool {
|
|
||||||
self.profile.enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bind_workspace_settings(
|
|
||||||
&mut self,
|
|
||||||
settings: WorkspaceMemorySettingsSnapshot,
|
|
||||||
) -> Result<(), &'static str> {
|
|
||||||
if !self.profile.enabled {
|
|
||||||
if self.workspace_settings.is_some() {
|
|
||||||
return Err("disabled Memory feature must not carry Workspace settings");
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if self.workspace_settings.is_some() {
|
|
||||||
return Err("memory Workspace settings are already bound");
|
|
||||||
}
|
|
||||||
self.workspace_settings = Some(settings);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn workspace_settings(&self) -> Option<WorkspaceMemorySettingsSnapshot> {
|
|
||||||
self.workspace_settings.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate_execution(&self) -> Result<(), &'static str> {
|
|
||||||
if self.profile.enabled && self.workspace_settings.is_none() {
|
|
||||||
return Err("enabled Memory feature requires trusted Workspace settings");
|
|
||||||
}
|
|
||||||
if !self.profile.enabled && self.workspace_settings.is_some() {
|
|
||||||
return Err("disabled Memory feature must not carry Workspace settings");
|
|
||||||
}
|
|
||||||
if let Some(settings) = &self.workspace_settings
|
|
||||||
&& (settings.settings_revision == 0
|
|
||||||
|| !is_normalized_workspace_memory_language(&settings.language))
|
|
||||||
{
|
|
||||||
return Err("Memory Workspace settings snapshot metadata is invalid");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,6 +472,98 @@ pub struct WorkspaceMemorySettingsSnapshot {
|
|||||||
pub language: String,
|
pub language: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Memory subsystem configuration. Presence in the manifest enables
|
||||||
|
/// memory; `workspace_root` pins the memory workspace explicitly. When it
|
||||||
|
/// is absent, memory resolution searches upward from the Worker's pwd for a
|
||||||
|
/// `.yoi/memory` marker rather than treating `.yoi` project records alone
|
||||||
|
/// as a memory root.
|
||||||
|
///
|
||||||
|
/// All fields are `Option`; defaults are applied at the consumer
|
||||||
|
/// (`.unwrap_or(defaults::...)`). This keeps cascade `merge` simple
|
||||||
|
/// (`upper.x.or(self.x)`) without a separate partial/resolved split.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct MemoryConfig {
|
||||||
|
/// Override for the memory workspace root. When `None`, consumers resolve
|
||||||
|
/// the root from their default path and ancestor `.yoi/memory` markers.
|
||||||
|
/// When set, must be an absolute path.
|
||||||
|
#[serde(default)]
|
||||||
|
pub workspace_root: Option<PathBuf>,
|
||||||
|
/// Maximum number of records returned by `MemoryQuery` /
|
||||||
|
/// `MemoryQuery` per call. `None` ⇒ tool default (20).
|
||||||
|
#[serde(default)]
|
||||||
|
pub query_result_limit: Option<usize>,
|
||||||
|
/// Lines of context before and after each match in query excerpts.
|
||||||
|
/// Ignored when the request omits `query`. `None` ⇒ tool default (3).
|
||||||
|
#[serde(default)]
|
||||||
|
pub query_excerpt_lines: Option<usize>,
|
||||||
|
/// Whether the body of `memory/summary.md` is exposed in the resident
|
||||||
|
/// system-prompt section. `None` ⇒ enabled.
|
||||||
|
#[serde(default)]
|
||||||
|
pub inject_summary: Option<bool>,
|
||||||
|
/// Workspace that owns the bound Memory settings revision.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub workspace_id: Option<String>,
|
||||||
|
/// Monotonic revision of the bound Workspace Memory settings.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub settings_revision: Option<u64>,
|
||||||
|
/// Language from the bound Workspace Memory settings revision.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub language: Option<String>,
|
||||||
|
/// Optional model for the extract worker. When `None`,
|
||||||
|
/// the main engine model is cloned via `clone_boxed()`. Lightweight
|
||||||
|
/// reasoning-capable models (Haiku / 4o-mini / Flash class) are
|
||||||
|
/// recommended.
|
||||||
|
#[serde(default)]
|
||||||
|
pub extract_model: Option<ModelManifest>,
|
||||||
|
/// Cumulative input-token threshold (since the last extract pointer)
|
||||||
|
/// that triggers an extract run. `None` disables the extract trigger
|
||||||
|
/// entirely; memory tools and resident injection still work, only
|
||||||
|
/// the auto-extract trigger is dormant.
|
||||||
|
#[serde(default)]
|
||||||
|
pub extract_threshold: Option<u64>,
|
||||||
|
/// Optional maximum extract-worker tool-loop depth. `None` leaves
|
||||||
|
/// the worker unlimited; the default bounds runaway short-context
|
||||||
|
/// loops. Falls through to
|
||||||
|
/// [`defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS`] when unset.
|
||||||
|
#[serde(default)]
|
||||||
|
pub extract_worker_max_turns: Option<u32>,
|
||||||
|
/// Optional model for the consolidation worker. When
|
||||||
|
/// `None`, the main engine model is cloned via `clone_boxed()`.
|
||||||
|
/// Reasoning-class models are recommended.
|
||||||
|
#[serde(default)]
|
||||||
|
pub consolidation_model: Option<ModelManifest>,
|
||||||
|
/// Consolidation trigger: file-count threshold of `_staging/`. The
|
||||||
|
/// consolidation run fires when the staging directory has at least
|
||||||
|
/// this many entries. Either threshold reaching its limit fires
|
||||||
|
/// consolidation (logical OR). `None` for both thresholds ⇒
|
||||||
|
/// consolidation disabled.
|
||||||
|
#[serde(default)]
|
||||||
|
pub consolidation_threshold_files: Option<usize>,
|
||||||
|
/// Consolidation trigger: byte-size threshold across all `_staging/`
|
||||||
|
/// entries. Either threshold reaching its limit fires consolidation.
|
||||||
|
/// `None` for both thresholds ⇒ consolidation disabled.
|
||||||
|
#[serde(default)]
|
||||||
|
pub consolidation_threshold_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryConfig {
|
||||||
|
/// Replace any untrusted manifest values with a trusted Workspace snapshot.
|
||||||
|
pub fn bind_workspace_settings(&mut self, snapshot: &WorkspaceMemorySettingsSnapshot) {
|
||||||
|
self.workspace_id = Some(snapshot.workspace_id.clone());
|
||||||
|
self.settings_revision = Some(snapshot.settings_revision);
|
||||||
|
self.language = Some(snapshot.language.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the complete bound Workspace settings snapshot, if every field is present.
|
||||||
|
pub fn workspace_settings(&self) -> Option<WorkspaceMemorySettingsSnapshot> {
|
||||||
|
Some(WorkspaceMemorySettingsSnapshot {
|
||||||
|
workspace_id: self.workspace_id.clone()?,
|
||||||
|
settings_revision: self.settings_revision?,
|
||||||
|
language: self.language.clone()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Worker metadata.
|
/// Worker metadata.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct WorkerMeta {
|
pub struct WorkerMeta {
|
||||||
@@ -934,10 +919,6 @@ impl Default for CompactionConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerManifest {
|
impl WorkerManifest {
|
||||||
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
|
||||||
self.profile.is_some() || self.feature.memory.workspace_settings.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a manifest from a TOML string.
|
/// Parse a manifest from a TOML string.
|
||||||
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
|
pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
|
||||||
config::reject_removed_manifest_fields(s)?;
|
config::reject_removed_manifest_fields(s)?;
|
||||||
@@ -948,267 +929,6 @@ impl WorkerManifest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
#[serde(default, deny_unknown_fields)]
|
|
||||||
struct LegacyMemoryFeatureConfig {
|
|
||||||
enabled: bool,
|
|
||||||
staging: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
|
||||||
#[serde(default, deny_unknown_fields)]
|
|
||||||
struct LegacyMemoryConfig {
|
|
||||||
#[serde(rename = "workspace_root")]
|
|
||||||
_workspace_root: Option<PathBuf>,
|
|
||||||
#[serde(rename = "query_result_limit")]
|
|
||||||
_query_result_limit: Option<usize>,
|
|
||||||
#[serde(rename = "query_excerpt_lines")]
|
|
||||||
_query_excerpt_lines: Option<usize>,
|
|
||||||
inject_summary: Option<bool>,
|
|
||||||
workspace_id: Option<String>,
|
|
||||||
settings_revision: Option<u64>,
|
|
||||||
language: Option<String>,
|
|
||||||
extract_model: Option<ModelManifest>,
|
|
||||||
extract_threshold: Option<u64>,
|
|
||||||
extract_worker_max_turns: Option<u32>,
|
|
||||||
consolidation_model: Option<ModelManifest>,
|
|
||||||
consolidation_threshold_files: Option<usize>,
|
|
||||||
consolidation_threshold_bytes: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 3;
|
|
||||||
const PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 2;
|
|
||||||
|
|
||||||
/// Serialize a resolved Worker Manifest for durable Worker-specific storage.
|
|
||||||
pub fn write_persisted_worker_manifest_snapshot(
|
|
||||||
manifest: &WorkerManifest,
|
|
||||||
) -> Result<serde_json::Value, serde_json::Error> {
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"schema_version": RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION,
|
|
||||||
"manifest": serde_json::to_value(manifest)?,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read a durable resolved Worker Manifest through the versioned compatibility
|
|
||||||
/// boundary. Runtime code must not deserialize persisted snapshots directly.
|
|
||||||
pub fn read_persisted_worker_manifest_snapshot(
|
|
||||||
snapshot: serde_json::Value,
|
|
||||||
) -> Result<WorkerManifest, serde_json::Error> {
|
|
||||||
let object = snapshot.as_object().ok_or_else(|| {
|
|
||||||
serde_json::Error::io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"resolved Worker manifest snapshot must be an object",
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
if let Some(version) = object.get("schema_version") {
|
|
||||||
let version = version.as_u64().ok_or_else(|| {
|
|
||||||
serde_json::Error::io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"resolved Worker manifest snapshot schema_version must be an integer",
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
|
|
||||||
&& version != PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
|
|
||||||
{
|
|
||||||
return Err(serde_json::Error::io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
format!("unsupported resolved Worker manifest snapshot schema version {version}"),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if object.len() != 2 {
|
|
||||||
return Err(serde_json::Error::io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"resolved Worker manifest snapshot contains unknown fields",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let mut manifest = object.get("manifest").cloned().ok_or_else(|| {
|
|
||||||
serde_json::Error::io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"resolved Worker manifest snapshot is missing manifest",
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
if manifest
|
|
||||||
.as_object()
|
|
||||||
.is_some_and(|manifest| manifest.contains_key("memory"))
|
|
||||||
{
|
|
||||||
return Err(serde_json::Error::io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"current resolved Worker manifest contains removed top-level memory authority",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if version == PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
|
|
||||||
migrate_legacy_manifest_authority(&mut manifest)?;
|
|
||||||
}
|
|
||||||
return validate_persisted_worker_manifest(serde_json::from_value(manifest)?);
|
|
||||||
}
|
|
||||||
|
|
||||||
migrate_legacy_resolved_manifest_snapshot(snapshot)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_persisted_worker_manifest(
|
|
||||||
manifest: WorkerManifest,
|
|
||||||
) -> Result<WorkerManifest, serde_json::Error> {
|
|
||||||
manifest
|
|
||||||
.feature
|
|
||||||
.memory
|
|
||||||
.validate_execution()
|
|
||||||
.map_err(|message| {
|
|
||||||
serde_json::Error::io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
message,
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
Ok(manifest)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn migrate_legacy_manifest_authority(
|
|
||||||
manifest: &mut serde_json::Value,
|
|
||||||
) -> Result<(), serde_json::Error> {
|
|
||||||
let root = manifest.as_object_mut().ok_or_else(|| {
|
|
||||||
serde_json::Error::io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"resolved Worker manifest must be an object",
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
root.remove("plugins");
|
|
||||||
if let Some(feature) = root.get_mut("feature") {
|
|
||||||
let feature = feature.as_object_mut().ok_or_else(|| {
|
|
||||||
serde_json::Error::io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"resolved Worker manifest feature must be an object",
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
feature.remove("plugins");
|
|
||||||
feature.remove("ticket_orchestration");
|
|
||||||
if let Some(workers) = feature.remove("workers") {
|
|
||||||
feature
|
|
||||||
.entry("sub_worker".to_string())
|
|
||||||
.or_insert_with(|| workers.clone());
|
|
||||||
feature.entry("worker".to_string()).or_insert(workers);
|
|
||||||
}
|
|
||||||
if let Some(ticket) = feature
|
|
||||||
.get_mut("ticket")
|
|
||||||
.and_then(serde_json::Value::as_object_mut)
|
|
||||||
&& let Some(access) = ticket.remove("access")
|
|
||||||
&& ticket
|
|
||||||
.get("enabled")
|
|
||||||
.and_then(serde_json::Value::as_bool)
|
|
||||||
.unwrap_or(false)
|
|
||||||
&& access.as_str() == Some("lifecycle")
|
|
||||||
{
|
|
||||||
ticket.insert("authoring".to_string(), serde_json::Value::Bool(true));
|
|
||||||
ticket.insert("thread".to_string(), serde_json::Value::Bool(true));
|
|
||||||
ticket.insert("workflow".to_string(), serde_json::Value::Bool(true));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn migrate_legacy_resolved_manifest_snapshot(
|
|
||||||
mut snapshot: serde_json::Value,
|
|
||||||
) -> Result<WorkerManifest, serde_json::Error> {
|
|
||||||
let root = snapshot.as_object_mut().ok_or_else(|| {
|
|
||||||
serde_json::Error::io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"legacy resolved Worker manifest snapshot must be an object",
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let legacy_memory = root.remove("memory");
|
|
||||||
let feature = root
|
|
||||||
.entry("feature")
|
|
||||||
.or_insert_with(|| serde_json::json!({}))
|
|
||||||
.as_object_mut()
|
|
||||||
.ok_or_else(|| {
|
|
||||||
serde_json::Error::io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"legacy resolved Worker manifest feature must be an object",
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let legacy_feature_memory: LegacyMemoryFeatureConfig = serde_json::from_value(
|
|
||||||
feature
|
|
||||||
.remove("memory")
|
|
||||||
.unwrap_or_else(|| serde_json::json!({})),
|
|
||||||
)?;
|
|
||||||
let requested_enabled = legacy_feature_memory.enabled;
|
|
||||||
let staging_tools = legacy_feature_memory.staging;
|
|
||||||
|
|
||||||
let legacy_memory: LegacyMemoryConfig =
|
|
||||||
serde_json::from_value(legacy_memory.unwrap_or_else(|| serde_json::json!({})))?;
|
|
||||||
let mut workspace_settings = match (
|
|
||||||
legacy_memory.workspace_id,
|
|
||||||
legacy_memory.settings_revision,
|
|
||||||
legacy_memory.language,
|
|
||||||
) {
|
|
||||||
(Some(workspace_id), Some(settings_revision), Some(language)) => Some(serde_json::json!({
|
|
||||||
"workspace_id": workspace_id,
|
|
||||||
"settings_revision": settings_revision,
|
|
||||||
"language": language,
|
|
||||||
})),
|
|
||||||
(None, None, None) => None,
|
|
||||||
_ => {
|
|
||||||
return Err(serde_json::Error::io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"legacy resolved Worker manifest contains a partial Memory settings snapshot",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if !requested_enabled {
|
|
||||||
workspace_settings = None;
|
|
||||||
}
|
|
||||||
// Legacy standalone manifests could enable process-local Memory without a
|
|
||||||
// Workspace-owned settings snapshot. That authority no longer exists, so
|
|
||||||
// migration safely disables Memory instead of treating the whole Worker
|
|
||||||
// snapshot as corrupt.
|
|
||||||
let enabled = requested_enabled && workspace_settings.is_some();
|
|
||||||
let extraction_enabled = legacy_memory.extract_threshold.is_some();
|
|
||||||
if legacy_memory.consolidation_model.is_some() {
|
|
||||||
return Err(serde_json::Error::io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"legacy resolved Worker manifest uses a Worker-owned consolidation model that cannot be migrated to Backend authority",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let consolidation_enabled = match (
|
|
||||||
legacy_memory.consolidation_threshold_files,
|
|
||||||
legacy_memory.consolidation_threshold_bytes,
|
|
||||||
) {
|
|
||||||
(None, None) => false,
|
|
||||||
(Some(5), Some(50_000)) => true,
|
|
||||||
_ => {
|
|
||||||
return Err(serde_json::Error::io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"legacy resolved Worker manifest uses custom consolidation thresholds that cannot be migrated to Backend policy",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let mut resolved = serde_json::json!({
|
|
||||||
"profile": {
|
|
||||||
"enabled": enabled,
|
|
||||||
"staging_tools": staging_tools,
|
|
||||||
"resident": {
|
|
||||||
"inject_summary": legacy_memory.inject_summary.unwrap_or(true),
|
|
||||||
},
|
|
||||||
"extraction": {
|
|
||||||
"enabled": extraction_enabled,
|
|
||||||
"model": serde_json::to_value(legacy_memory.extract_model)?,
|
|
||||||
"threshold": legacy_memory.extract_threshold,
|
|
||||||
"worker_max_turns": legacy_memory.extract_worker_max_turns,
|
|
||||||
},
|
|
||||||
"consolidation": {
|
|
||||||
"request_enabled": consolidation_enabled,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if let Some(workspace_settings) = workspace_settings {
|
|
||||||
resolved
|
|
||||||
.as_object_mut()
|
|
||||||
.expect("resolved Memory config is an object")
|
|
||||||
.insert("workspace_settings".to_string(), workspace_settings);
|
|
||||||
}
|
|
||||||
feature.insert("memory".to_string(), resolved);
|
|
||||||
migrate_legacy_manifest_authority(&mut snapshot)?;
|
|
||||||
validate_persisted_worker_manifest(serde_json::from_value(snapshot)?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1369,61 +1089,33 @@ model_id = "claude-sonnet-4-20250514"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dynamic_plugin_manifest_config_is_rejected() {
|
fn parse_plugin_enablement_config() {
|
||||||
let toml = format!(
|
let toml = format!(
|
||||||
"{MINIMAL_REQUIRED}\n\
|
"{MINIMAL_REQUIRED}\n\
|
||||||
[[plugins.enabled]]\n\
|
[[plugins.enabled]]\n\
|
||||||
id = \"project:example\"\n"
|
id = \"project:example\"\n\
|
||||||
|
version = \"0.1.0\"\n\
|
||||||
|
digest = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n\
|
||||||
|
surfaces = [\"hook\"]\n\n\
|
||||||
|
[plugins.enabled.config]\n\
|
||||||
|
greeting = \"hello\"\n"
|
||||||
);
|
);
|
||||||
let error = WorkerManifest::from_toml(&toml).unwrap_err();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
assert!(
|
assert_eq!(manifest.plugins.enabled.len(), 1);
|
||||||
error
|
let enabled = &manifest.plugins.enabled[0];
|
||||||
.to_string()
|
assert_eq!(enabled.id, "project:example");
|
||||||
.contains("dynamic Plugins are not supported"),
|
assert_eq!(
|
||||||
"unexpected error: {error}"
|
enabled.version.as_ref().map(|version| version.0.as_str()),
|
||||||
|
Some("0.1.0")
|
||||||
);
|
);
|
||||||
}
|
assert_eq!(enabled.surfaces, vec![plugin::PluginSurface::Hook]);
|
||||||
|
assert_eq!(
|
||||||
#[test]
|
enabled
|
||||||
fn persisted_manifest_with_dynamic_plugin_plan_is_rejected() {
|
.config
|
||||||
let base =
|
.as_ref()
|
||||||
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
|
.and_then(|value| value.get("greeting"))
|
||||||
|
.and_then(|value| value.as_str()),
|
||||||
let mut top_level = base.clone();
|
Some("hello")
|
||||||
top_level.as_object_mut().unwrap().insert(
|
|
||||||
"plugins".to_string(),
|
|
||||||
serde_json::json!({
|
|
||||||
"resolved": [{
|
|
||||||
"package_path": "/tmp/ambient.yoi-plugin"
|
|
||||||
}]
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err();
|
|
||||||
assert!(error.to_string().contains("unknown field `plugins`"));
|
|
||||||
|
|
||||||
let mut nested = base;
|
|
||||||
nested
|
|
||||||
.get_mut("feature")
|
|
||||||
.unwrap()
|
|
||||||
.as_object_mut()
|
|
||||||
.unwrap()
|
|
||||||
.insert(
|
|
||||||
"plugins".to_string(),
|
|
||||||
serde_json::json!({ "enabled": true }),
|
|
||||||
);
|
|
||||||
let error = serde_json::from_value::<WorkerManifest>(nested).unwrap_err();
|
|
||||||
assert!(error.to_string().contains("unknown field `plugins`"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dynamic_plugin_feature_flag_is_rejected() {
|
|
||||||
let toml = format!("{MINIMAL_REQUIRED}\n[feature.plugins]\nenabled = true\n");
|
|
||||||
let error = WorkerManifest::from_toml(&toml).unwrap_err();
|
|
||||||
assert!(
|
|
||||||
error
|
|
||||||
.to_string()
|
|
||||||
.contains("dynamic Plugins are not supported"),
|
|
||||||
"unexpected error: {error}"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1542,237 +1234,36 @@ model_id = "claude-sonnet-4-20250514"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn omitted_memory_feature_is_disabled() {
|
fn omitted_memory_is_none() {
|
||||||
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||||
assert!(!manifest.feature.memory.profile.enabled);
|
assert!(manifest.memory.is_none());
|
||||||
assert!(manifest.feature.memory.workspace_settings.is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolved_memory_feature_requires_nested_profile_and_trusted_snapshot() {
|
fn empty_memory_section_enables_with_default_root() {
|
||||||
let toml = format!(
|
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\n");
|
||||||
"{MINIMAL_REQUIRED}\n\
|
|
||||||
[feature.memory.profile]\n\
|
|
||||||
enabled = true\n\
|
|
||||||
staging_tools = false\n\n\
|
|
||||||
[feature.memory.profile.resident]\n\
|
|
||||||
inject_summary = false\n\n\
|
|
||||||
[feature.memory.profile.extraction]\n\
|
|
||||||
enabled = true\n\
|
|
||||||
threshold = 42000\n\
|
|
||||||
worker_max_turns = 2\n\n\
|
|
||||||
[feature.memory.workspace_settings]\n\
|
|
||||||
workspace_id = \"workspace-1\"\n\
|
|
||||||
settings_revision = 7\n\
|
|
||||||
language = \"日本語\"\n"
|
|
||||||
);
|
|
||||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
assert!(manifest.feature.memory.profile.enabled);
|
let mem = manifest.memory.expect("memory section parsed");
|
||||||
assert!(!manifest.feature.memory.profile.resident.inject_summary);
|
assert!(mem.workspace_root.is_none());
|
||||||
assert_eq!(
|
assert_eq!(mem.inject_summary, None);
|
||||||
manifest.feature.memory.profile.extraction.threshold,
|
|
||||||
Some(42_000)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
manifest
|
|
||||||
.feature
|
|
||||||
.memory
|
|
||||||
.workspace_settings()
|
|
||||||
.unwrap()
|
|
||||||
.language,
|
|
||||||
"日本語"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolved_memory_execution_validation_fails_closed() {
|
fn memory_section_with_inject_summary_false() {
|
||||||
let snapshot = WorkspaceMemorySettingsSnapshot {
|
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\ninject_summary = false\n");
|
||||||
workspace_id: "workspace-1".to_string(),
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
settings_revision: 1,
|
let mem = manifest.memory.unwrap();
|
||||||
language: "English".to_string(),
|
assert_eq!(mem.inject_summary, Some(false));
|
||||||
};
|
|
||||||
let mut enabled = ResolvedMemoryFeatureConfig::default();
|
|
||||||
enabled.profile.enabled = true;
|
|
||||||
assert!(enabled.validate_execution().is_err());
|
|
||||||
enabled.bind_workspace_settings(snapshot.clone()).unwrap();
|
|
||||||
assert!(enabled.validate_execution().is_ok());
|
|
||||||
|
|
||||||
let mut disabled = ResolvedMemoryFeatureConfig::default();
|
|
||||||
disabled.workspace_settings = Some(snapshot.clone());
|
|
||||||
assert!(disabled.validate_execution().is_err());
|
|
||||||
assert!(disabled.bind_workspace_settings(snapshot).is_err());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn current_manifest_rejects_legacy_top_level_memory_authority() {
|
fn memory_section_with_explicit_root() {
|
||||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nworkspace_root = \"/some/where\"\n");
|
||||||
assert!(WorkerManifest::from_toml(&toml).is_err());
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
}
|
let mem = manifest.memory.unwrap();
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persisted_manifest_adapter_migrates_legacy_memory_authority() {
|
|
||||||
let mut manifest =
|
|
||||||
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
|
|
||||||
manifest["feature"]["memory"] = serde_json::json!({
|
|
||||||
"enabled": true,
|
|
||||||
"staging": true,
|
|
||||||
});
|
|
||||||
manifest["memory"] = serde_json::json!({
|
|
||||||
"workspace_root": "/discarded",
|
|
||||||
"query_result_limit": 999,
|
|
||||||
"inject_summary": false,
|
|
||||||
"workspace_id": "workspace-1",
|
|
||||||
"settings_revision": 9,
|
|
||||||
"language": "Français",
|
|
||||||
"extract_threshold": 1234,
|
|
||||||
"extract_worker_max_turns": 3,
|
|
||||||
"consolidation_threshold_files": 5,
|
|
||||||
"consolidation_threshold_bytes": 50000,
|
|
||||||
});
|
|
||||||
|
|
||||||
let migrated = read_persisted_worker_manifest_snapshot(manifest).unwrap();
|
|
||||||
assert!(migrated.feature.memory.profile.enabled);
|
|
||||||
assert!(migrated.feature.memory.profile.staging_tools);
|
|
||||||
assert!(!migrated.feature.memory.profile.resident.inject_summary);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
migrated.feature.memory.profile.extraction.threshold,
|
mem.workspace_root.unwrap(),
|
||||||
Some(1234)
|
std::path::PathBuf::from("/some/where")
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
migrated
|
|
||||||
.feature
|
|
||||||
.memory
|
|
||||||
.profile
|
|
||||||
.consolidation
|
|
||||||
.request_enabled
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
migrated
|
|
||||||
.feature
|
|
||||||
.memory
|
|
||||||
.workspace_settings()
|
|
||||||
.unwrap()
|
|
||||||
.language,
|
|
||||||
"Français"
|
|
||||||
);
|
|
||||||
let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap();
|
|
||||||
assert_eq!(current["schema_version"], 3);
|
|
||||||
assert!(current["manifest"].get("memory").is_none());
|
|
||||||
|
|
||||||
let mut disabled =
|
|
||||||
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
|
|
||||||
disabled["feature"]["memory"] = serde_json::json!({ "enabled": false });
|
|
||||||
disabled["memory"] = serde_json::json!({
|
|
||||||
"workspace_id": "workspace-1",
|
|
||||||
"settings_revision": 9,
|
|
||||||
"language": "Français",
|
|
||||||
});
|
|
||||||
let disabled = read_persisted_worker_manifest_snapshot(disabled).unwrap();
|
|
||||||
assert!(!disabled.feature.memory.profile.enabled);
|
|
||||||
assert!(disabled.feature.memory.workspace_settings.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persisted_manifest_adapter_drops_removed_plugin_authority() {
|
|
||||||
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
|
||||||
let mut versioned = write_persisted_worker_manifest_snapshot(&manifest).unwrap();
|
|
||||||
versioned["schema_version"] = serde_json::json!(2);
|
|
||||||
versioned["manifest"]["feature"]["plugins"] = serde_json::json!({ "enabled": true });
|
|
||||||
versioned["manifest"]["feature"]
|
|
||||||
.as_object_mut()
|
|
||||||
.unwrap()
|
|
||||||
.remove("sub_worker");
|
|
||||||
versioned["manifest"]["feature"]
|
|
||||||
.as_object_mut()
|
|
||||||
.unwrap()
|
|
||||||
.remove("worker");
|
|
||||||
versioned["manifest"]["feature"]["workers"] = serde_json::json!({ "enabled": true });
|
|
||||||
versioned["manifest"]["feature"]["ticket"] =
|
|
||||||
serde_json::json!({ "enabled": true, "access": "lifecycle" });
|
|
||||||
versioned["manifest"]["feature"]["ticket_orchestration"] =
|
|
||||||
serde_json::json!({ "enabled": false });
|
|
||||||
versioned["manifest"]["plugins"] = serde_json::json!({
|
|
||||||
"enabled": ["legacy-plugin"],
|
|
||||||
"config": { "legacy-plugin": { "legacy": true } }
|
|
||||||
});
|
|
||||||
|
|
||||||
let restored = read_persisted_worker_manifest_snapshot(versioned).unwrap();
|
|
||||||
let current = write_persisted_worker_manifest_snapshot(&restored).unwrap();
|
|
||||||
assert_eq!(current["schema_version"], 3);
|
|
||||||
assert!(current["manifest"].get("plugins").is_none());
|
|
||||||
assert!(current["manifest"]["feature"].get("plugins").is_none());
|
|
||||||
assert!(current["manifest"]["feature"].get("workers").is_none());
|
|
||||||
assert_eq!(
|
|
||||||
current["manifest"]["feature"]["sub_worker"]["enabled"],
|
|
||||||
true
|
|
||||||
);
|
|
||||||
assert_eq!(current["manifest"]["feature"]["worker"]["enabled"], true);
|
|
||||||
assert_eq!(current["manifest"]["feature"]["ticket"]["authoring"], true);
|
|
||||||
assert_eq!(current["manifest"]["feature"]["ticket"]["thread"], true);
|
|
||||||
assert_eq!(current["manifest"]["feature"]["ticket"]["workflow"], true);
|
|
||||||
|
|
||||||
let mut legacy = serde_json::to_value(manifest).unwrap();
|
|
||||||
legacy.as_object_mut().unwrap().remove("memory");
|
|
||||||
legacy["feature"]["memory"] = serde_json::json!({
|
|
||||||
"enabled": true,
|
|
||||||
"staging": false
|
|
||||||
});
|
|
||||||
legacy["feature"]["plugins"] = serde_json::json!({ "enabled": false });
|
|
||||||
legacy["plugins"] = serde_json::json!({ "enabled": [] });
|
|
||||||
let legacy = read_persisted_worker_manifest_snapshot(legacy).unwrap();
|
|
||||||
let current = write_persisted_worker_manifest_snapshot(&legacy).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
current["manifest"]["feature"]["memory"]["profile"]["enabled"],
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn persisted_manifest_adapter_rejects_mixed_or_future_authority() {
|
|
||||||
let manifest =
|
|
||||||
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
|
|
||||||
let mut mixed = manifest.clone();
|
|
||||||
mixed["feature"]["memory"] = serde_json::json!({ "enabled": true, "profile": {} });
|
|
||||||
mixed["memory"] = serde_json::json!({});
|
|
||||||
assert!(read_persisted_worker_manifest_snapshot(mixed).is_err());
|
|
||||||
|
|
||||||
let mut custom_policy =
|
|
||||||
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
|
|
||||||
custom_policy["feature"]["memory"] = serde_json::json!({ "enabled": true });
|
|
||||||
custom_policy["memory"] = serde_json::json!({
|
|
||||||
"workspace_id": "workspace-1",
|
|
||||||
"settings_revision": 1,
|
|
||||||
"language": "English",
|
|
||||||
"consolidation_threshold_files": 99,
|
|
||||||
"consolidation_threshold_bytes": 50000,
|
|
||||||
});
|
|
||||||
assert!(read_persisted_worker_manifest_snapshot(custom_policy).is_err());
|
|
||||||
|
|
||||||
let current = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
|
||||||
let mut current = write_persisted_worker_manifest_snapshot(¤t).unwrap();
|
|
||||||
current["manifest"]["memory"] = serde_json::json!({
|
|
||||||
"workspace_id": "workspace-1",
|
|
||||||
"settings_revision": 1,
|
|
||||||
"language": "English",
|
|
||||||
});
|
|
||||||
assert!(read_persisted_worker_manifest_snapshot(current).is_err());
|
|
||||||
|
|
||||||
let mut missing_settings = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
|
||||||
missing_settings.feature.memory.profile.enabled = true;
|
|
||||||
let missing_settings = write_persisted_worker_manifest_snapshot(&missing_settings).unwrap();
|
|
||||||
assert!(read_persisted_worker_manifest_snapshot(missing_settings).is_err());
|
|
||||||
|
|
||||||
let mut malformed_legacy = manifest.clone();
|
|
||||||
malformed_legacy["feature"]["memory"] = serde_json::json!({ "enabled": "yes" });
|
|
||||||
malformed_legacy["memory"] = serde_json::json!({ "unknown": true });
|
|
||||||
assert!(read_persisted_worker_manifest_snapshot(malformed_legacy).is_err());
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
read_persisted_worker_manifest_snapshot(serde_json::json!({
|
|
||||||
"schema_version": 4,
|
|
||||||
"manifest": manifest,
|
|
||||||
}))
|
|
||||||
.is_err()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1788,6 +1279,14 @@ model_id = "claude-sonnet-4-20250514"
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn memory_section_with_language() {
|
||||||
|
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
||||||
|
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
||||||
|
let mem = manifest.memory.unwrap();
|
||||||
|
assert_eq!(mem.language.as_deref(), Some("Japanese"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reject_unknown_scheme() {
|
fn reject_unknown_scheme() {
|
||||||
let toml =
|
let toml =
|
||||||
|
|||||||
+1874
-100
File diff suppressed because it is too large
Load Diff
+320
-343
@@ -6,27 +6,62 @@
|
|||||||
//! from launch context.
|
//! from launch context.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::BTreeMap;
|
||||||
use std::fmt;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::builtin_profile::{
|
|
||||||
BUILTIN_DEFAULT_PROFILE, builtin_profile_catalog_snapshot, builtin_profile_entrypoints,
|
|
||||||
resolve_builtin_profile_artifact,
|
|
||||||
};
|
|
||||||
use crate::config::{
|
use crate::config::{
|
||||||
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
|
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
|
||||||
};
|
};
|
||||||
use crate::model::{AuthRef, ModelManifest};
|
use crate::model::{AuthRef, ModelManifest};
|
||||||
|
use crate::plugin::PluginConfig;
|
||||||
use crate::{
|
use crate::{
|
||||||
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig,
|
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, MemoryConfig, Permission, ResolveError,
|
||||||
ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig,
|
ScopeConfig, ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig,
|
||||||
paths,
|
WorkerMetaConfig, paths,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
|
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
|
||||||
const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml");
|
const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml");
|
||||||
|
|
||||||
|
struct BuiltinProfile {
|
||||||
|
name: &'static str,
|
||||||
|
label: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUILTIN_PROFILES: &[BuiltinProfile] = &[
|
||||||
|
BuiltinProfile {
|
||||||
|
name: "companion",
|
||||||
|
label: "builtin:companion",
|
||||||
|
description: "Bundled Companion role profile",
|
||||||
|
},
|
||||||
|
BuiltinProfile {
|
||||||
|
name: "intake",
|
||||||
|
label: "builtin:intake",
|
||||||
|
description: "Bundled Intake role profile",
|
||||||
|
},
|
||||||
|
BuiltinProfile {
|
||||||
|
name: "orchestrator",
|
||||||
|
label: "builtin:orchestrator",
|
||||||
|
description: "Bundled Orchestrator role profile",
|
||||||
|
},
|
||||||
|
BuiltinProfile {
|
||||||
|
name: "coder",
|
||||||
|
label: "builtin:coder",
|
||||||
|
description: "Bundled Coder role profile",
|
||||||
|
},
|
||||||
|
BuiltinProfile {
|
||||||
|
name: "reviewer",
|
||||||
|
label: "builtin:reviewer",
|
||||||
|
description: "Bundled Reviewer role profile",
|
||||||
|
},
|
||||||
|
BuiltinProfile {
|
||||||
|
name: "memory-consolidation",
|
||||||
|
label: "builtin:memory-consolidation",
|
||||||
|
description: "Bundled Memory staging consolidation profile",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ProfileRegistrySource {
|
pub enum ProfileRegistrySource {
|
||||||
@@ -124,103 +159,6 @@ impl ProfileSelector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ProfileExecutionTarget {
|
|
||||||
Workspace,
|
|
||||||
Standalone,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for ProfileExecutionTarget {
|
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Workspace => formatter.write_str("workspace"),
|
|
||||||
Self::Standalone => formatter.write_str("standalone"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
||||||
pub enum WorkspaceAuthorityRequirement {
|
|
||||||
Flow,
|
|
||||||
ManageWorkdir,
|
|
||||||
Memory,
|
|
||||||
MergeRequest,
|
|
||||||
Objective,
|
|
||||||
Orchestration,
|
|
||||||
Ticket,
|
|
||||||
Worker,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for WorkspaceAuthorityRequirement {
|
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Flow => formatter.write_str("feature.flow"),
|
|
||||||
Self::ManageWorkdir => formatter.write_str("feature.manage_workdir"),
|
|
||||||
Self::Memory => formatter.write_str("feature.memory"),
|
|
||||||
Self::MergeRequest => formatter.write_str("feature.merge_request"),
|
|
||||||
Self::Objective => formatter.write_str("feature.objective"),
|
|
||||||
Self::Orchestration => formatter.write_str("feature.orchestration"),
|
|
||||||
Self::Ticket => formatter.write_str("feature.ticket"),
|
|
||||||
Self::Worker => formatter.write_str("feature.worker"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validate_profile_execution_target(
|
|
||||||
manifest: &WorkerManifest,
|
|
||||||
target: ProfileExecutionTarget,
|
|
||||||
) -> Result<(), ProfileError> {
|
|
||||||
if target == ProfileExecutionTarget::Workspace {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let feature = &manifest.feature;
|
|
||||||
let mut requirements = BTreeSet::new();
|
|
||||||
if feature.flow.enabled {
|
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::Flow);
|
|
||||||
}
|
|
||||||
if feature.manage_workdir.enabled {
|
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
|
|
||||||
}
|
|
||||||
if feature.memory.profile.enabled || feature.memory.profile.staging_tools {
|
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::Memory);
|
|
||||||
}
|
|
||||||
if feature.merge_request.show
|
|
||||||
|| feature.merge_request.open
|
|
||||||
|| feature.merge_request.review
|
|
||||||
|| feature.merge_request.readiness_check
|
|
||||||
|| feature.merge_request.complete
|
|
||||||
{
|
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::MergeRequest);
|
|
||||||
}
|
|
||||||
if feature.objective.enabled {
|
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::Objective);
|
|
||||||
}
|
|
||||||
if feature.orchestration.enabled {
|
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::Orchestration);
|
|
||||||
}
|
|
||||||
if feature.ticket.enabled
|
|
||||||
|| feature.ticket.authoring
|
|
||||||
|| feature.ticket.thread
|
|
||||||
|| feature.ticket.intake
|
|
||||||
|| feature.ticket.workflow
|
|
||||||
{
|
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::Ticket);
|
|
||||||
}
|
|
||||||
if feature.worker.enabled {
|
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::Worker);
|
|
||||||
}
|
|
||||||
|
|
||||||
if requirements.is_empty() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(ProfileError::UnsupportedExecutionTarget {
|
|
||||||
target,
|
|
||||||
requirements: requirements.into_iter().collect(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum ProfileSource {
|
pub enum ProfileSource {
|
||||||
@@ -279,14 +217,13 @@ impl ProfileRegistryEntry {
|
|||||||
source: ProfileRegistrySource,
|
source: ProfileRegistrySource,
|
||||||
name: &'static str,
|
name: &'static str,
|
||||||
label: &'static str,
|
label: &'static str,
|
||||||
provenance: String,
|
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
source,
|
source,
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
path: None,
|
path: None,
|
||||||
provenance,
|
provenance: label.to_string(),
|
||||||
description,
|
description,
|
||||||
is_default: false,
|
is_default: false,
|
||||||
artifact: ProfileRegistryArtifact::Builtin { label },
|
artifact: ProfileRegistryArtifact::Builtin { label },
|
||||||
@@ -384,16 +321,12 @@ pub struct ProfileDiscovery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ProfileDiscovery {
|
impl ProfileDiscovery {
|
||||||
pub fn user_settings() -> Self {
|
pub fn for_cwd(_cwd: &Path) -> Self {
|
||||||
Self {
|
Self {
|
||||||
user_config: paths::user_profiles_path(),
|
user_config: paths::user_profiles_path(),
|
||||||
project_config: None,
|
project_config: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn for_cwd(_cwd: &Path) -> Self {
|
|
||||||
Self::user_settings()
|
|
||||||
}
|
|
||||||
pub fn with_sources(user_config: Option<PathBuf>, project_config: Option<PathBuf>) -> Self {
|
pub fn with_sources(user_config: Option<PathBuf>, project_config: Option<PathBuf>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
user_config,
|
user_config,
|
||||||
@@ -479,22 +412,15 @@ impl ProfileResolver {
|
|||||||
options,
|
options,
|
||||||
),
|
),
|
||||||
ProfileSelector::Named { .. } | ProfileSelector::Default => {
|
ProfileSelector::Named { .. } | ProfileSelector::Default => {
|
||||||
let registry = ProfileDiscovery::user_settings().discover()?;
|
let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo {
|
||||||
|
path: PathBuf::from("."),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
let registry = ProfileDiscovery::for_cwd(&cwd).discover()?;
|
||||||
self.resolve_from_registry(selector, ®istry, options)
|
self.resolve_from_registry(selector, ®istry, options)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_for_target(
|
|
||||||
&self,
|
|
||||||
selector: &ProfileSelector,
|
|
||||||
options: ProfileResolveOptions,
|
|
||||||
target: ProfileExecutionTarget,
|
|
||||||
) -> Result<ResolvedProfile, ProfileError> {
|
|
||||||
let resolved = self.resolve(selector, options)?;
|
|
||||||
validate_profile_execution_target(&resolved.manifest, target)?;
|
|
||||||
Ok(resolved)
|
|
||||||
}
|
|
||||||
/// Resolve a registry/default selector against an already-discovered
|
/// Resolve a registry/default selector against an already-discovered
|
||||||
/// registry. Callers such as SubWorkerSpawn use this to bind discovery to the
|
/// registry. Callers such as SubWorkerSpawn use this to bind discovery to the
|
||||||
/// Worker's cwd instead of the process current directory.
|
/// Worker's cwd instead of the process current directory.
|
||||||
@@ -577,7 +503,7 @@ impl ProfileResolver {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or_else(|| Path::new(".")),
|
.unwrap_or_else(|| Path::new(".")),
|
||||||
)?;
|
)?;
|
||||||
let raw_artifact = resolve_builtin_profile_artifact(label)?.ok_or_else(|| {
|
let raw_artifact = builtin_profile_artifact(label).ok_or_else(|| {
|
||||||
ProfileError::InvalidProfile(format!("unknown builtin profile artifact `{label}`"))
|
ProfileError::InvalidProfile(format!("unknown builtin profile artifact `{label}`"))
|
||||||
})?;
|
})?;
|
||||||
resolve_profile_value(
|
resolve_profile_value(
|
||||||
@@ -632,13 +558,14 @@ fn resolve_profile_value(
|
|||||||
session: profile.session,
|
session: profile.session,
|
||||||
permissions: profile.permissions,
|
permissions: profile.permissions,
|
||||||
feature: profile.feature,
|
feature: profile.feature,
|
||||||
|
plugins: profile.plugins,
|
||||||
mcp: profile.mcp,
|
mcp: profile.mcp,
|
||||||
compaction,
|
compaction,
|
||||||
web: profile.web,
|
web: profile.web,
|
||||||
|
memory: profile.memory.map(Into::into),
|
||||||
skills: profile.skills,
|
skills: profile.skills,
|
||||||
};
|
};
|
||||||
let config =
|
let config = WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir));
|
||||||
WorkerManifestConfig::resolution_defaults().merge(config.resolve_paths(profile_dir));
|
|
||||||
let mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
|
let mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
|
||||||
manifest.profile = Some(ProfileManifestSnapshot {
|
manifest.profile = Some(ProfileManifestSnapshot {
|
||||||
source: source.clone(),
|
source: source.clone(),
|
||||||
@@ -655,6 +582,51 @@ fn resolve_profile_value(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct ProfileMemoryConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
workspace_root: Option<PathBuf>,
|
||||||
|
#[serde(default)]
|
||||||
|
query_result_limit: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
query_excerpt_lines: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
inject_summary: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
extract_model: Option<ModelManifest>,
|
||||||
|
#[serde(default)]
|
||||||
|
extract_threshold: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
extract_worker_max_turns: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
consolidation_model: Option<ModelManifest>,
|
||||||
|
#[serde(default)]
|
||||||
|
consolidation_threshold_files: Option<usize>,
|
||||||
|
#[serde(default)]
|
||||||
|
consolidation_threshold_bytes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ProfileMemoryConfig> for MemoryConfig {
|
||||||
|
fn from(profile: ProfileMemoryConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
workspace_root: profile.workspace_root,
|
||||||
|
query_result_limit: profile.query_result_limit,
|
||||||
|
query_excerpt_lines: profile.query_excerpt_lines,
|
||||||
|
inject_summary: profile.inject_summary,
|
||||||
|
workspace_id: None,
|
||||||
|
settings_revision: None,
|
||||||
|
language: None,
|
||||||
|
extract_model: profile.extract_model,
|
||||||
|
extract_threshold: profile.extract_threshold,
|
||||||
|
extract_worker_max_turns: profile.extract_worker_max_turns,
|
||||||
|
consolidation_model: profile.consolidation_model,
|
||||||
|
consolidation_threshold_files: profile.consolidation_threshold_files,
|
||||||
|
consolidation_threshold_bytes: profile.consolidation_threshold_bytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
struct ProfileConfig {
|
struct ProfileConfig {
|
||||||
@@ -677,12 +649,16 @@ struct ProfileConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
feature: FeatureConfigPartial,
|
feature: FeatureConfigPartial,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
plugins: PluginConfig,
|
||||||
|
#[serde(default)]
|
||||||
mcp: McpConfig,
|
mcp: McpConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
compaction: Option<serde_json::Value>,
|
compaction: Option<serde_json::Value>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
web: Option<WebConfig>,
|
web: Option<WebConfig>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
memory: Option<ProfileMemoryConfig>,
|
||||||
|
#[serde(default)]
|
||||||
skills: Option<SkillsConfig>,
|
skills: Option<SkillsConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,30 +759,14 @@ fn load_profile_registry_file(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn add_builtin_profiles(registry: &mut ProfileRegistry) {
|
fn add_builtin_profiles(registry: &mut ProfileRegistry) {
|
||||||
let catalog = builtin_profile_catalog_snapshot();
|
for profile in BUILTIN_PROFILES {
|
||||||
let digest = catalog.digest();
|
|
||||||
for profile in builtin_profile_entrypoints() {
|
|
||||||
let label = profile
|
|
||||||
.selector
|
|
||||||
.expect("built-in Profile entrypoint must have a selector");
|
|
||||||
let name = label
|
|
||||||
.strip_prefix("builtin:")
|
|
||||||
.expect("built-in Profile selector must be source-qualified");
|
|
||||||
registry.push_entry(ProfileRegistryEntry::embedded(
|
registry.push_entry(ProfileRegistryEntry::embedded(
|
||||||
ProfileRegistrySource::Builtin,
|
ProfileRegistrySource::Builtin,
|
||||||
name,
|
profile.name,
|
||||||
label,
|
profile.label,
|
||||||
format!("{}#{digest}", profile.path),
|
|
||||||
Some(profile.description.into()),
|
Some(profile.description.into()),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
registry.set_default(ProfileDefault {
|
|
||||||
source: Some(ProfileRegistrySource::Builtin),
|
|
||||||
name: BUILTIN_DEFAULT_PROFILE
|
|
||||||
.strip_prefix("builtin:")
|
|
||||||
.expect("built-in default selector must be source-qualified")
|
|
||||||
.to_owned(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_profile_ref(raw: &str) -> (Option<ProfileRegistrySource>, String) {
|
fn parse_profile_ref(raw: &str) -> (Option<ProfileRegistrySource>, String) {
|
||||||
@@ -844,6 +804,201 @@ fn read_profile_artifact_file(path: &Path) -> Result<serde_json::Value, ProfileE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
|
||||||
|
let mut value = builtin_base_profile_artifact();
|
||||||
|
match label {
|
||||||
|
"builtin:companion" | "companion" => {
|
||||||
|
apply_role_profile(
|
||||||
|
&mut value,
|
||||||
|
"companion",
|
||||||
|
"Workspace companion profile.",
|
||||||
|
"workspace_write",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
"builtin:intake" | "intake" => {
|
||||||
|
apply_role_profile(
|
||||||
|
&mut value,
|
||||||
|
"intake",
|
||||||
|
"Ticket intake profile.",
|
||||||
|
"workspace_write",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
"builtin:orchestrator" | "orchestrator" => {
|
||||||
|
apply_role_profile(
|
||||||
|
&mut value,
|
||||||
|
"orchestrator",
|
||||||
|
"Ticket orchestrator profile.",
|
||||||
|
"workspace_write",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
"builtin:coder" | "coder" => {
|
||||||
|
apply_role_profile(
|
||||||
|
&mut value,
|
||||||
|
"coder",
|
||||||
|
"Ticket implementation coder profile.",
|
||||||
|
"workspace_write",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
"builtin:reviewer" | "reviewer" => {
|
||||||
|
apply_role_profile(
|
||||||
|
&mut value,
|
||||||
|
"reviewer",
|
||||||
|
"Ticket review profile.",
|
||||||
|
"workspace_read",
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
"builtin:memory-consolidation" | "memory-consolidation" => {
|
||||||
|
value["slug"] = serde_json::Value::String("memory-consolidation".to_string());
|
||||||
|
value["description"] =
|
||||||
|
serde_json::Value::String("Memory staging consolidation profile.".to_string());
|
||||||
|
value["feature"]["task"] = serde_json::json!({ "enabled": false });
|
||||||
|
value["feature"]["memory"] = serde_json::json!({ "enabled": true, "staging": true });
|
||||||
|
value["feature"]["web"] = serde_json::json!({ "enabled": false });
|
||||||
|
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": false });
|
||||||
|
value["feature"]["objective"] = serde_json::json!({ "enabled": false });
|
||||||
|
value["feature"]["ticket"] = serde_json::json!({ "enabled": false, "thread": false });
|
||||||
|
Some(value)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn builtin_base_profile_artifact() -> serde_json::Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"slug": "default",
|
||||||
|
"description": "Default Yoi coding profile.",
|
||||||
|
"model": { "ref": "codex-oauth/gpt-5.5" },
|
||||||
|
"session": { "record_event_trace": true },
|
||||||
|
"engine": { "reasoning": "high" },
|
||||||
|
"compaction": {
|
||||||
|
"kind": "tokens",
|
||||||
|
"threshold": 240000,
|
||||||
|
"request_threshold": 270000,
|
||||||
|
"worker_context_max_tokens": 100000
|
||||||
|
},
|
||||||
|
"feature": {
|
||||||
|
"task": { "enabled": true },
|
||||||
|
"memory": { "enabled": true },
|
||||||
|
"web": { "enabled": true },
|
||||||
|
"image": { "enabled": true },
|
||||||
|
"sub_worker": { "enabled": true },
|
||||||
|
"worker": { "enabled": false },
|
||||||
|
"objective": { "enabled": true },
|
||||||
|
"ticket": { "enabled": true, "authoring": true, "thread": true }
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"extract_threshold": 50000,
|
||||||
|
"consolidation_threshold_files": 5,
|
||||||
|
"consolidation_threshold_bytes": 50000
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"enabled": true,
|
||||||
|
"search": {
|
||||||
|
"provider": "brave",
|
||||||
|
"api_key_secret": "web/brave/default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn apply_role_profile(
|
||||||
|
value: &mut serde_json::Value,
|
||||||
|
slug: &str,
|
||||||
|
description: &str,
|
||||||
|
_scope: &str,
|
||||||
|
task: bool,
|
||||||
|
memory: bool,
|
||||||
|
web: bool,
|
||||||
|
sub_worker: bool,
|
||||||
|
) {
|
||||||
|
value["slug"] = serde_json::Value::String(slug.to_string());
|
||||||
|
value["description"] = serde_json::Value::String(description.to_string());
|
||||||
|
value["feature"]["task"] = serde_json::json!({ "enabled": task });
|
||||||
|
value["feature"]["memory"] = serde_json::json!({ "enabled": memory });
|
||||||
|
value["feature"]["web"] = serde_json::json!({ "enabled": web });
|
||||||
|
value["feature"]["image"] = serde_json::json!({ "enabled": true });
|
||||||
|
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
|
||||||
|
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
|
||||||
|
value["feature"]["worker"] = serde_json::json!({
|
||||||
|
"enabled": matches!(slug, "companion" | "orchestrator"),
|
||||||
|
"direct_spawn": slug != "orchestrator"
|
||||||
|
});
|
||||||
|
value["feature"]["manage_workdir"] = serde_json::json!({
|
||||||
|
"enabled": matches!(slug, "companion" | "orchestrator")
|
||||||
|
});
|
||||||
|
value["feature"]["orchestration"] = serde_json::json!({ "enabled": slug == "orchestrator" });
|
||||||
|
let ticket = match slug {
|
||||||
|
"companion" => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
|
||||||
|
"intake" => {
|
||||||
|
serde_json::json!({ "enabled": true, "authoring": true, "thread": true, "intake": true })
|
||||||
|
}
|
||||||
|
"orchestrator" => {
|
||||||
|
serde_json::json!({ "enabled": true, "thread": true, "workflow": true })
|
||||||
|
}
|
||||||
|
"coder" => serde_json::json!({ "enabled": true, "thread": true }),
|
||||||
|
"reviewer" => serde_json::json!({ "enabled": true, "thread": true }),
|
||||||
|
_ => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
|
||||||
|
};
|
||||||
|
value["feature"]["ticket"] = ticket;
|
||||||
|
let merge_request = match slug {
|
||||||
|
"coder" => serde_json::json!({
|
||||||
|
"show": true,
|
||||||
|
"open": true,
|
||||||
|
"review": false,
|
||||||
|
"readiness_check": false,
|
||||||
|
"complete": false
|
||||||
|
}),
|
||||||
|
"reviewer" => serde_json::json!({
|
||||||
|
"show": true,
|
||||||
|
"open": false,
|
||||||
|
"review": true,
|
||||||
|
"readiness_check": false,
|
||||||
|
"complete": false
|
||||||
|
}),
|
||||||
|
"orchestrator" => serde_json::json!({
|
||||||
|
"show": true,
|
||||||
|
"open": false,
|
||||||
|
"review": false,
|
||||||
|
"readiness_check": true,
|
||||||
|
"complete": true
|
||||||
|
}),
|
||||||
|
_ => serde_json::json!({
|
||||||
|
"show": false,
|
||||||
|
"open": false,
|
||||||
|
"review": false,
|
||||||
|
"readiness_check": false,
|
||||||
|
"complete": false
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
value["feature"]["merge_request"] = merge_request;
|
||||||
|
}
|
||||||
|
|
||||||
fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> {
|
fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> {
|
||||||
let Some(map) = value.as_object() else {
|
let Some(map) = value.as_object() else {
|
||||||
return Err(ProfileError::InvalidProfile(
|
return Err(ProfileError::InvalidProfile(
|
||||||
@@ -883,6 +1038,12 @@ fn validate_profile_paths(profile: &ProfileConfig) -> Result<(), ProfileError> {
|
|||||||
.map_err(|source| ProfileError::ProfileDeserialize { source })?;
|
.map_err(|source| ProfileError::ProfileDeserialize { source })?;
|
||||||
reject_absolute_auth_file(&model.auth, "compaction.model.auth.file")?;
|
reject_absolute_auth_file(&model.auth, "compaction.model.auth.file")?;
|
||||||
}
|
}
|
||||||
|
if let Some(memory) = &profile.memory
|
||||||
|
&& let Some(root) = &memory.workspace_root
|
||||||
|
&& root.is_absolute()
|
||||||
|
{
|
||||||
|
return Err(ProfileError::InvalidProfile("field `memory.workspace_root` is a resolved path and is not allowed in reusable Profiles".into()));
|
||||||
|
}
|
||||||
if let Some(skills) = &profile.skills {
|
if let Some(skills) = &profile.skills {
|
||||||
for dir in &skills.directories {
|
for dir in &skills.directories {
|
||||||
if dir.is_absolute() {
|
if dir.is_absolute() {
|
||||||
@@ -961,7 +1122,6 @@ fn profile_scope_intent_to_config(
|
|||||||
target: workspace_base.join(path),
|
target: workspace_base.join(path),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(ScopeConfig {
|
Ok(ScopeConfig {
|
||||||
@@ -969,7 +1129,6 @@ fn profile_scope_intent_to_config(
|
|||||||
target: workspace_base.to_path_buf(),
|
target: workspace_base.to_path_buf(),
|
||||||
permission,
|
permission,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}],
|
}],
|
||||||
deny,
|
deny,
|
||||||
})
|
})
|
||||||
@@ -1129,13 +1288,6 @@ pub enum ProfileError {
|
|||||||
#[source]
|
#[source]
|
||||||
source: toml::de::Error,
|
source: toml::de::Error,
|
||||||
},
|
},
|
||||||
#[error("failed to evaluate built-in Profile `{selector}`: {message}")]
|
|
||||||
BuiltinProfileEvaluation { selector: String, message: String },
|
|
||||||
#[error("Profile requires unsupported {target} launch authorities: {requirements:?}")]
|
|
||||||
UnsupportedExecutionTarget {
|
|
||||||
target: ProfileExecutionTarget,
|
|
||||||
requirements: Vec<WorkspaceAuthorityRequirement>,
|
|
||||||
},
|
|
||||||
#[error("no default profile is configured")]
|
#[error("no default profile is configured")]
|
||||||
NoDefaultProfile,
|
NoDefaultProfile,
|
||||||
#[error("profile resolution requires an explicit runtime Worker name")]
|
#[error("profile resolution requires an explicit runtime Worker name")]
|
||||||
@@ -1189,21 +1341,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_default_is_explicit_registry_authority() {
|
fn builtin_profiles_do_not_define_an_implicit_default() {
|
||||||
let registry = ProfileDiscovery::with_sources(None, None)
|
let registry = ProfileDiscovery::with_sources(None, None)
|
||||||
.discover()
|
.discover()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let default = registry.default_entry().unwrap();
|
assert!(matches!(
|
||||||
assert_eq!(default.source, ProfileRegistrySource::Builtin);
|
registry.default_entry(),
|
||||||
assert_eq!(default.name, "default");
|
Err(ProfileError::NoDefaultProfile)
|
||||||
assert_eq!(default.qualified_name(), BUILTIN_DEFAULT_PROFILE);
|
));
|
||||||
assert!(default.is_default);
|
assert!(matches!(
|
||||||
assert!(
|
registry.select(&ProfileSelector::Default),
|
||||||
default
|
Err(ProfileError::NoDefaultProfile)
|
||||||
.provenance
|
));
|
||||||
.starts_with("profiles/default.dcdl#sha256:")
|
|
||||||
);
|
|
||||||
assert_eq!(registry.select(&ProfileSelector::Default).unwrap(), default);
|
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_role_profiles_are_registered_and_resolve() {
|
fn builtin_role_profiles_are_registered_and_resolve() {
|
||||||
@@ -1238,9 +1387,7 @@ mod tests {
|
|||||||
("settings_revision", serde_json::json!(2)),
|
("settings_revision", serde_json::json!(2)),
|
||||||
("language", serde_json::json!("Japanese")),
|
("language", serde_json::json!("Japanese")),
|
||||||
] {
|
] {
|
||||||
let artifact = serde_json::json!({
|
let artifact = serde_json::json!({ "memory": { (field): value } });
|
||||||
"feature": { "memory": { (field): value } }
|
|
||||||
});
|
|
||||||
let error = resolve_profile_artifact_value(
|
let error = resolve_profile_artifact_value(
|
||||||
artifact,
|
artifact,
|
||||||
ProfileSource::Registry {
|
ProfileSource::Registry {
|
||||||
@@ -1261,151 +1408,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ambient_plugin_directories_do_not_affect_builtin_profile_resolution() {
|
fn builtin_companion_can_manage_workdirs() {
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let workspace = tmp.path().join("workspace/nested");
|
|
||||||
std::fs::create_dir_all(&workspace).unwrap();
|
|
||||||
for root in [tmp.path(), tmp.path().join("workspace").as_path()] {
|
|
||||||
let package = root.join(".yoi/plugins/broken.yoi-plugin");
|
|
||||||
std::fs::create_dir_all(package.parent().unwrap()).unwrap();
|
|
||||||
std::fs::write(package, b"malformed ambient package").unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolved = ProfileResolver::new()
|
|
||||||
.with_workspace_base(&workspace)
|
|
||||||
.resolve_for_target(
|
|
||||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"),
|
|
||||||
ProfileResolveOptions::with_worker_name("standalone-worker"),
|
|
||||||
ProfileExecutionTarget::Standalone,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(resolved.manifest.worker.name, "standalone-worker");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn profile_rejects_dynamic_plugin_configuration() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
for body in [
|
|
||||||
"[feature.plugins]\nenabled = true\n",
|
|
||||||
"[[plugins.enabled]]\nid = \"explicit:example\"\n",
|
|
||||||
] {
|
|
||||||
let profile = write_profile(tmp.path(), "plugin.toml", body);
|
|
||||||
let error = ProfileResolver::new()
|
|
||||||
.with_workspace_base(tmp.path())
|
|
||||||
.resolve(
|
|
||||||
&ProfileSelector::path(profile),
|
|
||||||
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(
|
|
||||||
error.to_string().contains("unknown field"),
|
|
||||||
"unexpected error: {error}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn builtin_default_resolves_as_a_standalone_local_capability_profile() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let resolved = ProfileResolver::new()
|
|
||||||
.with_workspace_base(tmp.path())
|
|
||||||
.resolve_for_target(
|
|
||||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"),
|
|
||||||
ProfileResolveOptions::with_worker_name("standalone-worker"),
|
|
||||||
ProfileExecutionTarget::Standalone,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
&resolved.source,
|
|
||||||
ProfileSource::Registry {
|
|
||||||
source: ProfileRegistrySource::Builtin,
|
|
||||||
name,
|
|
||||||
path: None,
|
|
||||||
provenance: Some(provenance),
|
|
||||||
..
|
|
||||||
} if name == "default" && provenance.starts_with("profiles/default.dcdl#sha256:")
|
|
||||||
));
|
|
||||||
assert!(resolved.manifest.feature.task.enabled);
|
|
||||||
assert!(resolved.manifest.feature.web.enabled);
|
|
||||||
assert!(resolved.manifest.feature.image.enabled);
|
|
||||||
assert!(resolved.manifest.feature.sub_worker.enabled);
|
|
||||||
assert!(resolved.manifest.scope.allow.iter().any(|rule| {
|
|
||||||
rule.permission == protocol::Permission::Write && rule.target == tmp.path()
|
|
||||||
}));
|
|
||||||
assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| {
|
|
||||||
rule.permission == protocol::Permission::Write && rule.target == tmp.path()
|
|
||||||
}));
|
|
||||||
assert!(!resolved.manifest.feature.memory.profile.enabled);
|
|
||||||
assert!(!resolved.manifest.feature.ticket.enabled);
|
|
||||||
assert!(!resolved.manifest.feature.objective.enabled);
|
|
||||||
assert!(!resolved.manifest.feature.flow.enabled);
|
|
||||||
assert!(!resolved.manifest.feature.worker.enabled);
|
|
||||||
assert!(!resolved.manifest.feature.manage_workdir.enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn standalone_rejects_profiles_that_require_workspace_authority() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let error = ProfileResolver::new()
|
|
||||||
.with_workspace_base(tmp.path())
|
|
||||||
.resolve_for_target(
|
|
||||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"),
|
|
||||||
ProfileResolveOptions::with_worker_name("standalone-worker"),
|
|
||||||
ProfileExecutionTarget::Standalone,
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
let diagnostic = error.to_string();
|
|
||||||
|
|
||||||
let ProfileError::UnsupportedExecutionTarget {
|
|
||||||
target,
|
|
||||||
requirements,
|
|
||||||
} = error
|
|
||||||
else {
|
|
||||||
panic!("unexpected error: {error}");
|
|
||||||
};
|
|
||||||
assert_eq!(target, ProfileExecutionTarget::Standalone);
|
|
||||||
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Memory));
|
|
||||||
assert!(requirements.contains(&WorkspaceAuthorityRequirement::MergeRequest));
|
|
||||||
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket));
|
|
||||||
assert!(!diagnostic.contains(tmp.path().to_string_lossy().as_ref()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn repository_markers_do_not_change_builtin_profile_authority() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let nested = tmp.path().join("repository/nested");
|
|
||||||
std::fs::create_dir_all(&nested).unwrap();
|
|
||||||
std::fs::create_dir_all(tmp.path().join("repository/.yoi")).unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
tmp.path().join("repository/.yoi/profiles.toml"),
|
|
||||||
"default = { source = 'project', name = 'shadow' }\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let discovery = ProfileDiscovery::for_cwd(&nested);
|
|
||||||
assert_eq!(discovery.user_config, paths::user_profiles_path());
|
|
||||||
assert!(discovery.project_config.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
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_combines_runtime_and_sub_worker_control_with_discovery() {
|
|
||||||
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())
|
||||||
@@ -1416,32 +1419,6 @@ 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);
|
|
||||||
assert!(!resolved.manifest.feature.worker.direct_spawn);
|
|
||||||
assert!(resolved.manifest.feature.workspace_worker_discovery.enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn builtin_orchestrator_keeps_cleanup_tool_providers_enabled() {
|
|
||||||
let tmp = TempDir::new().unwrap();
|
|
||||||
let resolved = ProfileResolver::new()
|
|
||||||
.with_workspace_base(tmp.path())
|
|
||||||
.resolve(
|
|
||||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "orchestrator"),
|
|
||||||
ProfileResolveOptions::with_worker_name("orchestrator-worker"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let feature = resolved.manifest.feature;
|
|
||||||
|
|
||||||
assert!(feature.worker.enabled);
|
|
||||||
assert!(!feature.worker.direct_spawn);
|
|
||||||
assert!(feature.manage_workdir.enabled);
|
|
||||||
assert!(feature.merge_request.show);
|
|
||||||
assert!(feature.merge_request.readiness_check);
|
|
||||||
assert!(feature.merge_request.complete);
|
|
||||||
assert!(!feature.merge_request.open);
|
|
||||||
assert!(!feature.merge_request.review);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1614,7 +1591,7 @@ enabled = false
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
|
assert_eq!(resolved.manifest.worker.name, "runtime-worker");
|
||||||
assert!(resolved.manifest.feature.task.enabled);
|
assert!(resolved.manifest.feature.task.enabled);
|
||||||
assert!(!resolved.manifest.feature.memory.profile.enabled);
|
assert!(!resolved.manifest.feature.memory.enabled);
|
||||||
assert!(resolved.manifest.feature.web.enabled);
|
assert!(resolved.manifest.feature.web.enabled);
|
||||||
assert!(resolved.manifest.feature.sub_worker.enabled);
|
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||||
assert!(resolved.manifest.feature.ticket.enabled);
|
assert!(resolved.manifest.feature.ticket.enabled);
|
||||||
|
|||||||
+69
-271
@@ -3,17 +3,16 @@
|
|||||||
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
|
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
|
||||||
//! rule `target` must already be an absolute path — per-layer path
|
//! rule `target` must already be an absolute path — per-layer path
|
||||||
//! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`].
|
//! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`].
|
||||||
//! All rule targets retain both their lexically normalized logical identity and
|
//! All rule `target` paths inside the [`Scope`] are canonicalised (where
|
||||||
//! their provider-resolved identity. Allow rules select one identity explicitly;
|
//! possible) so access checks are pure path comparisons.
|
||||||
//! deny rules always inspect both so aliases cannot bypass a restriction.
|
|
||||||
|
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use arc_swap::{ArcSwap, Guard};
|
use arc_swap::{ArcSwap, Guard};
|
||||||
|
|
||||||
use crate::{Permission, ScopeConfig, ScopeRule, SymlinkPolicy};
|
use crate::{Permission, ScopeConfig, ScopeRule};
|
||||||
|
|
||||||
/// Parsed, pwd-resolved set of allow/deny rules for a Worker.
|
/// Parsed, pwd-resolved set of allow/deny rules for a Worker.
|
||||||
///
|
///
|
||||||
@@ -27,13 +26,10 @@ pub struct Scope {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct ResolvedRule {
|
struct ResolvedRule {
|
||||||
/// Absolute, lexically normalized target as presented through the Workdir.
|
/// Absolute, canonicalized-or-normalized target directory/file.
|
||||||
logical_target: PathBuf,
|
target: PathBuf,
|
||||||
/// Absolute target after provider-side symbolic-link resolution.
|
|
||||||
resolved_target: PathBuf,
|
|
||||||
permission: Permission,
|
permission: Permission,
|
||||||
recursive: bool,
|
recursive: bool,
|
||||||
symlink_policy: SymlinkPolicy,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed filesystem authority this Worker may pass to spawned children.
|
/// Parsed filesystem authority this Worker may pass to spawned children.
|
||||||
@@ -102,46 +98,18 @@ fn permission_denies_requested(denied: Permission, requested: Permission) -> boo
|
|||||||
|
|
||||||
fn rule_covers(available: &ResolvedRule, requested: &ResolvedRule) -> bool {
|
fn rule_covers(available: &ResolvedRule, requested: &ResolvedRule) -> bool {
|
||||||
permission_covers(available.permission, requested.permission)
|
permission_covers(available.permission, requested.permission)
|
||||||
&& available.symlink_policy >= requested.symlink_policy
|
&& rule_path_set_contains(available, requested)
|
||||||
&& rule_path_set_contains(
|
|
||||||
available,
|
|
||||||
requested,
|
|
||||||
match available.symlink_policy {
|
|
||||||
SymlinkPolicy::Resolved => RuleIdentity::Resolved,
|
|
||||||
SymlinkPolicy::Logical => RuleIdentity::Logical,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn denial_overlaps_requested(deny: &ResolvedRule, requested: &ResolvedRule) -> bool {
|
fn denial_overlaps_requested(deny: &ResolvedRule, requested: &ResolvedRule) -> bool {
|
||||||
permission_denies_requested(deny.permission, requested.permission)
|
permission_denies_requested(deny.permission, requested.permission)
|
||||||
&& (rule_path_sets_overlap(deny, requested, RuleIdentity::Logical)
|
&& rule_path_sets_overlap(deny, requested)
|
||||||
|| rule_path_sets_overlap(deny, requested, RuleIdentity::Resolved))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
fn rule_path_set_contains(available: &ResolvedRule, requested: &ResolvedRule) -> bool {
|
||||||
enum RuleIdentity {
|
|
||||||
Logical,
|
|
||||||
Resolved,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rule_target(rule: &ResolvedRule, identity: RuleIdentity) -> &Path {
|
|
||||||
match identity {
|
|
||||||
RuleIdentity::Logical => &rule.logical_target,
|
|
||||||
RuleIdentity::Resolved => &rule.resolved_target,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rule_path_set_contains(
|
|
||||||
available: &ResolvedRule,
|
|
||||||
requested: &ResolvedRule,
|
|
||||||
identity: RuleIdentity,
|
|
||||||
) -> bool {
|
|
||||||
let available_target = rule_target(available, identity);
|
|
||||||
let requested_target = rule_target(requested, identity);
|
|
||||||
match (available.recursive, requested.recursive) {
|
match (available.recursive, requested.recursive) {
|
||||||
// A recursive grant contains every possible requested path below its target.
|
// A recursive grant contains every possible requested path below its target.
|
||||||
(true, _) => requested_target.starts_with(available_target),
|
(true, _) => requested.target.starts_with(&available.target),
|
||||||
// A non-recursive grant contains only the target and its direct children;
|
// A non-recursive grant contains only the target and its direct children;
|
||||||
// a recursive request always includes descendants beyond that finite-depth
|
// a recursive request always includes descendants beyond that finite-depth
|
||||||
// set.
|
// set.
|
||||||
@@ -149,42 +117,36 @@ fn rule_path_set_contains(
|
|||||||
// Two non-recursive rules have the same finite-depth set only when their
|
// Two non-recursive rules have the same finite-depth set only when their
|
||||||
// target is identical. A request rooted at a direct child would also grant
|
// target is identical. A request rooted at a direct child would also grant
|
||||||
// that child's children, which are grandchildren of `available.target`.
|
// that child's children, which are grandchildren of `available.target`.
|
||||||
(false, false) => requested_target == available_target,
|
(false, false) => requested.target == available.target,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rule_path_sets_overlap(
|
fn rule_path_sets_overlap(left: &ResolvedRule, right: &ResolvedRule) -> bool {
|
||||||
left: &ResolvedRule,
|
|
||||||
right: &ResolvedRule,
|
|
||||||
identity: RuleIdentity,
|
|
||||||
) -> bool {
|
|
||||||
let left_target = rule_target(left, identity);
|
|
||||||
let right_target = rule_target(right, identity);
|
|
||||||
match (left.recursive, right.recursive) {
|
match (left.recursive, right.recursive) {
|
||||||
(true, true) => {
|
(true, true) => {
|
||||||
left_target.starts_with(right_target) || right_target.starts_with(left_target)
|
left.target.starts_with(&right.target) || right.target.starts_with(&left.target)
|
||||||
}
|
}
|
||||||
(true, false) => recursive_and_non_recursive_sets_overlap(left_target, right_target),
|
(true, false) => recursive_and_non_recursive_sets_overlap(left, right),
|
||||||
(false, true) => recursive_and_non_recursive_sets_overlap(right_target, left_target),
|
(false, true) => recursive_and_non_recursive_sets_overlap(right, left),
|
||||||
(false, false) => {
|
(false, false) => {
|
||||||
left_target == right_target
|
left.target == right.target
|
||||||
|| direct_child(left_target, right_target)
|
|| direct_child(&left.target, &right.target)
|
||||||
|| direct_child(right_target, left_target)
|
|| direct_child(&right.target, &left.target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn recursive_and_non_recursive_sets_overlap(
|
fn recursive_and_non_recursive_sets_overlap(
|
||||||
recursive_target: &Path,
|
recursive: &ResolvedRule,
|
||||||
non_recursive_target: &Path,
|
non_recursive: &ResolvedRule,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
// The non-recursive set is `{target} + direct children`. It overlaps a
|
// The non-recursive set is `{target} + direct children`. It overlaps a
|
||||||
// recursive subtree when either the non-recursive target is inside that
|
// recursive subtree when either the non-recursive target is inside that
|
||||||
// subtree, or the recursive subtree begins at the non-recursive target or
|
// subtree, or the recursive subtree begins at the non-recursive target or
|
||||||
// one of its direct children.
|
// one of its direct children.
|
||||||
non_recursive_target.starts_with(recursive_target)
|
non_recursive.target.starts_with(&recursive.target)
|
||||||
|| recursive_target == non_recursive_target
|
|| recursive.target == non_recursive.target
|
||||||
|| direct_child(recursive_target, non_recursive_target)
|
|| direct_child(&recursive.target, &non_recursive.target)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn direct_child(child: &Path, parent: &Path) -> bool {
|
fn direct_child(child: &Path, parent: &Path) -> bool {
|
||||||
@@ -239,35 +201,23 @@ impl Scope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience constructor for tests and simple setups: a single
|
/// Convenience constructor for tests and simple setups: a single
|
||||||
/// recursive `allow(Write)` rule rooted at `root` with the default
|
/// recursive `allow(Write)` rule rooted at `root`.
|
||||||
/// resolved-target symlink policy.
|
|
||||||
pub fn writable(root: impl AsRef<Path>) -> std::io::Result<Self> {
|
pub fn writable(root: impl AsRef<Path>) -> std::io::Result<Self> {
|
||||||
let root = normalize_path(root.as_ref()).ok_or_else(|| {
|
let root = root.as_ref().canonicalize()?;
|
||||||
std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidInput,
|
|
||||||
"scope root must be an absolute path without root traversal",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let resolved_root = resolve_path(&root)?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
allow: vec![ResolvedRule {
|
allow: vec![ResolvedRule {
|
||||||
logical_target: root,
|
target: root,
|
||||||
resolved_target: resolved_root,
|
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: SymlinkPolicy::Resolved,
|
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return one rule target in the identity selected by its symlink policy.
|
/// Resolve one rule target with the same symlink and missing-tail semantics
|
||||||
|
/// used by scope matching.
|
||||||
pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> {
|
pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> {
|
||||||
let rule = resolve_rule(rule)?;
|
Ok(resolve_rule(rule)?.target)
|
||||||
Ok(match rule.symlink_policy {
|
|
||||||
SymlinkPolicy::Resolved => rule.resolved_target,
|
|
||||||
SymlinkPolicy::Logical => rule.logical_target,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return whether this effective scope fully contains a requested rule.
|
/// Return whether this effective scope fully contains a requested rule.
|
||||||
@@ -294,23 +244,10 @@ impl Scope {
|
|||||||
/// Returns `None` when `path` is outside every allow rule, or when
|
/// Returns `None` when `path` is outside every allow rule, or when
|
||||||
/// deny rules have knocked it below `Read`.
|
/// deny rules have knocked it below `Read`.
|
||||||
pub fn permission_at(&self, path: &Path) -> Option<Permission> {
|
pub fn permission_at(&self, path: &Path) -> Option<Permission> {
|
||||||
let logical = normalize_path(path)?;
|
let resolved = resolve_path(path)?;
|
||||||
let resolved = resolve_path(&logical).ok()?;
|
|
||||||
self.permission_at_paths(&logical, &resolved)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Effective permission for a path whose logical and provider-resolved
|
|
||||||
/// identities were obtained inside the filesystem provider boundary.
|
|
||||||
pub fn permission_at_paths(&self, logical: &Path, resolved: &Path) -> Option<Permission> {
|
|
||||||
let logical = normalize_path(logical)?;
|
|
||||||
let resolved = normalize_path(resolved)?;
|
|
||||||
let mut effective: Option<Permission> = None;
|
let mut effective: Option<Permission> = None;
|
||||||
for rule in &self.allow {
|
for rule in &self.allow {
|
||||||
let candidate = match rule.symlink_policy {
|
if rule.matches(&resolved) {
|
||||||
SymlinkPolicy::Resolved => &resolved,
|
|
||||||
SymlinkPolicy::Logical => &logical,
|
|
||||||
};
|
|
||||||
if rule.matches(candidate, rule.symlink_policy) {
|
|
||||||
effective = match effective {
|
effective = match effective {
|
||||||
None => Some(rule.permission),
|
None => Some(rule.permission),
|
||||||
Some(cur) => Some(cur.max(rule.permission)),
|
Some(cur) => Some(cur.max(rule.permission)),
|
||||||
@@ -319,13 +256,11 @@ impl Scope {
|
|||||||
}
|
}
|
||||||
let mut effective = effective?;
|
let mut effective = effective?;
|
||||||
|
|
||||||
// Deny rules always inspect both identities. This prevents a logical
|
// Deny: min(min_deny) dictates the cap. Effective level is capped
|
||||||
// alias or a second symlink to the same target from bypassing a deny.
|
// strictly below that value, so deny(read) wipes access entirely.
|
||||||
let mut min_deny: Option<Permission> = None;
|
let mut min_deny: Option<Permission> = None;
|
||||||
for rule in &self.deny {
|
for rule in &self.deny {
|
||||||
if rule.matches(&logical, SymlinkPolicy::Logical)
|
if rule.matches(&resolved) {
|
||||||
|| rule.matches(&resolved, SymlinkPolicy::Resolved)
|
|
||||||
{
|
|
||||||
min_deny = match min_deny {
|
min_deny = match min_deny {
|
||||||
None => Some(rule.permission),
|
None => Some(rule.permission),
|
||||||
Some(cur) => Some(cur.min(rule.permission)),
|
Some(cur) => Some(cur.min(rule.permission)),
|
||||||
@@ -358,7 +293,7 @@ impl Scope {
|
|||||||
/// rule, preserving declaration order. Does not account for deny
|
/// rule, preserving declaration order. Does not account for deny
|
||||||
/// rules, which only cap effective permission at query time.
|
/// rules, which only cap effective permission at query time.
|
||||||
pub fn readable_paths(&self) -> impl Iterator<Item = &Path> {
|
pub fn readable_paths(&self) -> impl Iterator<Item = &Path> {
|
||||||
self.allow.iter().map(|r| r.logical_target.as_path())
|
self.allow.iter().map(|r| r.target.as_path())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Allow rules with their targets resolved to absolute paths.
|
/// Allow rules with their targets resolved to absolute paths.
|
||||||
@@ -370,10 +305,9 @@ impl Scope {
|
|||||||
self.allow
|
self.allow
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| ScopeRule {
|
.map(|r| ScopeRule {
|
||||||
target: r.logical_target.clone(),
|
target: r.target.clone(),
|
||||||
permission: r.permission,
|
permission: r.permission,
|
||||||
recursive: r.recursive,
|
recursive: r.recursive,
|
||||||
symlink_policy: r.symlink_policy,
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -388,10 +322,9 @@ impl Scope {
|
|||||||
self.deny
|
self.deny
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| ScopeRule {
|
.map(|r| ScopeRule {
|
||||||
target: r.logical_target.clone(),
|
target: r.target.clone(),
|
||||||
permission: r.permission,
|
permission: r.permission,
|
||||||
recursive: r.recursive,
|
recursive: r.recursive,
|
||||||
symlink_policy: r.symlink_policy,
|
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -402,7 +335,7 @@ impl Scope {
|
|||||||
self.allow
|
self.allow
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| r.permission == Permission::Write)
|
.filter(|r| r.permission == Permission::Write)
|
||||||
.map(|r| r.logical_target.as_path())
|
.map(|r| r.target.as_path())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a new [`Scope`] equal to `self` with `extra_allow` appended
|
/// Build a new [`Scope`] equal to `self` with `extra_allow` appended
|
||||||
@@ -479,10 +412,7 @@ impl Scope {
|
|||||||
pub fn summary(&self) -> String {
|
pub fn summary(&self) -> String {
|
||||||
fn push_rule(out: &mut String, rule: &ResolvedRule) {
|
fn push_rule(out: &mut String, rule: &ResolvedRule) {
|
||||||
out.push_str(" - ");
|
out.push_str(" - ");
|
||||||
out.push_str(&rule.logical_target.display().to_string());
|
out.push_str(&rule.target.display().to_string());
|
||||||
if rule.symlink_policy == SymlinkPolicy::Logical {
|
|
||||||
out.push_str(" [logical-symlinks]");
|
|
||||||
}
|
|
||||||
if !rule.recursive {
|
if !rule.recursive {
|
||||||
out.push_str(" [non-recursive]");
|
out.push_str(" [non-recursive]");
|
||||||
}
|
}
|
||||||
@@ -580,15 +510,11 @@ impl SharedScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ResolvedRule {
|
impl ResolvedRule {
|
||||||
fn matches(&self, path: &Path, identity: SymlinkPolicy) -> bool {
|
fn matches(&self, path: &Path) -> bool {
|
||||||
let target = match identity {
|
|
||||||
SymlinkPolicy::Resolved => &self.resolved_target,
|
|
||||||
SymlinkPolicy::Logical => &self.logical_target,
|
|
||||||
};
|
|
||||||
if self.recursive {
|
if self.recursive {
|
||||||
path.starts_with(target)
|
path.starts_with(&self.target)
|
||||||
} else {
|
} else {
|
||||||
path == target || path.parent() == Some(target.as_path())
|
path == self.target || path.parent() == Some(self.target.as_path())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -597,84 +523,48 @@ fn resolve_rule(rule: &ScopeRule) -> Result<ResolvedRule, ScopeError> {
|
|||||||
if !rule.target.is_absolute() {
|
if !rule.target.is_absolute() {
|
||||||
return Err(ScopeError::RelativeTarget(rule.target.clone()));
|
return Err(ScopeError::RelativeTarget(rule.target.clone()));
|
||||||
}
|
}
|
||||||
let logical_target = normalize_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget {
|
let target = resolve_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget {
|
||||||
path: rule.target.clone(),
|
path: rule.target.clone(),
|
||||||
source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"),
|
source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"),
|
||||||
})?;
|
})?;
|
||||||
let resolved_target =
|
|
||||||
resolve_path(&logical_target).map_err(|source| ScopeError::ResolveTarget {
|
|
||||||
path: rule.target.clone(),
|
|
||||||
source,
|
|
||||||
})?;
|
|
||||||
Ok(ResolvedRule {
|
Ok(ResolvedRule {
|
||||||
logical_target,
|
target,
|
||||||
resolved_target,
|
|
||||||
permission: rule.permission,
|
permission: rule.permission,
|
||||||
recursive: rule.recursive,
|
recursive: rule.recursive,
|
||||||
symlink_policy: rule.symlink_policy,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve every existing path component while retaining a missing final tail.
|
/// Convert `path` to an absolute form suitable for prefix comparison.
|
||||||
/// A dangling symlink is rejected rather than treated as an ordinary missing
|
///
|
||||||
/// component because its resolved authority cannot be established.
|
/// Tries `canonicalize` on the full path first (resolves symlinks). If
|
||||||
fn resolve_path(path: &Path) -> std::io::Result<PathBuf> {
|
/// the path doesn't exist yet, climbs to the closest existing ancestor,
|
||||||
let mut cursor = path;
|
/// canonicalizes it, then rejoins the missing tail. Returns `None` for
|
||||||
let mut missing = Vec::<OsString>::new();
|
/// relative inputs that have no existing ancestor to anchor against.
|
||||||
loop {
|
fn resolve_path(path: &Path) -> Option<PathBuf> {
|
||||||
match std::fs::canonicalize(cursor) {
|
|
||||||
Ok(mut resolved) => {
|
|
||||||
for component in missing.iter().rev() {
|
|
||||||
resolved.push(component);
|
|
||||||
}
|
|
||||||
return normalize_path(&resolved).ok_or_else(|| {
|
|
||||||
std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidInput,
|
|
||||||
"resolved target is not an absolute normalized path",
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
||||||
if std::fs::symlink_metadata(cursor)
|
|
||||||
.is_ok_and(|metadata| metadata.file_type().is_symlink())
|
|
||||||
{
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
let name = cursor.file_name().ok_or(error)?;
|
|
||||||
missing.push(name.to_os_string());
|
|
||||||
cursor = cursor.parent().ok_or_else(|| {
|
|
||||||
std::io::Error::new(
|
|
||||||
std::io::ErrorKind::NotFound,
|
|
||||||
"scope target has no existing ancestor",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalize an absolute path for lexical scope comparison without consulting
|
|
||||||
/// filesystem metadata or resolving symbolic links.
|
|
||||||
fn normalize_path(path: &Path) -> Option<PathBuf> {
|
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut normalized = PathBuf::new();
|
if let Ok(canonical) = path.canonicalize() {
|
||||||
for component in path.components() {
|
return Some(canonical);
|
||||||
match component {
|
}
|
||||||
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
|
let mut tail: Vec<OsString> = Vec::new();
|
||||||
Component::RootDir => normalized.push(component.as_os_str()),
|
let mut cur = path.to_path_buf();
|
||||||
Component::CurDir => {}
|
loop {
|
||||||
Component::ParentDir => {
|
if let Ok(canonical) = cur.canonicalize() {
|
||||||
if !normalized.pop() {
|
let mut out = canonical;
|
||||||
return None;
|
for segment in tail.iter().rev() {
|
||||||
}
|
out.push(segment);
|
||||||
}
|
}
|
||||||
Component::Normal(part) => normalized.push(part),
|
return Some(out);
|
||||||
}
|
}
|
||||||
|
let name = cur.file_name()?.to_os_string();
|
||||||
|
tail.push(name);
|
||||||
|
let parent = cur.parent()?.to_path_buf();
|
||||||
|
if parent == cur {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
cur = parent;
|
||||||
}
|
}
|
||||||
normalized.is_absolute().then_some(normalized)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -687,7 +577,6 @@ mod tests {
|
|||||||
target: target.to_path_buf(),
|
target: target.to_path_buf(),
|
||||||
permission,
|
permission,
|
||||||
recursive,
|
recursive,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,7 +691,6 @@ mod tests {
|
|||||||
target: dir.path().to_path_buf(),
|
target: dir.path().to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: false,
|
recursive: false,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -902,7 +790,6 @@ mod tests {
|
|||||||
target: PathBuf::from("relative/path"),
|
target: PathBuf::from("relative/path"),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -918,88 +805,6 @@ mod tests {
|
|||||||
assert!(!scope.is_readable(&traversal));
|
assert!(!scope.is_readable(&traversal));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn scope_defaults_to_resolved_symlink_authority_and_logical_is_explicit() {
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
|
|
||||||
let dir = TempDir::new().unwrap();
|
|
||||||
let outside = TempDir::new().unwrap();
|
|
||||||
std::fs::write(outside.path().join("outside.txt"), "visible through link").unwrap();
|
|
||||||
symlink(outside.path(), dir.path().join("external")).unwrap();
|
|
||||||
|
|
||||||
let resolved = Scope::writable(dir.path()).unwrap();
|
|
||||||
assert!(!resolved.is_readable(&dir.path().join("external/outside.txt")));
|
|
||||||
assert!(!resolved.is_writable(&dir.path().join("external/new.txt")));
|
|
||||||
|
|
||||||
let logical = Scope::from_config(&ScopeConfig {
|
|
||||||
allow: vec![ScopeRule {
|
|
||||||
target: dir.path().to_path_buf(),
|
|
||||||
permission: Permission::Write,
|
|
||||||
recursive: true,
|
|
||||||
symlink_policy: SymlinkPolicy::Logical,
|
|
||||||
}],
|
|
||||||
deny: Vec::new(),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert!(logical.is_readable(&dir.path().join("external/outside.txt")));
|
|
||||||
assert!(logical.is_writable(&dir.path().join("external/new.txt")));
|
|
||||||
assert!(!logical.is_readable(&outside.path().join("outside.txt")));
|
|
||||||
assert!(!logical.is_writable(&outside.path().join("new.txt")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn deny_rules_match_both_logical_alias_and_resolved_target() {
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
|
|
||||||
let root = TempDir::new().unwrap();
|
|
||||||
let secret = root.path().join("secret");
|
|
||||||
std::fs::create_dir(&secret).unwrap();
|
|
||||||
std::fs::write(secret.join("key"), "hidden").unwrap();
|
|
||||||
symlink(&secret, root.path().join("alias")).unwrap();
|
|
||||||
let scope = Scope::from_config(&ScopeConfig {
|
|
||||||
allow: vec![ScopeRule {
|
|
||||||
target: root.path().to_path_buf(),
|
|
||||||
permission: Permission::Write,
|
|
||||||
recursive: true,
|
|
||||||
symlink_policy: SymlinkPolicy::Logical,
|
|
||||||
}],
|
|
||||||
deny: vec![ScopeRule {
|
|
||||||
target: secret,
|
|
||||||
permission: Permission::Read,
|
|
||||||
recursive: true,
|
|
||||||
symlink_policy: SymlinkPolicy::Logical,
|
|
||||||
}],
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(!scope.is_readable(&root.path().join("alias/key")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn delegation_symlink_policy_is_monotonically_attenuated() {
|
|
||||||
let root = TempDir::new().unwrap();
|
|
||||||
let mut parent_rule = allow_rule(root.path(), Permission::Write);
|
|
||||||
parent_rule.symlink_policy = SymlinkPolicy::Logical;
|
|
||||||
let logical_parent = DelegationScope::from_config(&ScopeConfig {
|
|
||||||
allow: vec![parent_rule],
|
|
||||||
deny: Vec::new(),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
let resolved_child = allow_rule(&root.path().join("child"), Permission::Read);
|
|
||||||
assert!(logical_parent.allows_rule(&resolved_child).unwrap());
|
|
||||||
|
|
||||||
let resolved_parent = DelegationScope::from_config(&ScopeConfig {
|
|
||||||
allow: vec![allow_rule(root.path(), Permission::Write)],
|
|
||||||
deny: Vec::new(),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
let mut logical_child = resolved_child;
|
|
||||||
logical_child.symlink_policy = SymlinkPolicy::Logical;
|
|
||||||
assert!(!resolved_parent.allows_rule(&logical_child).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn summary_lists_readable_and_writable() {
|
fn summary_lists_readable_and_writable() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -1046,13 +851,11 @@ mod tests {
|
|||||||
target: docs.clone(),
|
target: docs.clone(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: false,
|
recursive: false,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
},
|
},
|
||||||
ScopeRule {
|
ScopeRule {
|
||||||
target: dir.path().to_path_buf(),
|
target: dir.path().to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
@@ -1111,7 +914,6 @@ mod tests {
|
|||||||
target: extra.path().to_path_buf(),
|
target: extra.path().to_path_buf(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}])
|
}])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(extended.is_readable(&extra.path().join("x")));
|
assert!(extended.is_readable(&extra.path().join("x")));
|
||||||
@@ -1129,7 +931,6 @@ mod tests {
|
|||||||
target: sub.clone(),
|
target: sub.clone(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}])
|
}])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let f = sub.join("a.txt");
|
let f = sub.join("a.txt");
|
||||||
@@ -1149,7 +950,6 @@ mod tests {
|
|||||||
target: sub.clone(),
|
target: sub.clone(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
};
|
};
|
||||||
let base = Scope::writable(dir.path())
|
let base = Scope::writable(dir.path())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1203,7 +1003,6 @@ mod tests {
|
|||||||
target: sub.clone(),
|
target: sub.clone(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1222,7 +1021,6 @@ mod tests {
|
|||||||
target: extra.path().to_path_buf(),
|
target: extra.path().to_path_buf(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -152,10 +152,13 @@ pub enum MemoryStagingAffectedMemoryOperation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct MemoryConsolidateStagingOperation {
|
pub struct MemoryConsolidateStagingOperation {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub threshold_files: Option<usize>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub threshold_bytes: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -447,21 +450,10 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::extract::{CandidateKind, ExtractedCandidate};
|
use crate::extract::{CandidateKind, ExtractedCandidate};
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn consolidation_operation_rejects_caller_owned_thresholds() {
|
|
||||||
let error =
|
|
||||||
serde_json::from_value::<MemoryConsolidateStagingOperation>(serde_json::json!({
|
|
||||||
"force": false,
|
|
||||||
"threshold_files": 1,
|
|
||||||
}))
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(error.to_string().contains("threshold_files"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn staging_list_read_close_records_reason_and_deletes_candidate() {
|
fn staging_list_read_close_records_reason_and_deletes_candidate() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let layout = WorkspaceLayout::resolve(temp.path());
|
let layout = WorkspaceLayout::resolve(&manifest::MemoryConfig::default(), temp.path());
|
||||||
let source = SourceRef {
|
let source = SourceRef {
|
||||||
segment_id: "segment-1".into(),
|
segment_id: "segment-1".into(),
|
||||||
range: [0, 1],
|
range: [0, 1],
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ pub struct StagingEntry {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
pub record: StagingRecord,
|
pub record: StagingRecord,
|
||||||
/// このファイルのバイト長。Backendのconsolidation閾値判定に使用する。
|
/// このファイルのバイト長。閾値判定 (`consolidation_threshold_bytes`)
|
||||||
|
/// に使う。
|
||||||
pub bytes: u64,
|
pub bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::schema::{EvidenceKind, EvidenceOrigin, SourceEvidenceRef, SourceRef};
|
use crate::schema::{EvidenceKind, 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;
|
||||||
@@ -74,15 +74,12 @@ impl ExtractedPayload {
|
|||||||
|
|
||||||
/// Bounded evidence snippet copied into a flat staging record.
|
/// Bounded evidence snippet copied into a flat staging record.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct StagingEvidence {
|
pub struct StagingEvidence {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub kind: EvidenceKind,
|
pub kind: EvidenceKind,
|
||||||
#[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>,
|
||||||
@@ -90,7 +87,6 @@ pub struct StagingEvidence {
|
|||||||
|
|
||||||
/// One flat staging record. One record is one consolidation decision unit.
|
/// One flat staging record. One record is one consolidation decision unit.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct StagingRecord {
|
pub struct StagingRecord {
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -163,7 +159,6 @@ 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()),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ impl<'de> Deserialize<'de> for SourceRef {
|
|||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
{
|
{
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct RawSourceRef {
|
struct RawSourceRef {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
segment_id: Option<String>,
|
segment_id: Option<String>,
|
||||||
@@ -68,48 +67,12 @@ 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)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
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
|
||||||
/// ranges, and short labels/summaries. It must not carry raw message bodies or
|
/// ranges, and short labels/summaries. It must not carry raw message bodies or
|
||||||
/// full tool result content.
|
/// full tool result content.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct SourceEvidenceRef {
|
pub struct SourceEvidenceRef {
|
||||||
/// Stable session id when the anchor crosses or disambiguates segments.
|
/// Stable session id when the anchor crosses or disambiguates segments.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -123,9 +86,6 @@ 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,10 +10,7 @@ mod decision;
|
|||||||
mod request;
|
mod request;
|
||||||
mod summary;
|
mod summary;
|
||||||
|
|
||||||
pub use common::{
|
pub use common::{EvidenceKind, Frontmatter, SourceEvidenceRef, SourceRef, split_frontmatter};
|
||||||
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;
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ fn deny_write(target: &Path) -> ScopeRule {
|
|||||||
target: target.to_path_buf(),
|
target: target.to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,12 +70,24 @@ impl WorkspaceLayout {
|
|||||||
Self { root: root.into() }
|
Self { root: root.into() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a layout from the nearest Memory marker.
|
/// Resolve a layout from a `MemoryConfig`.
|
||||||
///
|
///
|
||||||
/// Resolution searches `default_root` and its ancestors for the nearest
|
/// An explicit `memory.workspace_root` is honored exactly. Without an
|
||||||
/// `.yoi/memory` directory. This legacy local-storage helper owns its path
|
/// explicit root, resolution searches `default_root` and its ancestors for
|
||||||
/// policy directly; resolved Worker Manifests do not carry storage paths.
|
/// the nearest `.yoi/memory` directory. This keeps child worktrees that
|
||||||
pub fn resolve(default_root: &Path) -> Self {
|
/// contain `.yoi` project records such as tickets from
|
||||||
|
/// becoming independent memory roots merely because they contain `.yoi`.
|
||||||
|
///
|
||||||
|
/// If no memory marker exists, this falls back to `default_root` because
|
||||||
|
/// existing call sites require a concrete layout. That fallback is a
|
||||||
|
/// no-marker compatibility path, not a `.yoi` marker interpretation; it
|
||||||
|
/// must not be used as evidence that `.yoi` alone enables repo-local
|
||||||
|
/// memory.
|
||||||
|
pub fn resolve(cfg: &manifest::MemoryConfig, default_root: &Path) -> Self {
|
||||||
|
if let Some(root) = &cfg.workspace_root {
|
||||||
|
return Self::new(root.clone());
|
||||||
|
}
|
||||||
|
|
||||||
let root =
|
let root =
|
||||||
find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf());
|
find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf());
|
||||||
Self::new(root)
|
Self::new(root)
|
||||||
@@ -323,6 +335,16 @@ mod tests {
|
|||||||
assert!(matches!(err, LintError::InvalidPath(_)));
|
assert!(matches!(err, LintError::InvalidPath(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_uses_workspace_root_when_set() {
|
||||||
|
let cfg = manifest::MemoryConfig {
|
||||||
|
workspace_root: Some(PathBuf::from("/explicit")),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let layout = WorkspaceLayout::resolve(&cfg, Path::new("/fallback"));
|
||||||
|
assert_eq!(layout.root(), Path::new("/explicit"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() {
|
fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
@@ -331,7 +353,8 @@ mod tests {
|
|||||||
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
|
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
|
||||||
std::fs::create_dir_all(&child).unwrap();
|
std::fs::create_dir_all(&child).unwrap();
|
||||||
|
|
||||||
let layout = WorkspaceLayout::resolve(&child);
|
let cfg = manifest::MemoryConfig::default();
|
||||||
|
let layout = WorkspaceLayout::resolve(&cfg, &child);
|
||||||
assert_eq!(layout.root(), workspace.as_path());
|
assert_eq!(layout.root(), workspace.as_path());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +366,8 @@ mod tests {
|
|||||||
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
|
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
|
||||||
std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap();
|
std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap();
|
||||||
|
|
||||||
let layout = WorkspaceLayout::resolve(&child);
|
let cfg = manifest::MemoryConfig::default();
|
||||||
|
let layout = WorkspaceLayout::resolve(&cfg, &child);
|
||||||
assert_eq!(layout.root(), workspace.as_path());
|
assert_eq!(layout.root(), workspace.as_path());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +381,8 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(find_memory_marker_root(&child), None);
|
assert_eq!(find_memory_marker_root(&child), None);
|
||||||
|
|
||||||
let layout = WorkspaceLayout::resolve(&child);
|
let cfg = manifest::MemoryConfig::default();
|
||||||
|
let layout = WorkspaceLayout::resolve(&cfg, &child);
|
||||||
assert_eq!(layout.root(), child.as_path());
|
assert_eq!(layout.root(), child.as_path());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+244
-72
@@ -9,6 +9,7 @@ use thiserror::Error;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
const SCHEMA_VERSION: i64 = 12;
|
const SCHEMA_VERSION: i64 = 12;
|
||||||
|
const PREVIOUS_SCHEMA_VERSION: i64 = 11;
|
||||||
const MAX_BODY_BYTES: usize = 16 * 1024;
|
const MAX_BODY_BYTES: usize = 16 * 1024;
|
||||||
const DOMAIN_TABLES: [&str; 5] = [
|
const DOMAIN_TABLES: [&str; 5] = [
|
||||||
"merge_requests",
|
"merge_requests",
|
||||||
@@ -36,7 +37,7 @@ impl MergeRequestState {
|
|||||||
|
|
||||||
fn parse(v: &str) -> Result<Self, MergeRequestError> {
|
fn parse(v: &str) -> Result<Self, MergeRequestError> {
|
||||||
match v {
|
match v {
|
||||||
"open" => Ok(Self::Open),
|
"draft" | "open" => Ok(Self::Open),
|
||||||
"merged" => Ok(Self::Merged),
|
"merged" => Ok(Self::Merged),
|
||||||
"closed" => Ok(Self::Closed),
|
"closed" => Ok(Self::Closed),
|
||||||
_ => Err(MergeRequestError::Corrupt(format!("unknown state `{v}`"))),
|
_ => Err(MergeRequestError::Corrupt(format!("unknown state `{v}`"))),
|
||||||
@@ -273,12 +274,6 @@ pub struct RegisterReviewerChildSession {
|
|||||||
pub reviewer_profile: String,
|
pub reviewer_profile: String,
|
||||||
pub now: DateTime<Utc>,
|
pub now: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct ReviewSubmissionAuthorization {
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub subject_ref: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SubmitMergeRequestReview {
|
pub struct SubmitMergeRequestReview {
|
||||||
pub ticket_id: String,
|
pub ticket_id: String,
|
||||||
@@ -421,7 +416,7 @@ impl MergeRequestStore {
|
|||||||
let conflict:bool=t.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_ticket_relations rel JOIN merge_requests mr ON mr.workspace_id=rel.workspace_id AND mr.merge_request_id=rel.merge_request_id WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 AND mr.state='open')",params![i.auth.workspace_id,i.ticket_id],|r|r.get(0))?;
|
let conflict:bool=t.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_ticket_relations rel JOIN merge_requests mr ON mr.workspace_id=rel.workspace_id AND mr.merge_request_id=rel.merge_request_id WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 AND mr.state='open')",params![i.auth.workspace_id,i.ticket_id],|r|r.get(0))?;
|
||||||
if conflict {
|
if conflict {
|
||||||
return Err(MergeRequestError::Conflict(
|
return Err(MergeRequestError::Conflict(
|
||||||
"Ticket already has an open Merge Request; use ShowMergeRequest and advance the existing selector_from with a normal non-force push instead of opening a replacement Merge Request or adding a revision".into(),
|
"Ticket already has an open Merge Request".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let now = i.now.to_rfc3339();
|
let now = i.now.to_rfc3339();
|
||||||
@@ -540,34 +535,6 @@ impl MergeRequestStore {
|
|||||||
t.commit()?;
|
t.commit()?;
|
||||||
Ok(RequestedMergeRequestReview { request_event: e })
|
Ok(RequestedMergeRequestReview { request_event: e })
|
||||||
}
|
}
|
||||||
pub fn authorize_review_submission(
|
|
||||||
&self,
|
|
||||||
ticket_id: &str,
|
|
||||||
capability_token: &str,
|
|
||||||
) -> Result<ReviewSubmissionAuthorization, MergeRequestError> {
|
|
||||||
let connection = self.lock()?;
|
|
||||||
connection
|
|
||||||
.query_row(
|
|
||||||
"SELECT g.workspace_id,g.subject_ref
|
|
||||||
FROM merge_request_review_grants g
|
|
||||||
JOIN merge_request_ticket_relations rel
|
|
||||||
ON rel.workspace_id=g.workspace_id AND rel.merge_request_id=g.merge_request_id
|
|
||||||
JOIN merge_requests mr
|
|
||||||
ON mr.workspace_id=g.workspace_id AND mr.merge_request_id=g.merge_request_id
|
|
||||||
WHERE g.capability_token=?1 AND rel.ticket_id=?2
|
|
||||||
AND g.status='issued' AND mr.state='open'",
|
|
||||||
params![capability_token, ticket_id],
|
|
||||||
|row| {
|
|
||||||
Ok(ReviewSubmissionAuthorization {
|
|
||||||
workspace_id: row.get(0)?,
|
|
||||||
subject_ref: row.get(1)?,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.optional()?
|
|
||||||
.ok_or_else(|| MergeRequestError::Unauthorized("review grant invalid".into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn submit_review(
|
pub fn submit_review(
|
||||||
&self,
|
&self,
|
||||||
i: SubmitMergeRequestReview,
|
i: SubmitMergeRequestReview,
|
||||||
@@ -608,16 +575,12 @@ impl MergeRequestStore {
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
if subject != i.current_subject_ref {
|
if subject != i.current_subject_ref {
|
||||||
let reason = format!(
|
|
||||||
"selector_from moved from requested subject {subject} to current subject {}; fresh review of the exact current source ref is required",
|
|
||||||
i.current_subject_ref
|
|
||||||
);
|
|
||||||
let e = ReviewCancelledEvent {
|
let e = ReviewCancelledEvent {
|
||||||
event_id: Uuid::now_v7().to_string(),
|
event_id: Uuid::now_v7().to_string(),
|
||||||
sequence: next_seq(&t, &ws, &mr)?,
|
sequence: next_seq(&t, &ws, &mr)?,
|
||||||
request_event_id: req,
|
request_event_id: req,
|
||||||
subject_ref: subject,
|
subject_ref: subject,
|
||||||
reason,
|
reason: "selector_from moved before submission".into(),
|
||||||
created_at: i.now,
|
created_at: i.now,
|
||||||
};
|
};
|
||||||
insert_event(&t, &ws, &mr, "review_cancelled", &e, i.now, None)?;
|
insert_event(&t, &ws, &mr, "review_cancelled", &e, i.now, None)?;
|
||||||
@@ -704,27 +667,9 @@ impl MergeRequestStore {
|
|||||||
}
|
}
|
||||||
match (&i.current_subject_ref, &review) {
|
match (&i.current_subject_ref, &review) {
|
||||||
(None, _) => b.push("selector_from could not be resolved".into()),
|
(None, _) => b.push("selector_from could not be resolved".into()),
|
||||||
(Some(subject_ref), None) => {
|
(Some(_), None) => b.push("current source ref has no valid review".into()),
|
||||||
let previous_review_subject = mr.thread.iter().rev().find_map(|event| match event {
|
(_, Some(r)) if r.decision == ReviewDecision::RequestChanges => {
|
||||||
MergeRequestThreadEvent::ReviewRequested(value) => {
|
b.push("current source ref requests changes".into())
|
||||||
Some(value.subject_ref.as_str())
|
|
||||||
}
|
|
||||||
MergeRequestThreadEvent::Review(value) => Some(value.subject_ref.as_str()),
|
|
||||||
_ => None,
|
|
||||||
});
|
|
||||||
match previous_review_subject.filter(|previous| *previous != subject_ref) {
|
|
||||||
Some(previous) => b.push(format!(
|
|
||||||
"selector_from moved from reviewed/requested subject {previous} to current subject {subject_ref}; request a fresh review for this exact source ref (selector_to movement alone does not invalidate source approval)"
|
|
||||||
)),
|
|
||||||
None => b.push(format!(
|
|
||||||
"current source ref {subject_ref} has no valid review; request a fresh review for this exact source ref"
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(Some(subject_ref), Some(r)) if r.decision == ReviewDecision::RequestChanges => {
|
|
||||||
b.push(format!(
|
|
||||||
"current source ref {subject_ref} requests changes; advance the existing selector_from with a normal non-force push, then request a fresh review for the exact new source ref"
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -1354,9 +1299,14 @@ pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
|
|||||||
match schema_state(c)? {
|
match schema_state(c)? {
|
||||||
SchemaState::Fresh => fresh(c),
|
SchemaState::Fresh => fresh(c),
|
||||||
SchemaState::Current(SCHEMA_VERSION) => verify(c),
|
SchemaState::Current(SCHEMA_VERSION) => verify(c),
|
||||||
|
SchemaState::Current(PREVIOUS_SCHEMA_VERSION) => from_v11(c, PreviousSchemaMarker::Current),
|
||||||
|
SchemaState::Legacy(PREVIOUS_SCHEMA_VERSION) => from_v11(c, PreviousSchemaMarker::Legacy),
|
||||||
SchemaState::Current(v) => Err(MergeRequestError::Operation(format!(
|
SchemaState::Current(v) => Err(MergeRequestError::Operation(format!(
|
||||||
"unsupported schema {v}"
|
"unsupported schema {v}"
|
||||||
))),
|
))),
|
||||||
|
SchemaState::Legacy(v) => Err(MergeRequestError::Operation(format!(
|
||||||
|
"unsupported legacy schema {v}"
|
||||||
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1364,14 +1314,26 @@ pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> {
|
|||||||
enum SchemaState {
|
enum SchemaState {
|
||||||
Fresh,
|
Fresh,
|
||||||
Current(i64),
|
Current(i64),
|
||||||
|
Legacy(i64),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum PreviousSchemaMarker {
|
||||||
|
Current,
|
||||||
|
Legacy,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn schema_state(c: &Connection) -> Result<SchemaState, MergeRequestError> {
|
fn schema_state(c: &Connection) -> Result<SchemaState, MergeRequestError> {
|
||||||
let current: bool = c.query_row(
|
let (current, legacy): (bool, bool) = c.query_row(
|
||||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema')",
|
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema'),EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema_migrations')",
|
||||||
[],
|
[],
|
||||||
|r| r.get(0),
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
)?;
|
)?;
|
||||||
|
if current && legacy {
|
||||||
|
return Err(MergeRequestError::Corrupt(
|
||||||
|
"both current and legacy schema markers exist".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
if current {
|
if current {
|
||||||
let (count, singleton, version): (i64, Option<i64>, Option<i64>) = c.query_row(
|
let (count, singleton, version): (i64, Option<i64>, Option<i64>) = c.query_row(
|
||||||
"SELECT COUNT(*),MIN(singleton),MAX(version) FROM merge_request_schema",
|
"SELECT COUNT(*),MIN(singleton),MAX(version) FROM merge_request_schema",
|
||||||
@@ -1388,6 +1350,22 @@ fn schema_state(c: &Connection) -> Result<SchemaState, MergeRequestError> {
|
|||||||
})?;
|
})?;
|
||||||
return Ok(SchemaState::Current(version));
|
return Ok(SchemaState::Current(version));
|
||||||
}
|
}
|
||||||
|
if legacy {
|
||||||
|
let (count, version): (i64, Option<i64>) = c.query_row(
|
||||||
|
"SELECT COUNT(*),MAX(version) FROM merge_request_schema_migrations",
|
||||||
|
[],
|
||||||
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
|
)?;
|
||||||
|
if count != 1 {
|
||||||
|
return Err(MergeRequestError::Corrupt(
|
||||||
|
"legacy schema marker must contain exactly one version".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let version = version.ok_or_else(|| {
|
||||||
|
MergeRequestError::Corrupt("legacy schema marker version is null".into())
|
||||||
|
})?;
|
||||||
|
return Ok(SchemaState::Legacy(version));
|
||||||
|
}
|
||||||
let domain_tables: bool = c.query_row(
|
let domain_tables: bool = c.query_row(
|
||||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name GLOB 'merge_request*')",
|
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name GLOB 'merge_request*')",
|
||||||
[],
|
[],
|
||||||
@@ -1402,20 +1380,214 @@ fn schema_state(c: &Connection) -> Result<SchemaState, MergeRequestError> {
|
|||||||
}
|
}
|
||||||
fn fresh(c: &Connection) -> Result<(), MergeRequestError> {
|
fn fresh(c: &Connection) -> Result<(), MergeRequestError> {
|
||||||
let t = c.unchecked_transaction()?;
|
let t = c.unchecked_transaction()?;
|
||||||
tables(&t)?;
|
tables(&t, true)?;
|
||||||
t.execute(
|
t.execute("INSERT INTO merge_request_schema VALUES(1,12)", [])?;
|
||||||
"INSERT INTO merge_request_schema VALUES(1,?1)",
|
|
||||||
params![SCHEMA_VERSION],
|
|
||||||
)?;
|
|
||||||
fk(&t)?;
|
fk(&t)?;
|
||||||
t.commit()?;
|
t.commit()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn tables(t: &Transaction<'_>) -> Result<(), MergeRequestError> {
|
fn tables(t: &Transaction<'_>, marker: bool) -> Result<(), MergeRequestError> {
|
||||||
t.execute_batch("CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);")?;
|
if marker {
|
||||||
|
t.execute_batch("CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);")?
|
||||||
|
}
|
||||||
t.execute_batch("CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,repository_id TEXT NOT NULL,state TEXT NOT NULL CHECK(state IN('open','merged','closed')),selector_from TEXT,selector_to TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id),FOREIGN KEY(workspace_id,repository_id)REFERENCES repositories(workspace_id,repository_id));CREATE TABLE merge_request_ticket_relations(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,ticket_id TEXT NOT NULL,relation_kind TEXT NOT NULL CHECK(relation_kind='implements'),created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,ticket_id),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE,FOREIGN KEY(workspace_id,ticket_id)REFERENCES typed_tickets(workspace_id,ticket_id)ON DELETE CASCADE);CREATE TABLE merge_request_thread_events(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,event_id TEXT NOT NULL,sequence INTEGER NOT NULL,kind TEXT NOT NULL CHECK(kind IN('review_requested','review','review_revoked','review_cancelled','comment','merge')),payload_json TEXT NOT NULL,operation_id TEXT,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,event_id),UNIQUE(workspace_id,merge_request_id,sequence),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE);CREATE UNIQUE INDEX merge_request_merge_operations ON merge_request_thread_events(workspace_id,operation_id)WHERE operation_id IS NOT NULL;CREATE TABLE merge_request_review_grants(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,request_event_id TEXT NOT NULL,subject_ref TEXT NOT NULL,reviewer_runtime_id TEXT NOT NULL,reviewer_worker_id TEXT NOT NULL,capability_token TEXT PRIMARY KEY,issued_at TEXT NOT NULL,consumed_at TEXT,revoked_at TEXT,status TEXT NOT NULL CHECK(status IN('issued','consumed','revoked')),FOREIGN KEY(workspace_id,merge_request_id,request_event_id)REFERENCES merge_request_thread_events(workspace_id,merge_request_id,event_id)ON DELETE CASCADE);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT NOT NULL,child_session_id TEXT NOT NULL,parent_runtime_id TEXT NOT NULL,parent_worker_id TEXT NOT NULL,reviewer_profile TEXT NOT NULL,registered_at TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN('active','consumed')),PRIMARY KEY(workspace_id,child_session_id));")?;
|
t.execute_batch("CREATE TABLE merge_requests(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,repository_id TEXT NOT NULL,state TEXT NOT NULL CHECK(state IN('open','merged','closed')),selector_from TEXT,selector_to TEXT NOT NULL,created_at TEXT NOT NULL,updated_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id),FOREIGN KEY(workspace_id,repository_id)REFERENCES repositories(workspace_id,repository_id));CREATE TABLE merge_request_ticket_relations(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,ticket_id TEXT NOT NULL,relation_kind TEXT NOT NULL CHECK(relation_kind='implements'),created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,ticket_id),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE,FOREIGN KEY(workspace_id,ticket_id)REFERENCES typed_tickets(workspace_id,ticket_id)ON DELETE CASCADE);CREATE TABLE merge_request_thread_events(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,event_id TEXT NOT NULL,sequence INTEGER NOT NULL,kind TEXT NOT NULL CHECK(kind IN('review_requested','review','review_revoked','review_cancelled','comment','merge')),payload_json TEXT NOT NULL,operation_id TEXT,created_at TEXT NOT NULL,PRIMARY KEY(workspace_id,merge_request_id,event_id),UNIQUE(workspace_id,merge_request_id,sequence),FOREIGN KEY(workspace_id,merge_request_id)REFERENCES merge_requests(workspace_id,merge_request_id)ON DELETE CASCADE);CREATE UNIQUE INDEX merge_request_merge_operations ON merge_request_thread_events(workspace_id,operation_id)WHERE operation_id IS NOT NULL;CREATE TABLE merge_request_review_grants(workspace_id TEXT NOT NULL,merge_request_id TEXT NOT NULL,request_event_id TEXT NOT NULL,subject_ref TEXT NOT NULL,reviewer_runtime_id TEXT NOT NULL,reviewer_worker_id TEXT NOT NULL,capability_token TEXT PRIMARY KEY,issued_at TEXT NOT NULL,consumed_at TEXT,revoked_at TEXT,status TEXT NOT NULL CHECK(status IN('issued','consumed','revoked')),FOREIGN KEY(workspace_id,merge_request_id,request_event_id)REFERENCES merge_request_thread_events(workspace_id,merge_request_id,event_id)ON DELETE CASCADE);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT NOT NULL,child_session_id TEXT NOT NULL,parent_runtime_id TEXT NOT NULL,parent_worker_id TEXT NOT NULL,reviewer_profile TEXT NOT NULL,registered_at TEXT NOT NULL,status TEXT NOT NULL CHECK(status IN('active','consumed')),PRIMARY KEY(workspace_id,child_session_id));")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
fn from_v11(
|
||||||
|
c: &Connection,
|
||||||
|
previous_marker: PreviousSchemaMarker,
|
||||||
|
) -> Result<(), MergeRequestError> {
|
||||||
|
let t = c.unchecked_transaction()?;
|
||||||
|
if previous_marker == PreviousSchemaMarker::Legacy {
|
||||||
|
t.execute_batch("CREATE TABLE merge_request_schema(singleton INTEGER PRIMARY KEY CHECK(singleton=1),version INTEGER NOT NULL);")?;
|
||||||
|
t.execute(
|
||||||
|
"INSERT INTO merge_request_schema VALUES(1,?1)",
|
||||||
|
params![PREVIOUS_SCHEMA_VERSION],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
t.execute_batch("ALTER TABLE merge_requests RENAME TO merge_requests_v11;ALTER TABLE merge_request_ticket_relations RENAME TO merge_request_ticket_relations_v11;ALTER TABLE merge_request_revisions RENAME TO merge_request_revisions_v11;ALTER TABLE merge_request_revision_paths RENAME TO merge_request_revision_paths_v11;ALTER TABLE merge_request_reviewer_child_sessions RENAME TO merge_request_reviewer_child_sessions_v11;ALTER TABLE merge_request_review_attempts RENAME TO merge_request_review_attempts_v11;ALTER TABLE merge_request_reviews RENAME TO merge_request_reviews_v11;ALTER TABLE merge_request_review_findings RENAME TO merge_request_review_findings_v11;ALTER TABLE merge_request_completion_operations RENAME TO merge_request_completion_operations_v11;")?;
|
||||||
|
tables(&t, false)?;
|
||||||
|
t.execute("INSERT INTO merge_requests SELECT workspace_id,merge_request_id,repository_id,CASE state WHEN 'draft'THEN'open'ELSE state END,NULL,target_ref_selector,created_at,updated_at FROM merge_requests_v11",[])?;
|
||||||
|
t.execute("INSERT INTO merge_request_ticket_relations SELECT * FROM merge_request_ticket_relations_v11",[])?;
|
||||||
|
migrate_events(&t)?;
|
||||||
|
if previous_marker == PreviousSchemaMarker::Legacy {
|
||||||
|
t.execute("DROP TABLE merge_request_schema_migrations", [])?;
|
||||||
|
}
|
||||||
|
t.execute_batch("DROP TABLE merge_request_review_findings_v11;DROP TABLE merge_request_reviews_v11;DROP TABLE merge_request_review_attempts_v11;DROP TABLE merge_request_reviewer_child_sessions_v11;DROP TABLE merge_request_revision_paths_v11;DROP TABLE merge_request_revisions_v11;DROP TABLE merge_request_completion_operations_v11;DROP TABLE merge_request_ticket_relations_v11;DROP TABLE merge_requests_v11;UPDATE merge_request_schema SET version=12 WHERE singleton=1;")?;
|
||||||
|
fk(&t)?;
|
||||||
|
t.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn migrate_events(t: &Transaction<'_>) -> Result<(), MergeRequestError> {
|
||||||
|
let attempts = {
|
||||||
|
let mut s=t.prepare("SELECT a.workspace_id,a.attempt_id,a.merge_request_id,a.parent_runtime_id,a.parent_worker_id,a.child_session_id,a.status,a.created_at,a.consumed_at,r.head_commit FROM merge_request_review_attempts_v11 a JOIN merge_request_revisions_v11 r ON r.workspace_id=a.workspace_id AND r.merge_request_id=a.merge_request_id AND r.revision_id=a.revision_id ORDER BY a.created_at")?;
|
||||||
|
s.query_map([], |r| {
|
||||||
|
Ok((
|
||||||
|
r.get::<_, String>(0)?,
|
||||||
|
r.get::<_, String>(1)?,
|
||||||
|
r.get::<_, String>(2)?,
|
||||||
|
r.get::<_, String>(3)?,
|
||||||
|
r.get::<_, String>(4)?,
|
||||||
|
r.get::<_, String>(5)?,
|
||||||
|
r.get::<_, String>(6)?,
|
||||||
|
r.get::<_, String>(7)?,
|
||||||
|
r.get::<_, Option<String>>(8)?,
|
||||||
|
r.get::<_, String>(9)?,
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
};
|
||||||
|
for (ws, a, mr, pr, pw, child, status, created, consumed, subject) in attempts {
|
||||||
|
let req = ReviewRequestedEvent {
|
||||||
|
event_id: format!("migrated-request-{a}"),
|
||||||
|
sequence: next_seq(t, &ws, &mr)?,
|
||||||
|
subject_ref: subject.clone(),
|
||||||
|
requested_by: WorkerIdentity {
|
||||||
|
runtime_id: pr.clone(),
|
||||||
|
worker_id: pw,
|
||||||
|
},
|
||||||
|
reviewer: WorkerIdentity {
|
||||||
|
runtime_id: pr,
|
||||||
|
worker_id: child,
|
||||||
|
},
|
||||||
|
created_at: time(&created)?,
|
||||||
|
};
|
||||||
|
insert_event(t, &ws, &mr, "review_requested", &req, req.created_at, None)?;
|
||||||
|
if status == "submitted" {
|
||||||
|
let(row_dec,row_body,row_at):(String,String,String)=t.query_row("SELECT decision,body,submitted_at FROM merge_request_reviews_v11 WHERE workspace_id=?1 AND attempt_id=?2",params![ws,a],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?;
|
||||||
|
let findings = {
|
||||||
|
let mut s=t.prepare("SELECT severity,code,path,line,body FROM merge_request_review_findings_v11 WHERE workspace_id=?1 AND attempt_id=?2 ORDER BY ordinal")?;
|
||||||
|
s.query_map(params![ws, a], |r| {
|
||||||
|
Ok(ReviewFinding {
|
||||||
|
severity: match r.get::<_, String>(0)?.as_str() {
|
||||||
|
"blocker" => FindingSeverity::Blocker,
|
||||||
|
"major" => FindingSeverity::Major,
|
||||||
|
"minor" => FindingSeverity::Minor,
|
||||||
|
_ => FindingSeverity::Note,
|
||||||
|
},
|
||||||
|
code: r.get(1)?,
|
||||||
|
path: r.get(2)?,
|
||||||
|
line: r.get(3)?,
|
||||||
|
body: r.get(4)?,
|
||||||
|
})
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
};
|
||||||
|
let rev = ReviewEvent {
|
||||||
|
event_id: format!("migrated-review-{a}"),
|
||||||
|
sequence: next_seq(t, &ws, &mr)?,
|
||||||
|
request_event_id: req.event_id,
|
||||||
|
subject_ref: subject,
|
||||||
|
decision: if row_dec == "approve" {
|
||||||
|
ReviewDecision::Approve
|
||||||
|
} else {
|
||||||
|
ReviewDecision::RequestChanges
|
||||||
|
},
|
||||||
|
body: row_body,
|
||||||
|
findings,
|
||||||
|
reviewer: req.reviewer,
|
||||||
|
created_at: time(&row_at)?,
|
||||||
|
};
|
||||||
|
insert_event(t, &ws, &mr, "review", &rev, rev.created_at, None)?
|
||||||
|
} else {
|
||||||
|
let at = consumed.as_deref().unwrap_or(&created);
|
||||||
|
let e = ReviewCancelledEvent {
|
||||||
|
event_id: format!("migrated-cancel-{a}"),
|
||||||
|
sequence: next_seq(t, &ws, &mr)?,
|
||||||
|
request_event_id: req.event_id,
|
||||||
|
subject_ref: subject,
|
||||||
|
reason: format!(
|
||||||
|
"legacy `{status}` review request cancelled because its capability cannot be migrated"
|
||||||
|
),
|
||||||
|
created_at: time(at)?,
|
||||||
|
};
|
||||||
|
insert_event(t, &ws, &mr, "review_cancelled", &e, e.created_at, None)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let completed = {
|
||||||
|
let mut q=t.prepare("SELECT c.workspace_id,c.operation_id,c.ticket_id,c.target_commit,c.source_commit,c.result_commit,c.strategy,c.resolution,c.completion_actor_runtime_id,c.completion_actor_worker_id,c.updated_at,rel.merge_request_id FROM merge_request_completion_operations_v11 c JOIN merge_request_ticket_relations_v11 rel ON rel.workspace_id=c.workspace_id AND rel.ticket_id=c.ticket_id WHERE c.status='completed' ORDER BY c.updated_at")?;
|
||||||
|
q.query_map([], |r| {
|
||||||
|
Ok((
|
||||||
|
r.get::<_, String>(0)?,
|
||||||
|
r.get::<_, String>(1)?,
|
||||||
|
r.get::<_, String>(2)?,
|
||||||
|
r.get::<_, Option<String>>(3)?,
|
||||||
|
r.get::<_, Option<String>>(4)?,
|
||||||
|
r.get::<_, Option<String>>(5)?,
|
||||||
|
r.get::<_, Option<String>>(6)?,
|
||||||
|
r.get::<_, Option<String>>(7)?,
|
||||||
|
r.get::<_, Option<String>>(8)?,
|
||||||
|
r.get::<_, Option<String>>(9)?,
|
||||||
|
r.get::<_, String>(10)?,
|
||||||
|
r.get::<_, String>(11)?,
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?
|
||||||
|
};
|
||||||
|
for (
|
||||||
|
ws,
|
||||||
|
op,
|
||||||
|
_ticket,
|
||||||
|
target,
|
||||||
|
source,
|
||||||
|
result,
|
||||||
|
strategy,
|
||||||
|
resolution,
|
||||||
|
runtime,
|
||||||
|
worker,
|
||||||
|
updated,
|
||||||
|
mr,
|
||||||
|
) in completed
|
||||||
|
{
|
||||||
|
let subject = source.ok_or_else(|| {
|
||||||
|
MergeRequestError::Operation(format!("completed operation {op} lacks source evidence"))
|
||||||
|
})?;
|
||||||
|
let approval:Option<String>=t.query_row("SELECT event_id FROM merge_request_thread_events WHERE workspace_id=?1 AND merge_request_id=?2 AND kind='review' AND json_extract(payload_json,'$.subject_ref')=?3 AND json_extract(payload_json,'$.decision')='approve' ORDER BY sequence DESC LIMIT 1",params![ws,mr,subject],|r|r.get(0)).optional()?;
|
||||||
|
let approval = approval.ok_or_else(|| {
|
||||||
|
MergeRequestError::Operation(format!(
|
||||||
|
"completed operation {op} lacks approval evidence"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let e = MergeEvent {
|
||||||
|
event_id: format!("migrated-merge-{op}"),
|
||||||
|
sequence: next_seq(t, &ws, &mr)?,
|
||||||
|
operation_id: op,
|
||||||
|
approval_event_id: approval,
|
||||||
|
approved_source_ref: subject,
|
||||||
|
target_ref_before: target.ok_or_else(|| {
|
||||||
|
MergeRequestError::Operation("completed operation lacks target evidence".into())
|
||||||
|
})?,
|
||||||
|
target_ref_after: result.ok_or_else(|| {
|
||||||
|
MergeRequestError::Operation("completed operation lacks result evidence".into())
|
||||||
|
})?,
|
||||||
|
strategy: if strategy.as_deref() == Some("merge") {
|
||||||
|
MergeStrategy::Merge
|
||||||
|
} else {
|
||||||
|
MergeStrategy::FastForward
|
||||||
|
},
|
||||||
|
resolution: match resolution.as_deref() {
|
||||||
|
Some("clean") => ConflictResolution::Clean,
|
||||||
|
Some("conflicts_resolved") => ConflictResolution::ConflictsResolved,
|
||||||
|
_ => ConflictResolution::None,
|
||||||
|
},
|
||||||
|
merged_by: WorkerIdentity {
|
||||||
|
runtime_id: runtime.unwrap_or_else(|| "legacy".into()),
|
||||||
|
worker_id: worker.unwrap_or_else(|| "legacy".into()),
|
||||||
|
},
|
||||||
|
created_at: time(&updated)?,
|
||||||
|
};
|
||||||
|
insert_event(
|
||||||
|
t,
|
||||||
|
&ws,
|
||||||
|
&mr,
|
||||||
|
"merge",
|
||||||
|
&e,
|
||||||
|
e.created_at,
|
||||||
|
Some(&e.operation_id),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
fn verify(c: &Connection) -> Result<(), MergeRequestError> {
|
fn verify(c: &Connection) -> Result<(), MergeRequestError> {
|
||||||
for n in DOMAIN_TABLES {
|
for n in DOMAIN_TABLES {
|
||||||
let e: bool = c.query_row(
|
let e: bool = c.query_row(
|
||||||
|
|||||||
@@ -91,23 +91,6 @@ fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
|
|||||||
})
|
})
|
||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
#[test]
|
|
||||||
fn review_submission_authorization_rejects_invalid_grants_before_side_effects() {
|
|
||||||
let (_d, store) = fixture();
|
|
||||||
open(&store);
|
|
||||||
request(&store, "published-source", "valid-token");
|
|
||||||
|
|
||||||
let invalid = store
|
|
||||||
.authorize_review_submission("T", "invalid-token")
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(invalid, MergeRequestError::Unauthorized(_)));
|
|
||||||
let authorized = store
|
|
||||||
.authorize_review_submission("T", "valid-token")
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(authorized.workspace_id, "W");
|
|
||||||
assert_eq!(authorized.subject_ref, "published-source");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
fn selectors_thread_and_completion_have_no_revision_or_commit_api() {
|
||||||
let (d, s) = fixture();
|
let (d, s) = fixture();
|
||||||
@@ -198,85 +181,16 @@ fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_retur
|
|||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
let mr = s.get("W", "T").unwrap();
|
let mr = s.get("W", "T").unwrap();
|
||||||
let cancellation = mr.thread.iter().find_map(|event| match event {
|
|
||||||
MergeRequestThreadEvent::ReviewCancelled(value) => Some(value),
|
|
||||||
_ => None,
|
|
||||||
});
|
|
||||||
assert!(
|
assert!(
|
||||||
cancellation
|
mr.thread
|
||||||
.as_ref()
|
.iter()
|
||||||
.is_some_and(|value| value.reason.contains("selector_from moved")
|
.any(|e| matches!(e, MergeRequestThreadEvent::ReviewCancelled(_)))
|
||||||
&& value.reason.contains("fresh review"))
|
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
mr.effective_review("source-a").map(|r| &r.event_id),
|
mr.effective_review("source-a").map(|r| &r.event_id),
|
||||||
Some(&approved.event_id)
|
Some(&approved.event_id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
|
||||||
fn same_selector_source_advancement_requires_fresh_review_and_preserves_target_only_approval() {
|
|
||||||
let (_d, s) = fixture();
|
|
||||||
open(&s);
|
|
||||||
let first = approve(&s, "source-1", "one");
|
|
||||||
|
|
||||||
let stale = s
|
|
||||||
.readiness(ReadinessCheck {
|
|
||||||
ticket_id: "T".into(),
|
|
||||||
current_subject_ref: Some("source-2".into()),
|
|
||||||
auth: auth(),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert!(!stale.ready);
|
|
||||||
assert!(stale.review.is_none());
|
|
||||||
assert!(stale.blockers.iter().any(|blocker| {
|
|
||||||
blocker.contains("selector_from moved from reviewed/requested subject source-1")
|
|
||||||
&& blocker.contains("current subject source-2")
|
|
||||||
&& blocker.contains("fresh review")
|
|
||||||
}));
|
|
||||||
assert_eq!(
|
|
||||||
s.get("W", "T")
|
|
||||||
.unwrap()
|
|
||||||
.effective_review("source-1")
|
|
||||||
.map(|review| &review.event_id),
|
|
||||||
Some(&first.event_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
let second = approve(&s, "source-2", "two");
|
|
||||||
let ready = s
|
|
||||||
.readiness(ReadinessCheck {
|
|
||||||
ticket_id: "T".into(),
|
|
||||||
current_subject_ref: Some("source-2".into()),
|
|
||||||
auth: auth(),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert!(ready.ready);
|
|
||||||
assert_eq!(
|
|
||||||
ready.review.as_ref().map(|review| &review.event_id),
|
|
||||||
Some(&second.event_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
// The target can move from target-1 to target-2 without changing selector_from
|
|
||||||
// or invalidating the exact-source approval. Completion consumes refreshed
|
|
||||||
// integration evidence for the current target pair.
|
|
||||||
let merged = s
|
|
||||||
.complete(CompleteMergeRequest {
|
|
||||||
operation_id: "target-moved".into(),
|
|
||||||
ticket_id: "T".into(),
|
|
||||||
current_subject_ref: "source-2".into(),
|
|
||||||
target_ref_before: "target-2".into(),
|
|
||||||
target_ref_after: "integrated-target-2".into(),
|
|
||||||
approval_event_id: second.event_id,
|
|
||||||
strategy: MergeStrategy::FastForward,
|
|
||||||
resolution: ConflictResolution::None,
|
|
||||||
auth: auth(),
|
|
||||||
now: at(5),
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(merged.approved_source_ref, "source-2");
|
|
||||||
assert_eq!(merged.target_ref_before, "target-2");
|
|
||||||
assert_eq!(merged.target_ref_after, "integrated-target-2");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn review_revocation_invalidates_readiness() {
|
fn review_revocation_invalidates_readiness() {
|
||||||
let (_d, s) = fixture();
|
let (_d, s) = fixture();
|
||||||
@@ -301,13 +215,21 @@ fn review_revocation_invalidates_readiness() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fresh_schema_uses_version_12_and_reopens_as_current() {
|
fn legacy_v11_migration_preserves_review_events_and_replaces_marker() {
|
||||||
let c = Connection::open_in_memory().unwrap();
|
let c = Connection::open_in_memory().unwrap();
|
||||||
|
c.execute_batch("CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));INSERT INTO repositories VALUES('W','R');INSERT INTO typed_tickets VALUES('W','T');CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);INSERT INTO merge_request_schema_migrations(version) VALUES(11);CREATE TABLE merge_requests(workspace_id TEXT,merge_request_id TEXT,repository_id TEXT,state TEXT,target_ref_selector TEXT,current_revision_ordinal INTEGER,current_revision_id TEXT,created_at TEXT,updated_at TEXT,merged_revision_id TEXT,merged_at TEXT);CREATE TABLE merge_request_ticket_relations(workspace_id TEXT,merge_request_id TEXT,ticket_id TEXT,relation_kind TEXT,created_at TEXT);CREATE TABLE merge_request_revisions(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,base_commit TEXT,head_commit TEXT,diff_digest TEXT,summary TEXT,assignment_id TEXT,created_at TEXT);CREATE TABLE merge_request_revision_paths(workspace_id TEXT,merge_request_id TEXT,revision_id TEXT,ordinal INTEGER,path TEXT);CREATE TABLE merge_request_reviewer_child_sessions(workspace_id TEXT,child_session_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,reviewer_profile TEXT,registered_at TEXT);CREATE TABLE merge_request_review_attempts(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,ticket_id TEXT,revision_id TEXT,revision_ordinal INTEGER,parent_assignment_id TEXT,parent_runtime_id TEXT,parent_worker_id TEXT,child_session_id TEXT,reviewer_effective_profile TEXT,capability_token TEXT,status TEXT,created_at TEXT,consumed_at TEXT);CREATE TABLE merge_request_reviews(workspace_id TEXT,attempt_id TEXT,merge_request_id TEXT,revision_id TEXT,decision TEXT,body TEXT,submitted_at TEXT);CREATE TABLE merge_request_review_findings(workspace_id TEXT,attempt_id TEXT,ordinal INTEGER,severity TEXT,code TEXT,path TEXT,line INTEGER,body TEXT);CREATE TABLE merge_request_completion_operations(workspace_id TEXT,operation_id TEXT,ticket_id TEXT,revision_id TEXT,authority_kind TEXT,implementation_assignment_id TEXT,completion_actor_runtime_id TEXT,completion_actor_worker_id TEXT,target_commit TEXT,source_commit TEXT,result_commit TEXT,strategy TEXT,resolution TEXT,fingerprint TEXT,status TEXT,result_ticket_state TEXT,created_at TEXT,updated_at TEXT);INSERT INTO merge_requests VALUES('W','MR','R','open','develop',1,'V','2026-07-26T12:00:00Z','2026-07-26T12:00:00Z',NULL,NULL);INSERT INTO merge_request_ticket_relations VALUES('W','MR','T','implements','2026-07-26T12:00:00Z');INSERT INTO merge_request_revisions VALUES('W','MR','V',1,'base','subject','digest','summary','A','2026-07-26T12:00:00Z');INSERT INTO merge_request_review_attempts VALUES('W','AT','MR','T','V',1,'A','runtime','coder','child','builtin:reviewer','token','submitted','2026-07-26T12:00:00Z','2026-07-26T12:00:01Z');INSERT INTO merge_request_reviews VALUES('W','AT','MR','V','approve','approved','2026-07-26T12:00:01Z');INSERT INTO merge_request_review_attempts VALUES('W','PENDING','MR','T','V',1,'A','runtime','coder','pending-child','builtin:reviewer','pending-token','registered','2026-07-26T12:00:02Z',NULL);").unwrap();
|
||||||
c.execute_batch(
|
c.execute_batch(
|
||||||
"CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));",
|
"CREATE TABLE unrelated_parent(left_id TEXT,right_id TEXT,PRIMARY KEY(left_id,right_id));CREATE TABLE unrelated_child(left_id TEXT REFERENCES unrelated_parent(left_id));",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let unrelated_mismatch = c
|
||||||
|
.query_row("PRAGMA foreign_key_check", [], |_| Ok(()))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
unrelated_mismatch
|
||||||
|
.to_string()
|
||||||
|
.contains("foreign key mismatch")
|
||||||
|
);
|
||||||
merge_request::migrate(&c).unwrap();
|
merge_request::migrate(&c).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
c.query_row("SELECT version FROM merge_request_schema", [], |r| {
|
c.query_row("SELECT version FROM merge_request_schema", [], |r| {
|
||||||
@@ -316,26 +238,66 @@ fn fresh_schema_uses_version_12_and_reopens_as_current() {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
12
|
12
|
||||||
);
|
);
|
||||||
merge_request::migrate(&c).unwrap();
|
let legacy_marker: bool = c
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema_migrations')",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!legacy_marker);
|
||||||
|
let selector: Option<String> = c
|
||||||
|
.query_row("SELECT selector_from FROM merge_requests", [], |r| r.get(0))
|
||||||
|
.unwrap();
|
||||||
|
assert!(selector.is_none());
|
||||||
|
let kinds: String = c
|
||||||
|
.query_row(
|
||||||
|
"SELECT group_concat(kind,',') FROM merge_request_thread_events ORDER BY sequence",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
kinds,
|
||||||
|
"review_requested,review,review_requested,review_cancelled"
|
||||||
|
);
|
||||||
|
let old: bool = c
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='merge_request_revisions')",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!old);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn current_schema_validation_rejects_missing_tables() {
|
fn failed_legacy_v11_migration_rolls_back_marker_bridge() {
|
||||||
let c = Connection::open_in_memory().unwrap();
|
let c = Connection::open_in_memory().unwrap();
|
||||||
c.execute_batch(
|
c.execute_batch(
|
||||||
"CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,PRIMARY KEY(workspace_id,ticket_id));",
|
"CREATE TABLE merge_request_schema_migrations(version INTEGER PRIMARY KEY,applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);INSERT INTO merge_request_schema_migrations(version) VALUES(11);CREATE TABLE merge_requests(merge_request_id TEXT);",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
merge_request::migrate(&c).unwrap();
|
|
||||||
c.execute_batch("DROP TABLE merge_request_review_grants;")
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let error = merge_request::migrate(&c).unwrap_err();
|
assert!(merge_request::migrate(&c).is_err());
|
||||||
assert!(matches!(
|
for table in ["merge_request_schema_migrations", "merge_requests"] {
|
||||||
error,
|
let exists: bool = c
|
||||||
MergeRequestError::Corrupt(message)
|
.query_row(
|
||||||
if message == "missing `merge_request_review_grants`"
|
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
|
||||||
));
|
[table],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(exists, "{table} was not rolled back");
|
||||||
|
}
|
||||||
|
let current_marker: bool = c
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='merge_request_schema')",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!current_marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ json-schema = ["dep:schemars"]
|
|||||||
schemars = { workspace = true, optional = true }
|
schemars = { workspace = true, optional = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
sha2.workspace = true
|
|
||||||
tokio = { workspace = true, features = ["io-util"], optional = true }
|
tokio = { workspace = true, features = ["io-util"], optional = true }
|
||||||
ts-rs = { version = "12.0.1", optional = true }
|
ts-rs = { version = "12.0.1", optional = true }
|
||||||
uuid = { workspace = true, features = ["serde", "v7"] }
|
uuid = { workspace = true, features = ["serde"] }
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
use std::{fmt, str::FromStr};
|
|
||||||
|
|
||||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use uuid::{Uuid, Version};
|
|
||||||
|
|
||||||
/// Stable Worker identity independent of its current Runtime placement or
|
|
||||||
/// conversation Session.
|
|
||||||
///
|
|
||||||
/// Workspace authority allocates this ID for managed Workers. A standalone
|
|
||||||
/// Worker store allocates it locally when no Workspace authority is present.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
||||||
pub struct WorkerId(Uuid);
|
|
||||||
|
|
||||||
impl WorkerId {
|
|
||||||
pub fn now_v7() -> Self {
|
|
||||||
Self(Uuid::now_v7())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Converts a legacy Runtime-local numeric id into a syntactically valid
|
|
||||||
/// migration-only UUIDv7 value. New Worker allocation must use `now_v7`.
|
|
||||||
pub fn from_legacy_u64(value: u64) -> Self {
|
|
||||||
let mut bytes = [0_u8; 16];
|
|
||||||
bytes[8..].copy_from_slice(&value.to_be_bytes());
|
|
||||||
bytes[6] = 0x70;
|
|
||||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
||||||
Self(Uuid::from_bytes(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_legacy_binding(workspace_id: &str, runtime_id: &str, value: u64) -> Self {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(b"yoi.workspace-worker-id.v1\0");
|
|
||||||
hasher.update(workspace_id.as_bytes());
|
|
||||||
hasher.update([0]);
|
|
||||||
hasher.update(runtime_id.as_bytes());
|
|
||||||
hasher.update([0]);
|
|
||||||
hasher.update(value.to_be_bytes());
|
|
||||||
let digest = hasher.finalize();
|
|
||||||
let mut bytes = [0_u8; 16];
|
|
||||||
bytes.copy_from_slice(&digest[..16]);
|
|
||||||
// Migrated ids sort before normally allocated UUIDv7 values while retaining
|
|
||||||
// deterministic collision-resistant payload bits.
|
|
||||||
bytes[..6].fill(0);
|
|
||||||
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
|
||||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
||||||
Self(Uuid::from_bytes(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse(value: &str) -> Option<Self> {
|
|
||||||
let value = Uuid::parse_str(value).ok()?;
|
|
||||||
(value.get_version() == Some(Version::SortRand)).then_some(Self(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn as_uuid(self) -> Uuid {
|
|
||||||
self.0
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn short(self) -> String {
|
|
||||||
let simple = self.0.simple().to_string();
|
|
||||||
simple[simple.len() - 12..].to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for WorkerId {
|
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
self.0.fmt(formatter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for WorkerId {
|
|
||||||
type Err = WorkerIdParseError;
|
|
||||||
|
|
||||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
||||||
Self::parse(value).ok_or(WorkerIdParseError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Serialize for WorkerId {
|
|
||||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
serializer.serialize_str(&self.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for WorkerId {
|
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let value = String::deserialize(deserializer)?;
|
|
||||||
Self::parse(&value).ok_or_else(|| de::Error::custom("Worker id must be a UUIDv7"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct WorkerIdParseError;
|
|
||||||
|
|
||||||
impl fmt::Display for WorkerIdParseError {
|
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
formatter.write_str("Worker id must be a UUIDv7")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for WorkerIdParseError {}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn worker_id_accepts_only_uuid_v7() {
|
|
||||||
let worker_id = WorkerId::now_v7();
|
|
||||||
assert_eq!(WorkerId::parse(&worker_id.to_string()), Some(worker_id));
|
|
||||||
assert!(WorkerId::parse("30").is_none());
|
|
||||||
assert!(WorkerId::parse(&Uuid::nil().to_string()).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_worker_id_mapping_is_stable() {
|
|
||||||
assert_eq!(
|
|
||||||
WorkerId::from_legacy_binding("workspace", "runtime", 42),
|
|
||||||
WorkerId::from_legacy_binding("workspace", "runtime", 42)
|
|
||||||
);
|
|
||||||
assert_ne!(
|
|
||||||
WorkerId::from_legacy_binding("workspace", "runtime", 42),
|
|
||||||
WorkerId::from_legacy_binding("workspace", "runtime", 43)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+135
-892
File diff suppressed because it is too large
Load Diff
@@ -170,23 +170,6 @@ fn validate_identifier(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_repository_key(value: &str) -> Result<(), SubscriptionValidationError> {
|
|
||||||
let bytes = value.as_bytes();
|
|
||||||
if bytes.is_empty()
|
|
||||||
|| bytes.len() > 64
|
|
||||||
|| bytes.first() == Some(&b'-')
|
|
||||||
|| bytes.last() == Some(&b'-')
|
|
||||||
|| !bytes
|
|
||||||
.iter()
|
|
||||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
|
|
||||||
{
|
|
||||||
return Err(SubscriptionValidationError::InvalidIdentifier {
|
|
||||||
field: "repository_key",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_rejection_message(message: &str) -> Result<(), SubscriptionValidationError> {
|
fn validate_rejection_message(message: &str) -> Result<(), SubscriptionValidationError> {
|
||||||
if message.is_empty() {
|
if message.is_empty() {
|
||||||
return Err(SubscriptionValidationError::EmptyRejectionMessage);
|
return Err(SubscriptionValidationError::EmptyRejectionMessage);
|
||||||
@@ -557,6 +540,7 @@ pub enum SubscriptionWorkerState {
|
|||||||
Running,
|
Running,
|
||||||
Paused,
|
Paused,
|
||||||
Stopped,
|
Stopped,
|
||||||
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -573,11 +557,6 @@ pub struct SubscriptionWorker {
|
|||||||
pub resource_key: Option<String>,
|
pub resource_key: Option<String>,
|
||||||
/// Producer-owned monotonic revision for this Worker subject.
|
/// Producer-owned monotonic revision for this Worker subject.
|
||||||
pub subject_revision: u64,
|
pub subject_revision: u64,
|
||||||
/// Latest revisioned foreground state observed from the Worker. This remains
|
|
||||||
/// absent until an authoritative Worker snapshot/event has been applied.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub worker_state: Option<crate::WorkerStateSnapshot>,
|
|
||||||
/// Runtime catalog lifecycle compatibility projection; not foreground-state authority.
|
|
||||||
pub state: SubscriptionWorkerState,
|
pub state: SubscriptionWorkerState,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub has_running_internal_workers: bool,
|
pub has_running_internal_workers: bool,
|
||||||
@@ -588,12 +567,7 @@ pub struct SubscriptionWorker {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub profile: Option<String>,
|
pub profile: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
#[cfg_attr(feature = "typescript", ts(skip))]
|
|
||||||
pub repository_id: Option<String>,
|
pub repository_id: Option<String>,
|
||||||
/// Workspace-facing Repository key. Runtime producers leave this unset and
|
|
||||||
/// Workspace Server projections replace `repository_id` with this field.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub repository_key: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub working_directory_id: Option<SubscriptionWorkdirId>,
|
pub working_directory_id: Option<SubscriptionWorkdirId>,
|
||||||
}
|
}
|
||||||
@@ -610,14 +584,6 @@ impl SubscriptionWorker {
|
|||||||
if let Some(repository_id) = &self.repository_id {
|
if let Some(repository_id) = &self.repository_id {
|
||||||
validate_identifier("repository_id", repository_id, MAX_RESOURCE_ID_BYTES)?;
|
validate_identifier("repository_id", repository_id, MAX_RESOURCE_ID_BYTES)?;
|
||||||
}
|
}
|
||||||
if let Some(repository_key) = &self.repository_key {
|
|
||||||
validate_repository_key(repository_key)?;
|
|
||||||
}
|
|
||||||
if self.repository_id.is_some() && self.repository_key.is_some() {
|
|
||||||
return Err(SubscriptionValidationError::InvalidIdentifier {
|
|
||||||
field: "repository_authority",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Some(working_directory_id) = &self.working_directory_id {
|
if let Some(working_directory_id) = &self.working_directory_id {
|
||||||
working_directory_id.validate()?;
|
working_directory_id.validate()?;
|
||||||
}
|
}
|
||||||
@@ -629,13 +595,7 @@ impl SubscriptionWorker {
|
|||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct SubscriptionWorkdir {
|
pub struct SubscriptionWorkdir {
|
||||||
pub working_directory_id: SubscriptionWorkdirId,
|
pub working_directory_id: SubscriptionWorkdirId,
|
||||||
/// Runtime-internal Repository id. Workspace-facing TypeScript contracts
|
pub repository_id: String,
|
||||||
/// omit this field and require `repository_key` from the Server projection.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
#[cfg_attr(feature = "typescript", ts(skip))]
|
|
||||||
pub repository_id: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub repository_key: Option<String>,
|
|
||||||
pub state: String,
|
pub state: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub primary_worker_id: Option<SubscriptionWorkerId>,
|
pub primary_worker_id: Option<SubscriptionWorkerId>,
|
||||||
@@ -644,41 +604,7 @@ pub struct SubscriptionWorkdir {
|
|||||||
impl SubscriptionWorkdir {
|
impl SubscriptionWorkdir {
|
||||||
pub fn validate(&self) -> Result<(), SubscriptionValidationError> {
|
pub fn validate(&self) -> Result<(), SubscriptionValidationError> {
|
||||||
self.working_directory_id.validate()?;
|
self.working_directory_id.validate()?;
|
||||||
match (&self.repository_id, &self.repository_key) {
|
validate_identifier("repository_id", &self.repository_id, MAX_RESOURCE_ID_BYTES)?;
|
||||||
(Some(repository_id), None) => {
|
|
||||||
validate_identifier("repository_id", repository_id, MAX_RESOURCE_ID_BYTES)?;
|
|
||||||
}
|
|
||||||
(None, Some(repository_key)) => validate_repository_key(repository_key)?,
|
|
||||||
_ => {
|
|
||||||
return Err(SubscriptionValidationError::InvalidIdentifier {
|
|
||||||
field: "repository_authority",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
validate_identifier("workdir_state", &self.state, MAX_RESOURCE_ID_BYTES)?;
|
|
||||||
if let Some(worker_id) = &self.primary_worker_id {
|
|
||||||
worker_id.validate()?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Workspace-facing Workdir summary. Backend-generated Repository UUIDs never
|
|
||||||
/// enter this DTO; Workspace Server must resolve the required Repository key.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
|
||||||
pub struct WorkspaceSubscriptionWorkdir {
|
|
||||||
pub working_directory_id: SubscriptionWorkdirId,
|
|
||||||
pub repository_key: String,
|
|
||||||
pub state: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub primary_worker_id: Option<SubscriptionWorkerId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorkspaceSubscriptionWorkdir {
|
|
||||||
pub fn validate(&self) -> Result<(), SubscriptionValidationError> {
|
|
||||||
self.working_directory_id.validate()?;
|
|
||||||
validate_repository_key(&self.repository_key)?;
|
|
||||||
validate_identifier("workdir_state", &self.state, MAX_RESOURCE_ID_BYTES)?;
|
validate_identifier("workdir_state", &self.state, MAX_RESOURCE_ID_BYTES)?;
|
||||||
if let Some(worker_id) = &self.primary_worker_id {
|
if let Some(worker_id) = &self.primary_worker_id {
|
||||||
worker_id.validate()?;
|
worker_id.validate()?;
|
||||||
@@ -699,7 +625,7 @@ pub enum SubscriptionSnapshot {
|
|||||||
events: Vec<WorkerProtocolEvent>,
|
events: Vec<WorkerProtocolEvent>,
|
||||||
},
|
},
|
||||||
WorkspaceWorkdirs {
|
WorkspaceWorkdirs {
|
||||||
workdirs: Vec<WorkspaceSubscriptionWorkdir>,
|
workdirs: Vec<SubscriptionWorkdir>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,7 +693,7 @@ pub enum SubscriptionEventPayload {
|
|||||||
event: WorkerProtocolEvent,
|
event: WorkerProtocolEvent,
|
||||||
},
|
},
|
||||||
WorkdirUpserted {
|
WorkdirUpserted {
|
||||||
workdir: WorkspaceSubscriptionWorkdir,
|
workdir: SubscriptionWorkdir,
|
||||||
},
|
},
|
||||||
WorkdirRemoved {
|
WorkdirRemoved {
|
||||||
working_directory_id: SubscriptionWorkdirId,
|
working_directory_id: SubscriptionWorkdirId,
|
||||||
@@ -879,49 +805,16 @@ mod tests {
|
|||||||
runtime_id: None,
|
runtime_id: None,
|
||||||
resource_key: None,
|
resource_key: None,
|
||||||
subject_revision: 0,
|
subject_revision: 0,
|
||||||
worker_state: None,
|
|
||||||
state: SubscriptionWorkerState::Idle,
|
state: SubscriptionWorkerState::Idle,
|
||||||
has_running_internal_workers: false,
|
has_running_internal_workers: false,
|
||||||
workspace_id: Some("workspace-1".to_string()),
|
workspace_id: Some("workspace-1".to_string()),
|
||||||
display_name: Some(format!("Worker {value}")),
|
display_name: Some(format!("Worker {value}")),
|
||||||
profile: Some("builtin:coder".to_string()),
|
profile: Some("builtin:coder".to_string()),
|
||||||
repository_id: None,
|
repository_id: None,
|
||||||
repository_key: None,
|
|
||||||
working_directory_id: None,
|
working_directory_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn runtime_and_workspace_repository_identity_projections_do_not_alias() {
|
|
||||||
let mut runtime_worker = worker("worker-1");
|
|
||||||
runtime_worker.repository_id = Some("01890f47-3c22-7cc0-98c4-dc0c0c07398f".to_string());
|
|
||||||
runtime_worker.validate().unwrap();
|
|
||||||
let runtime_json = serde_json::to_value(&runtime_worker).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
runtime_json["repository_id"],
|
|
||||||
"01890f47-3c22-7cc0-98c4-dc0c0c07398f"
|
|
||||||
);
|
|
||||||
assert!(runtime_json.get("repository_key").is_none());
|
|
||||||
|
|
||||||
let mut workspace_worker = worker("worker-1");
|
|
||||||
workspace_worker.repository_key = Some("main".to_string());
|
|
||||||
workspace_worker.validate().unwrap();
|
|
||||||
let workspace_json = serde_json::to_value(&workspace_worker).unwrap();
|
|
||||||
assert_eq!(workspace_json["repository_key"], "main");
|
|
||||||
assert!(workspace_json.get("repository_id").is_none());
|
|
||||||
|
|
||||||
let workspace_workdir = WorkspaceSubscriptionWorkdir {
|
|
||||||
working_directory_id: SubscriptionWorkdirId::new("workdir-1").unwrap(),
|
|
||||||
repository_key: "main".to_string(),
|
|
||||||
state: "active".to_string(),
|
|
||||||
primary_worker_id: Some(worker_id("worker-1")),
|
|
||||||
};
|
|
||||||
workspace_workdir.validate().unwrap();
|
|
||||||
let workdir_json = serde_json::to_value(&workspace_workdir).unwrap();
|
|
||||||
assert_eq!(workdir_json["repository_key"], "main");
|
|
||||||
assert!(workdir_json.get("repository_id").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn subscribe_frame_has_stable_versioned_json_shape() {
|
fn subscribe_frame_has_stable_versioned_json_shape() {
|
||||||
let frame = SubscriptionFrame::new(SubscriptionFramePayload::Request(
|
let frame = SubscriptionFrame::new(SubscriptionFramePayload::Request(
|
||||||
@@ -1115,25 +1008,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn worker_subscription_state_has_exactly_four_lifecycle_values() {
|
|
||||||
for (state, wire) in [
|
|
||||||
(SubscriptionWorkerState::Idle, "idle"),
|
|
||||||
(SubscriptionWorkerState::Running, "running"),
|
|
||||||
(SubscriptionWorkerState::Paused, "paused"),
|
|
||||||
(SubscriptionWorkerState::Stopped, "stopped"),
|
|
||||||
] {
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_value(state).unwrap(),
|
|
||||||
serde_json::json!(wire)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
serde_json::from_value::<SubscriptionWorkerState>(serde_json::json!("cancelled"))
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn client_selector_has_no_workspace_scope_field() {
|
fn client_selector_has_no_workspace_scope_field() {
|
||||||
let json = serde_json::to_value(EventSubscriptionSelector::WorkspaceWorkers).unwrap();
|
let json = serde_json::to_value(EventSubscriptionSelector::WorkspaceWorkers).unwrap();
|
||||||
|
|||||||
@@ -4,25 +4,18 @@ use ts_rs::{Config, TS};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
||||||
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
|
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry,
|
||||||
CompactionTrigger, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
|
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
||||||
InFlightCompaction, InFlightSnapshot, InFlightToolCallState, InternalWorkerKind,
|
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||||
InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method,
|
InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId,
|
||||||
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
|
RunResult, ScopeRule, Segment, TurnResult, WorkerEvent, WorkerStatus,
|
||||||
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
|
|
||||||
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
|
|
||||||
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
|
||||||
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
|
|
||||||
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
|
|
||||||
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
|
|
||||||
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
|
|
||||||
subscription::{
|
subscription::{
|
||||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||||
SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot,
|
SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot,
|
||||||
SubscriptionTerminationCode, SubscriptionWorkdirId, SubscriptionWorker,
|
SubscriptionTerminationCode, SubscriptionWorkdir, SubscriptionWorkdirId,
|
||||||
SubscriptionWorkerId, SubscriptionWorkerIds, SubscriptionWorkerProtocolMethod,
|
SubscriptionWorker, SubscriptionWorkerId, SubscriptionWorkerIds,
|
||||||
SubscriptionWorkerState, WorkspaceSubscriptionWorkdir,
|
SubscriptionWorkerProtocolMethod, SubscriptionWorkerState,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,22 +42,11 @@ pub fn generated_protocol_types() -> String {
|
|||||||
push_decl::<AlertSource>(&cfg, &mut output);
|
push_decl::<AlertSource>(&cfg, &mut output);
|
||||||
push_decl::<CompletionKind>(&cfg, &mut output);
|
push_decl::<CompletionKind>(&cfg, &mut output);
|
||||||
push_decl::<WorkerStatus>(&cfg, &mut output);
|
push_decl::<WorkerStatus>(&cfg, &mut output);
|
||||||
push_decl::<WorkerCommandEnvelope>(&cfg, &mut output);
|
|
||||||
push_decl::<WorkerCommandKind>(&cfg, &mut output);
|
|
||||||
push_decl::<WorkerCommandDisposition>(&cfg, &mut output);
|
|
||||||
push_decl::<WorkerCommandAcknowledgement>(&cfg, &mut output);
|
|
||||||
push_decl::<WorkerRunState>(&cfg, &mut output);
|
|
||||||
push_decl::<WorkerMaintenanceState>(&cfg, &mut output);
|
|
||||||
push_decl::<WorkerBusyState>(&cfg, &mut output);
|
|
||||||
push_decl::<WorkerState>(&cfg, &mut output);
|
|
||||||
push_decl::<WorkerStateSnapshot>(&cfg, &mut output);
|
|
||||||
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::<SymlinkPolicy>(&cfg, &mut output);
|
|
||||||
push_decl::<InFlightToolCallState>(&cfg, &mut output);
|
push_decl::<InFlightToolCallState>(&cfg, &mut output);
|
||||||
push_decl::<CommandStatus>(&cfg, &mut output);
|
push_decl::<CommandStatus>(&cfg, &mut output);
|
||||||
push_decl::<CommandStream>(&cfg, &mut output);
|
push_decl::<CommandStream>(&cfg, &mut output);
|
||||||
@@ -73,37 +55,19 @@ pub fn generated_protocol_types() -> String {
|
|||||||
push_decl::<CommandEvent>(&cfg, &mut output);
|
push_decl::<CommandEvent>(&cfg, &mut output);
|
||||||
push_decl::<CompactionLifecycleState>(&cfg, &mut output);
|
push_decl::<CompactionLifecycleState>(&cfg, &mut output);
|
||||||
push_decl::<CompactionLifecycle>(&cfg, &mut output);
|
push_decl::<CompactionLifecycle>(&cfg, &mut output);
|
||||||
push_decl::<CompactionPhase>(&cfg, &mut output);
|
|
||||||
push_decl::<CompactionTrigger>(&cfg, &mut output);
|
|
||||||
push_decl::<UploadedFileAvailability>(&cfg, &mut output);
|
|
||||||
push_decl::<UploadedFileRef>(&cfg, &mut output);
|
|
||||||
push_decl::<ScopeRule>(&cfg, &mut output);
|
push_decl::<ScopeRule>(&cfg, &mut output);
|
||||||
push_decl::<CompletionEntry>(&cfg, &mut output);
|
push_decl::<CompletionEntry>(&cfg, &mut output);
|
||||||
push_decl::<RewindTargetId>(&cfg, &mut output);
|
push_decl::<RewindTargetId>(&cfg, &mut output);
|
||||||
push_decl::<RewindTarget>(&cfg, &mut output);
|
push_decl::<RewindTarget>(&cfg, &mut output);
|
||||||
push_decl::<RewindSummary>(&cfg, &mut output);
|
push_decl::<RewindSummary>(&cfg, &mut output);
|
||||||
push_decl::<InFlightBlock>(&cfg, &mut output);
|
push_decl::<InFlightBlock>(&cfg, &mut output);
|
||||||
push_decl::<InFlightCompaction>(&cfg, &mut output);
|
|
||||||
push_decl::<InFlightSnapshot>(&cfg, &mut output);
|
push_decl::<InFlightSnapshot>(&cfg, &mut output);
|
||||||
push_decl::<SessionEntryProvenance>(&cfg, &mut output);
|
|
||||||
push_decl::<SessionMessageRole>(&cfg, &mut output);
|
|
||||||
push_decl::<SessionContentPart>(&cfg, &mut output);
|
|
||||||
push_decl::<SessionToolAttachment>(&cfg, &mut output);
|
|
||||||
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
|
|
||||||
push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
|
|
||||||
push_decl::<PendingSubmissionSummary>(&cfg, &mut output);
|
|
||||||
push_decl::<PendingSubmissionsSnapshot>(&cfg, &mut output);
|
|
||||||
push_decl::<SubmissionDisposition>(&cfg, &mut output);
|
|
||||||
push_decl::<SessionSnapshot>(&cfg, &mut output);
|
|
||||||
push_decl::<InternalWorkerKind>(&cfg, &mut output);
|
push_decl::<InternalWorkerKind>(&cfg, &mut output);
|
||||||
push_decl::<InternalWorkerRef>(&cfg, &mut output);
|
push_decl::<InternalWorkerRef>(&cfg, &mut output);
|
||||||
push_decl::<InternalWorkerSnapshot>(&cfg, &mut output);
|
push_decl::<InternalWorkerSnapshot>(&cfg, &mut output);
|
||||||
push_decl::<Greeting>(&cfg, &mut output);
|
push_decl::<Greeting>(&cfg, &mut output);
|
||||||
push_decl::<Alert>(&cfg, &mut output);
|
push_decl::<Alert>(&cfg, &mut output);
|
||||||
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
|
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
|
||||||
push_decl::<PasteArtifactMediaType>(&cfg, &mut output);
|
|
||||||
push_decl::<PasteArtifactAvailability>(&cfg, &mut output);
|
|
||||||
push_decl::<PasteArtifactRef>(&cfg, &mut output);
|
|
||||||
push_decl::<Segment>(&cfg, &mut output);
|
push_decl::<Segment>(&cfg, &mut output);
|
||||||
push_decl::<WorkerEvent>(&cfg, &mut output);
|
push_decl::<WorkerEvent>(&cfg, &mut output);
|
||||||
push_decl::<SubscriptionRequestId>(&cfg, &mut output);
|
push_decl::<SubscriptionRequestId>(&cfg, &mut output);
|
||||||
@@ -114,7 +78,7 @@ pub fn generated_protocol_types() -> String {
|
|||||||
push_decl::<SubscriptionWorkerState>(&cfg, &mut output);
|
push_decl::<SubscriptionWorkerState>(&cfg, &mut output);
|
||||||
push_decl::<EventSubscriptionSelector>(&cfg, &mut output);
|
push_decl::<EventSubscriptionSelector>(&cfg, &mut output);
|
||||||
push_decl::<SubscriptionWorker>(&cfg, &mut output);
|
push_decl::<SubscriptionWorker>(&cfg, &mut output);
|
||||||
push_decl::<WorkspaceSubscriptionWorkdir>(&cfg, &mut output);
|
push_decl::<SubscriptionWorkdir>(&cfg, &mut output);
|
||||||
push_decl::<SubscriptionSnapshot>(&cfg, &mut output);
|
push_decl::<SubscriptionSnapshot>(&cfg, &mut output);
|
||||||
push_decl::<SubscriptionEventPayload>(&cfg, &mut output);
|
push_decl::<SubscriptionEventPayload>(&cfg, &mut output);
|
||||||
push_decl::<SubscriptionRejectionCode>(&cfg, &mut output);
|
push_decl::<SubscriptionRejectionCode>(&cfg, &mut output);
|
||||||
@@ -158,14 +122,6 @@ fn export_decl(decl: &str) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_typescript_omits_runtime_repository_ids() {
|
|
||||||
let generated = generated_protocol_types();
|
|
||||||
assert!(!generated.contains("repository_id?:"), "{generated}");
|
|
||||||
assert!(!generated.contains("repository_id:"), "{generated}");
|
|
||||||
assert!(generated.contains("repository_key"), "{generated}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generated_protocol_types_are_current() {
|
fn generated_protocol_types_are_current() {
|
||||||
let expected = generated_protocol_types();
|
let expected = generated_protocol_types();
|
||||||
|
|||||||
@@ -8,6 +8,3 @@ license.workspace = true
|
|||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
session-store = { workspace = true }
|
session-store = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
tempfile = { workspace = true }
|
|
||||||
|
|||||||
@@ -1,39 +1,29 @@
|
|||||||
# session-metrics
|
# session-metrics
|
||||||
|
|
||||||
Session 単位の append-only な観測値を既存 session-log に記録し、明示的な
|
## Role
|
||||||
metrics 読取 / JSONL export 経路で取り出すための小さなヘルパークレートです。
|
|
||||||
|
|
||||||
- 保存先は `session-store` の `LogEntry::Extension`
|
`session-metrics` records usage and memory/session metrics that are useful for diagnostics and maintenance.
|
||||||
- extension domain は `metrics`
|
|
||||||
- metric は `name / ts / dimensions / value / correlation_id` の最小 envelope
|
|
||||||
- `record_metric` で指定した Session / Segment に append する
|
|
||||||
- `read_segment_metrics` は 1 Segment、`read_session_metrics` は Session 内の全
|
|
||||||
Segment を読み、各 metric に `segment_id` と `compacted_from` を付ける
|
|
||||||
- `export_metrics_jsonl` はその located metric を newline-delimited JSON にする
|
|
||||||
- 通常の Session snapshot / Worker list / Worker detail は Extension を公開しない
|
|
||||||
|
|
||||||
compaction は `compact.start` を source Segment、`compact.finish` と
|
## Boundaries
|
||||||
`compact.post_request` を結果 Segment に記録する。同じ `correlation_id` と
|
|
||||||
`SegmentStart.compacted_from` により、Segment をまたぐ attempt と次の通常 LLM
|
|
||||||
request を結合できる。
|
|
||||||
|
|
||||||
```rust,ignore
|
Owns:
|
||||||
use session_metrics::{
|
|
||||||
Metric, export_metrics_jsonl, read_session_metrics, record_metric,
|
|
||||||
};
|
|
||||||
|
|
||||||
let metric = Metric::now("compact.start")
|
- metric record types and persistence helpers
|
||||||
.with_value(12_345.0)
|
- explicit memory usage/read/reference observations where applicable
|
||||||
.with_dimension("trigger", "pre_run")
|
- lightweight diagnostic data that should not become model context by itself
|
||||||
.with_correlation_id("018f6f8a-9822-7b11-8b35-706f30313700");
|
|
||||||
record_metric(
|
|
||||||
&store,
|
|
||||||
location.session_id,
|
|
||||||
location.segment_id,
|
|
||||||
&metric,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let records = read_session_metrics(&store, location.session_id)?;
|
Does not own:
|
||||||
let jsonl = export_metrics_jsonl(&records)?;
|
|
||||||
# Ok::<(), Box<dyn std::error::Error>>(())
|
- prompt context packing (`agen`)
|
||||||
```
|
- generated memory contents (`memory`)
|
||||||
|
- provider billing semantics (`provider`)
|
||||||
|
- UI status rendering (`tui`)
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
Metrics are observations. They may guide compaction, memory effectiveness analysis, or UX, but they are not authoritative conversation history and should not smuggle hidden state into model input.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [`../../docs/design/memory-knowledge.md`](../../docs/design/memory-knowledge.md)
|
||||||
|
- [`../../docs/design/compaction.md`](../../docs/design/compaction.md)
|
||||||
|
|||||||
@@ -12,16 +12,13 @@
|
|||||||
//! 観測できない値は `None` で明示する
|
//! 観測できない値は `None` で明示する
|
||||||
//! - 「後から埋まる値」(例: prune 発火直後の `cache_read_tokens`)は前 entry に
|
//! - 「後から埋まる値」(例: prune 発火直後の `cache_read_tokens`)は前 entry に
|
||||||
//! 書き戻さず、`correlation_id` を共有する別 metric として流す。集計は読み手で join
|
//! 書き戻さず、`correlation_id` を共有する別 metric として流す。集計は読み手で join
|
||||||
//! - 集計 / 可視化には [`read_session_metrics`] / [`read_segment_metrics`] /
|
//! - 集計 / 可視化 API はこのクレートには無い。session-log を読めば取り出せる、
|
||||||
//! [`export_metrics_jsonl`] の明示的な metrics 専用経路を使う。通常の
|
//! までが到達点
|
||||||
//! Session snapshot は `Extension` を公開しない。
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use session_store::{
|
use session_store::{SegmentId, SessionId, Store, StoreError, save_extension, segment_log};
|
||||||
LogEntry, SegmentId, SegmentOrigin, SessionId, Store, StoreError, save_extension, segment_log,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Domain tag used in `LogEntry::Extension` for all metrics records.
|
/// Domain tag used in `LogEntry::Extension` for all metrics records.
|
||||||
pub const DOMAIN: &str = "metrics";
|
pub const DOMAIN: &str = "metrics";
|
||||||
@@ -100,165 +97,6 @@ pub fn metrics_from_extensions(extensions: &[(String, serde_json::Value)]) -> Ve
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A metric together with its durable Session/Segment origin.
|
|
||||||
///
|
|
||||||
/// `compacted_from` is copied from the Segment start record so readers can
|
|
||||||
/// reconstruct compaction lineage without inferring relationships from metric
|
|
||||||
/// names or timestamps.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
||||||
pub struct LocatedMetric {
|
|
||||||
pub session_id: SessionId,
|
|
||||||
pub segment_id: SegmentId,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub compacted_from: Option<SegmentOrigin>,
|
|
||||||
pub log_index: usize,
|
|
||||||
pub metric: Metric,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum SessionMetricsError {
|
|
||||||
Store(StoreError),
|
|
||||||
MissingSegmentStart {
|
|
||||||
segment_id: SegmentId,
|
|
||||||
},
|
|
||||||
SessionMismatch {
|
|
||||||
requested: SessionId,
|
|
||||||
observed: SessionId,
|
|
||||||
segment_id: SegmentId,
|
|
||||||
},
|
|
||||||
Encode(serde_json::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for SessionMetricsError {
|
|
||||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::Store(error) => write!(formatter, "session metrics store error: {error}"),
|
|
||||||
Self::MissingSegmentStart { segment_id } => {
|
|
||||||
write!(formatter, "segment {segment_id} has no start record")
|
|
||||||
}
|
|
||||||
Self::SessionMismatch {
|
|
||||||
requested,
|
|
||||||
observed,
|
|
||||||
segment_id,
|
|
||||||
} => write!(
|
|
||||||
formatter,
|
|
||||||
"segment {segment_id} belongs to session {observed}, not {requested}"
|
|
||||||
),
|
|
||||||
Self::Encode(error) => write!(formatter, "session metrics encode error: {error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for SessionMetricsError {}
|
|
||||||
|
|
||||||
impl From<StoreError> for SessionMetricsError {
|
|
||||||
fn from(error: StoreError) -> Self {
|
|
||||||
Self::Store(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<serde_json::Error> for SessionMetricsError {
|
|
||||||
fn from(error: serde_json::Error) -> Self {
|
|
||||||
Self::Encode(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read metrics from one exact Segment.
|
|
||||||
///
|
|
||||||
/// This is an explicit metrics-only surface. It validates the Segment's
|
|
||||||
/// durable start record and retains the log position of each metric.
|
|
||||||
pub fn read_segment_metrics(
|
|
||||||
store: &dyn Store,
|
|
||||||
session_id: SessionId,
|
|
||||||
segment_id: SegmentId,
|
|
||||||
) -> Result<Vec<LocatedMetric>, SessionMetricsError> {
|
|
||||||
let entries = store.read_all(session_id, segment_id)?;
|
|
||||||
let (observed_session_id, compacted_from) = entries
|
|
||||||
.iter()
|
|
||||||
.find_map(|entry| match entry {
|
|
||||||
LogEntry::AnnotatedSegmentStart {
|
|
||||||
session_id,
|
|
||||||
compacted_from,
|
|
||||||
..
|
|
||||||
} => Some((*session_id, compacted_from.clone())),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.ok_or(SessionMetricsError::MissingSegmentStart { segment_id })?;
|
|
||||||
if observed_session_id != session_id {
|
|
||||||
return Err(SessionMetricsError::SessionMismatch {
|
|
||||||
requested: session_id,
|
|
||||||
observed: observed_session_id,
|
|
||||||
segment_id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(entries
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter_map(|(log_index, entry)| match entry {
|
|
||||||
LogEntry::Extension {
|
|
||||||
domain, payload, ..
|
|
||||||
} if domain == DOMAIN => {
|
|
||||||
serde_json::from_value::<Metric>(payload.clone())
|
|
||||||
.ok()
|
|
||||||
.map(|metric| LocatedMetric {
|
|
||||||
session_id,
|
|
||||||
segment_id,
|
|
||||||
compacted_from: compacted_from.clone(),
|
|
||||||
log_index,
|
|
||||||
metric,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read every metric for a Session across all of its Segments.
|
|
||||||
pub fn read_session_metrics(
|
|
||||||
store: &dyn Store,
|
|
||||||
session_id: SessionId,
|
|
||||||
) -> Result<Vec<LocatedMetric>, SessionMetricsError> {
|
|
||||||
let mut metrics = Vec::new();
|
|
||||||
for segment_id in store.list_segments(session_id)? {
|
|
||||||
metrics.extend(read_segment_metrics(store, session_id, segment_id)?);
|
|
||||||
}
|
|
||||||
metrics.sort_by(|left, right| {
|
|
||||||
(
|
|
||||||
left.metric.ts,
|
|
||||||
metric_phase_order(&left.metric.name),
|
|
||||||
left.segment_id,
|
|
||||||
left.log_index,
|
|
||||||
)
|
|
||||||
.cmp(&(
|
|
||||||
right.metric.ts,
|
|
||||||
metric_phase_order(&right.metric.name),
|
|
||||||
right.segment_id,
|
|
||||||
right.log_index,
|
|
||||||
))
|
|
||||||
});
|
|
||||||
Ok(metrics)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize located metrics as newline-delimited JSON for an explicit export.
|
|
||||||
pub fn export_metrics_jsonl(metrics: &[LocatedMetric]) -> Result<String, SessionMetricsError> {
|
|
||||||
let mut output = String::new();
|
|
||||||
for metric in metrics {
|
|
||||||
output.push_str(&serde_json::to_string(metric)?);
|
|
||||||
output.push('\n');
|
|
||||||
}
|
|
||||||
Ok(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn metric_phase_order(name: &str) -> u8 {
|
|
||||||
match name {
|
|
||||||
"compact.start" => 0,
|
|
||||||
"compact.finish" => 2,
|
|
||||||
"compact.post_request" => 3,
|
|
||||||
_ => 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -313,101 +151,6 @@ mod tests {
|
|||||||
assert_eq!(metrics[1].name, "b");
|
assert_eq!(metrics[1].name, "b");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn explicit_reader_and_export_preserve_compaction_lineage() {
|
|
||||||
use session_store::FsStore;
|
|
||||||
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let store = FsStore::new(temp.path()).unwrap();
|
|
||||||
let session_id = SessionId::parse_str("018f6f8a-9822-7b11-8b35-706f30313701").unwrap();
|
|
||||||
let source_segment_id =
|
|
||||||
SegmentId::parse_str("018f6f8a-9822-7b11-8b35-706f30313702").unwrap();
|
|
||||||
let result_segment_id =
|
|
||||||
SegmentId::parse_str("018f6f8a-9822-7b11-8b35-706f30313703").unwrap();
|
|
||||||
let correlation_id = "018f6f8a-9822-7b11-8b35-706f30313700";
|
|
||||||
|
|
||||||
store
|
|
||||||
.create_segment(
|
|
||||||
session_id,
|
|
||||||
source_segment_id,
|
|
||||||
&[LogEntry::AnnotatedSegmentStart {
|
|
||||||
ts: 1,
|
|
||||||
session_id,
|
|
||||||
system_prompt: None,
|
|
||||||
config: Default::default(),
|
|
||||||
history: Vec::new(),
|
|
||||||
forked_from: None,
|
|
||||||
compacted_from: None,
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let mut start = Metric::now("compact.start").with_correlation_id(correlation_id);
|
|
||||||
start.ts = 10;
|
|
||||||
record_metric(&store, session_id, source_segment_id, &start).unwrap();
|
|
||||||
|
|
||||||
let origin = SegmentOrigin {
|
|
||||||
segment_id: source_segment_id,
|
|
||||||
at_turn_index: 0,
|
|
||||||
};
|
|
||||||
store
|
|
||||||
.create_segment(
|
|
||||||
session_id,
|
|
||||||
result_segment_id,
|
|
||||||
&[LogEntry::AnnotatedSegmentStart {
|
|
||||||
ts: 2,
|
|
||||||
session_id,
|
|
||||||
system_prompt: None,
|
|
||||||
config: Default::default(),
|
|
||||||
history: Vec::new(),
|
|
||||||
forked_from: None,
|
|
||||||
compacted_from: Some(origin.clone()),
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let mut finish = Metric::now("compact.finish").with_correlation_id(correlation_id);
|
|
||||||
finish.ts = 10;
|
|
||||||
record_metric(&store, session_id, result_segment_id, &finish).unwrap();
|
|
||||||
let mut post = Metric::now("compact.post_request").with_correlation_id(correlation_id);
|
|
||||||
post.ts = 11;
|
|
||||||
record_metric(&store, session_id, result_segment_id, &post).unwrap();
|
|
||||||
|
|
||||||
let source_metrics = read_segment_metrics(&store, session_id, source_segment_id).unwrap();
|
|
||||||
assert_eq!(source_metrics.len(), 1);
|
|
||||||
assert_eq!(source_metrics[0].compacted_from, None);
|
|
||||||
|
|
||||||
let metrics = read_session_metrics(&store, session_id).unwrap();
|
|
||||||
assert_eq!(metrics.len(), 3);
|
|
||||||
assert_eq!(metrics[0].metric.name, "compact.start");
|
|
||||||
let finish = metrics
|
|
||||||
.iter()
|
|
||||||
.find(|record| record.metric.name == "compact.finish")
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(finish.segment_id, result_segment_id);
|
|
||||||
assert_eq!(finish.compacted_from, Some(origin));
|
|
||||||
assert!(
|
|
||||||
metrics
|
|
||||||
.iter()
|
|
||||||
.all(|record| { record.metric.correlation_id.as_deref() == Some(correlation_id) })
|
|
||||||
);
|
|
||||||
|
|
||||||
let exported = export_metrics_jsonl(&metrics).unwrap();
|
|
||||||
let ordinary_snapshot = session_store::public_snapshot::project_current_session_snapshot(
|
|
||||||
&store.read_all(session_id, result_segment_id).unwrap(),
|
|
||||||
);
|
|
||||||
let ordinary_json = serde_json::to_string(&ordinary_snapshot).unwrap();
|
|
||||||
assert!(!ordinary_json.contains("compact.finish"));
|
|
||||||
assert!(!ordinary_json.contains("compact.post_request"));
|
|
||||||
let decoded = exported
|
|
||||||
.lines()
|
|
||||||
.map(|line| serde_json::from_str::<LocatedMetric>(line).unwrap())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
assert_eq!(decoded, metrics);
|
|
||||||
|
|
||||||
let reopened = FsStore::new(temp.path()).unwrap();
|
|
||||||
let restored = read_session_metrics(&reopened, session_id).unwrap();
|
|
||||||
assert_eq!(restored, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fold_skips_undeserializable_payloads() {
|
fn fold_skips_undeserializable_payloads() {
|
||||||
// 将来 schema が変わって読めない payload も skip して落ちない。
|
// 将来 schema が変わって読めない payload も skip して落ちない。
|
||||||
|
|||||||
@@ -8,17 +8,12 @@ license.workspace = true
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
agen = { workspace = true }
|
agen = { workspace = true }
|
||||||
fs4.workspace = true
|
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
sha2.workspace = true
|
|
||||||
uuid = { workspace = true, features = ["v7", "serde"] }
|
uuid = { workspace = true, features = ["v7", "serde"] }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
unicode-normalization = "0.1.25"
|
|
||||||
unicode-properties = { version = "0.1.4", features = ["general-category"] }
|
|
||||||
unicode-security = "0.1.2"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
|||||||
@@ -16,20 +16,9 @@
|
|||||||
//! enumerable by the picker.
|
//! enumerable by the picker.
|
||||||
|
|
||||||
use crate::event_trace::TraceEntry;
|
use crate::event_trace::TraceEntry;
|
||||||
use crate::paste_artifact::{read_from_dir, write_to_dir};
|
|
||||||
use crate::segment_log::LogEntry;
|
use crate::segment_log::LogEntry;
|
||||||
use crate::store::{Store, StoreError};
|
use crate::store::{Store, StoreError};
|
||||||
use crate::uploaded_file::{
|
use crate::{SegmentId, SessionId};
|
||||||
bind_uploaded_file, clear_uploaded_file_binding, copy_committed_uploaded_files,
|
|
||||||
delete_uncommitted_uploaded_files, delete_uploaded_file, finalize_uploaded_file_binding,
|
|
||||||
list_uploaded_file_refs, pin_uploaded_file, read_uploaded_file, read_uploaded_file_by_id,
|
|
||||||
reconcile_uploaded_file_pins, release_uploaded_file_pin, uploaded_file_has_pending_owner,
|
|
||||||
write_uploaded_file,
|
|
||||||
};
|
|
||||||
use crate::{
|
|
||||||
PasteArtifactLimits, SegmentId, SessionId, UploadedFileLimits, UploadedFileUploadContext,
|
|
||||||
};
|
|
||||||
use protocol::{PasteArtifactRef, UploadedFileRef};
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{Read, Seek, SeekFrom, Write};
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -120,50 +109,6 @@ impl FsStore {
|
|||||||
.join(format!("{segment_id}.trace.jsonl"))
|
.join(format!("{segment_id}.trace.jsonl"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paste_artifact_dir(&self, session_id: SessionId) -> PathBuf {
|
|
||||||
self.session_dir(session_id).join("artifacts").join("paste")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn uploaded_file_is_referenced(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
artifact_id: &str,
|
|
||||||
) -> Result<bool, StoreError> {
|
|
||||||
fn segments_contain(segments: &[protocol::Segment], artifact_id: &str) -> bool {
|
|
||||||
segments.iter().any(|segment| {
|
|
||||||
matches!(
|
|
||||||
segment,
|
|
||||||
protocol::Segment::UploadedFile { file }
|
|
||||||
if file.artifact_id == artifact_id
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for segment_id in self.list_segments(session_id)? {
|
|
||||||
for entry in self.read_all(session_id, segment_id)? {
|
|
||||||
let referenced = match entry {
|
|
||||||
LogEntry::AnnotatedUserInput { segments, .. } => {
|
|
||||||
segments_contain(&segments, artifact_id)
|
|
||||||
}
|
|
||||||
LogEntry::InputSegmentsCheckpoint { user_segments, .. } => user_segments
|
|
||||||
.iter()
|
|
||||||
.any(|segments| segments_contain(segments, artifact_id)),
|
|
||||||
_ => false,
|
|
||||||
};
|
|
||||||
if referenced {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn paste_artifact_path(&self, session_id: SessionId, artifact_id: &str) -> PathBuf {
|
|
||||||
self.paste_artifact_dir(session_id)
|
|
||||||
.join(format!("{artifact_id}.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
|
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
|
||||||
let _guard = self
|
let _guard = self
|
||||||
.append_lock
|
.append_lock
|
||||||
@@ -405,231 +350,6 @@ impl Store for FsStore {
|
|||||||
Ok(complete.lines().filter(|l| !l.trim().is_empty()).count())
|
Ok(complete.lines().filter(|l| !l.trim().is_empty()).count())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_paste_artifact(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
source_entry_id: &str,
|
|
||||||
content: &str,
|
|
||||||
limits: PasteArtifactLimits,
|
|
||||||
) -> Result<PasteArtifactRef, StoreError> {
|
|
||||||
let _guard = self
|
|
||||||
.append_lock
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
|
||||||
write_to_dir(
|
|
||||||
&self.paste_artifact_dir(session_id),
|
|
||||||
source_entry_id,
|
|
||||||
content,
|
|
||||||
limits,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_paste_artifact(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
artifact_id: &str,
|
|
||||||
) -> Result<(PasteArtifactRef, String), StoreError> {
|
|
||||||
read_from_dir(&self.paste_artifact_dir(session_id), artifact_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_uploaded_file(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
file_name: &str,
|
|
||||||
media_type: &str,
|
|
||||||
content: &[u8],
|
|
||||||
limits: UploadedFileLimits,
|
|
||||||
) -> Result<UploadedFileRef, StoreError> {
|
|
||||||
let _guard = self
|
|
||||||
.append_lock
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
|
||||||
write_uploaded_file(
|
|
||||||
&self.paste_artifact_dir(session_id),
|
|
||||||
file_name,
|
|
||||||
media_type,
|
|
||||||
content,
|
|
||||||
None,
|
|
||||||
limits,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_uploaded_file_with_context(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
file_name: &str,
|
|
||||||
media_type: &str,
|
|
||||||
content: &[u8],
|
|
||||||
context: &UploadedFileUploadContext,
|
|
||||||
limits: UploadedFileLimits,
|
|
||||||
) -> Result<UploadedFileRef, StoreError> {
|
|
||||||
let _guard = self
|
|
||||||
.append_lock
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
|
||||||
write_uploaded_file(
|
|
||||||
&self.paste_artifact_dir(session_id),
|
|
||||||
file_name,
|
|
||||||
media_type,
|
|
||||||
content,
|
|
||||||
Some(context),
|
|
||||||
limits,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_uploaded_file(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
reference: &UploadedFileRef,
|
|
||||||
) -> Result<Vec<u8>, StoreError> {
|
|
||||||
read_uploaded_file(&self.paste_artifact_dir(session_id), reference)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_uploaded_file_by_id(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
artifact_id: &str,
|
|
||||||
) -> Result<(UploadedFileRef, Vec<u8>), StoreError> {
|
|
||||||
read_uploaded_file_by_id(&self.paste_artifact_dir(session_id), artifact_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bind_uploaded_file(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
reference: &UploadedFileRef,
|
|
||||||
source_entry_id: &str,
|
|
||||||
) -> Result<UploadedFileRef, StoreError> {
|
|
||||||
let _guard = self
|
|
||||||
.append_lock
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
|
||||||
let dir = self.paste_artifact_dir(session_id);
|
|
||||||
match bind_uploaded_file(&dir, reference, source_entry_id) {
|
|
||||||
Err(StoreError::ArtifactAlreadyCommitted) => {
|
|
||||||
let (stored, _) = read_uploaded_file_by_id(&dir, &reference.artifact_id)?;
|
|
||||||
let previous_source = stored
|
|
||||||
.source_entry_id
|
|
||||||
.ok_or(StoreError::ArtifactIntegrityMismatch)?;
|
|
||||||
if self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? {
|
|
||||||
return Err(StoreError::ArtifactAlreadyCommitted);
|
|
||||||
}
|
|
||||||
clear_uploaded_file_binding(&dir, &reference.artifact_id, &previous_source)?;
|
|
||||||
bind_uploaded_file(&dir, reference, source_entry_id)
|
|
||||||
}
|
|
||||||
result => result,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pin_uploaded_file(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
reference: &UploadedFileRef,
|
|
||||||
owner_id: &str,
|
|
||||||
) -> Result<(), StoreError> {
|
|
||||||
let _guard = self
|
|
||||||
.append_lock
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
|
||||||
pin_uploaded_file(&self.paste_artifact_dir(session_id), reference, owner_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn release_uploaded_file_pin(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
artifact_id: &str,
|
|
||||||
owner_id: &str,
|
|
||||||
) -> Result<(), StoreError> {
|
|
||||||
let _guard = self
|
|
||||||
.append_lock
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
|
||||||
release_uploaded_file_pin(&self.paste_artifact_dir(session_id), artifact_id, owner_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finalize_uploaded_file_binding(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
artifact_id: &str,
|
|
||||||
source_entry_id: &str,
|
|
||||||
) -> Result<(), StoreError> {
|
|
||||||
let _guard = self
|
|
||||||
.append_lock
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
|
||||||
finalize_uploaded_file_binding(
|
|
||||||
&self.paste_artifact_dir(session_id),
|
|
||||||
artifact_id,
|
|
||||||
source_entry_id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reconcile_uploaded_file_pins(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
live_owner_ids: &[String],
|
|
||||||
) -> Result<u64, StoreError> {
|
|
||||||
let _guard = self
|
|
||||||
.append_lock
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
|
||||||
reconcile_uploaded_file_pins(&self.paste_artifact_dir(session_id), live_owner_ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn delete_uploaded_file(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
artifact_id: &str,
|
|
||||||
) -> Result<bool, StoreError> {
|
|
||||||
let _guard = self
|
|
||||||
.append_lock
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
|
||||||
delete_uploaded_file(&self.paste_artifact_dir(session_id), artifact_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn delete_uncommitted_uploaded_files(&self, session_id: SessionId) -> Result<u64, StoreError> {
|
|
||||||
let _guard = self
|
|
||||||
.append_lock
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
|
||||||
let dir = self.paste_artifact_dir(session_id);
|
|
||||||
let mut removed = delete_uncommitted_uploaded_files(&dir)?;
|
|
||||||
for reference in list_uploaded_file_refs(&dir)? {
|
|
||||||
let Some(source_entry_id) = reference.source_entry_id.as_deref() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if self.uploaded_file_is_referenced(session_id, &reference.artifact_id)? {
|
|
||||||
finalize_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if uploaded_file_has_pending_owner(&dir, &reference.artifact_id)? {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
clear_uploaded_file_binding(&dir, &reference.artifact_id, source_entry_id)?;
|
|
||||||
if delete_uploaded_file(&dir, &reference.artifact_id)? {
|
|
||||||
removed = removed
|
|
||||||
.checked_add(1)
|
|
||||||
.ok_or(StoreError::ArtifactQuotaExceeded)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(removed)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn copy_committed_uploaded_files(
|
|
||||||
&self,
|
|
||||||
source_session_id: SessionId,
|
|
||||||
target_session_id: SessionId,
|
|
||||||
) -> Result<u64, StoreError> {
|
|
||||||
let _guard = self
|
|
||||||
.append_lock
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
|
|
||||||
copy_committed_uploaded_files(
|
|
||||||
&self.paste_artifact_dir(source_session_id),
|
|
||||||
&self.paste_artifact_dir(target_session_id),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_trace(
|
fn append_trace(
|
||||||
&self,
|
&self,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
@@ -678,524 +398,4 @@ mod tests {
|
|||||||
store.create_segment(session_id, segment_id, &[]).unwrap();
|
store.create_segment(session_id, segment_id, &[]).unwrap();
|
||||||
assert!(store.session_modified_at(session_id).unwrap().is_some());
|
assert!(store.session_modified_at(session_id).unwrap().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn paste_artifacts_are_atomic_integrity_checked_and_session_scoped() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
let store = FsStore::new(tmp.path()).unwrap();
|
|
||||||
let owner = new_session_id();
|
|
||||||
let other = new_session_id();
|
|
||||||
let content = "αβγ\nsecond line\n";
|
|
||||||
let reference = store
|
|
||||||
.write_paste_artifact(owner, "entry-1", content, PasteArtifactLimits::default())
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(reference.byte_len, content.len() as u64);
|
|
||||||
assert!(reference.created_at_ms > 0);
|
|
||||||
assert_eq!(
|
|
||||||
reference.media_type,
|
|
||||||
protocol::PasteArtifactMediaType::TextPlainUtf8
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
reference.availability,
|
|
||||||
protocol::PasteArtifactAvailability::Available
|
|
||||||
);
|
|
||||||
assert_eq!(reference.char_count, content.chars().count() as u64);
|
|
||||||
assert_eq!(reference.source_entry_id, "entry-1");
|
|
||||||
assert_eq!(
|
|
||||||
store
|
|
||||||
.read_paste_artifact(owner, &reference.artifact_id)
|
|
||||||
.unwrap()
|
|
||||||
.1,
|
|
||||||
content
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
store.read_paste_artifact(other, &reference.artifact_id),
|
|
||||||
Err(StoreError::PasteArtifactNotFound(_))
|
|
||||||
));
|
|
||||||
assert!(
|
|
||||||
self::fs::read_dir(store.paste_artifact_dir(owner))
|
|
||||||
.unwrap()
|
|
||||||
.all(|entry| !entry
|
|
||||||
.unwrap()
|
|
||||||
.file_name()
|
|
||||||
.to_string_lossy()
|
|
||||||
.ends_with(".tmp"))
|
|
||||||
);
|
|
||||||
let very_large = "z".repeat(1024 * 1024);
|
|
||||||
let very_large_ref = store
|
|
||||||
.write_paste_artifact(
|
|
||||||
owner,
|
|
||||||
"entry-2",
|
|
||||||
&very_large,
|
|
||||||
PasteArtifactLimits::default(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
store
|
|
||||||
.read_paste_artifact(owner, &very_large_ref.artifact_id)
|
|
||||||
.unwrap()
|
|
||||||
.1,
|
|
||||||
very_large
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn concurrent_paste_writes_atomically_enforce_aggregate_caps() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
let session_id = new_session_id();
|
|
||||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
|
|
||||||
let limits = PasteArtifactLimits {
|
|
||||||
max_artifact_bytes: 4,
|
|
||||||
max_session_bytes: 8,
|
|
||||||
max_session_artifacts: 1,
|
|
||||||
};
|
|
||||||
let mut handles = Vec::new();
|
|
||||||
for entry_id in ["entry-1", "entry-2"] {
|
|
||||||
let root = tmp.path().to_path_buf();
|
|
||||||
let barrier = barrier.clone();
|
|
||||||
handles.push(std::thread::spawn(move || {
|
|
||||||
let store = FsStore::new(root).unwrap();
|
|
||||||
barrier.wait();
|
|
||||||
store.write_paste_artifact(session_id, entry_id, "1234", limits)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
barrier.wait();
|
|
||||||
let results = handles
|
|
||||||
.into_iter()
|
|
||||||
.map(|handle| handle.join().unwrap())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
|
|
||||||
assert_eq!(
|
|
||||||
results
|
|
||||||
.iter()
|
|
||||||
.filter(|result| matches!(result, Err(StoreError::PasteArtifactLimit(_))))
|
|
||||||
.count(),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
std::fs::read_dir(
|
|
||||||
FsStore::new(tmp.path())
|
|
||||||
.unwrap()
|
|
||||||
.paste_artifact_dir(session_id)
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.filter_map(Result::ok)
|
|
||||||
.filter(
|
|
||||||
|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("json")
|
|
||||||
)
|
|
||||||
.count(),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn uploaded_file_persists_trusted_upload_context_without_projecting_it() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
let store = FsStore::new(tmp.path()).unwrap();
|
|
||||||
let session_id = new_session_id();
|
|
||||||
let context = UploadedFileUploadContext {
|
|
||||||
upload_id: "upload-1".into(),
|
|
||||||
principal_id: "account-1".into(),
|
|
||||||
workspace_id: "workspace-1".into(),
|
|
||||||
runtime_id: "runtime-1".into(),
|
|
||||||
worker_id: "worker-1".into(),
|
|
||||||
};
|
|
||||||
let reference = store
|
|
||||||
.write_uploaded_file_with_context(
|
|
||||||
session_id,
|
|
||||||
"notes.txt",
|
|
||||||
"text/plain",
|
|
||||||
b"hello",
|
|
||||||
&context,
|
|
||||||
UploadedFileLimits::default(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let raw = fs::read_to_string(
|
|
||||||
store
|
|
||||||
.paste_artifact_dir(session_id)
|
|
||||||
.join(format!("{}.file.json", reference.artifact_id)),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(raw.contains("account-1"));
|
|
||||||
assert!(raw.contains("workspace-1"));
|
|
||||||
assert!(raw.contains("runtime-1"));
|
|
||||||
assert!(raw.contains("worker-1"));
|
|
||||||
assert!(
|
|
||||||
!serde_json::to_string(&reference)
|
|
||||||
.unwrap()
|
|
||||||
.contains("account-1")
|
|
||||||
);
|
|
||||||
|
|
||||||
let replay = store
|
|
||||||
.write_uploaded_file_with_context(
|
|
||||||
session_id,
|
|
||||||
"notes.txt",
|
|
||||||
"text/plain",
|
|
||||||
b"hello",
|
|
||||||
&context,
|
|
||||||
UploadedFileLimits::default(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(replay.artifact_id, reference.artifact_id);
|
|
||||||
assert!(matches!(
|
|
||||||
store.write_uploaded_file_with_context(
|
|
||||||
session_id,
|
|
||||||
"renamed.txt",
|
|
||||||
"text/plain",
|
|
||||||
b"hello",
|
|
||||||
&context,
|
|
||||||
UploadedFileLimits::default(),
|
|
||||||
),
|
|
||||||
Err(StoreError::InvalidUploadedFileName)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn uploaded_file_exact_replay_succeeds_at_session_count_limit() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
let store = FsStore::new(tmp.path()).unwrap();
|
|
||||||
let session_id = new_session_id();
|
|
||||||
let limits = UploadedFileLimits {
|
|
||||||
max_file_bytes: 1,
|
|
||||||
max_session_bytes: crate::DEFAULT_MAX_SESSION_UPLOADED_FILES,
|
|
||||||
};
|
|
||||||
let mut first = None;
|
|
||||||
for index in 0..crate::DEFAULT_MAX_SESSION_UPLOADED_FILES {
|
|
||||||
let reference = store
|
|
||||||
.write_uploaded_file(
|
|
||||||
session_id,
|
|
||||||
&format!("file-{index}.txt"),
|
|
||||||
"text/plain",
|
|
||||||
b"x",
|
|
||||||
limits,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
first.get_or_insert(reference);
|
|
||||||
}
|
|
||||||
|
|
||||||
let replay = store
|
|
||||||
.write_uploaded_file(session_id, "file-0.txt", "text/plain", b"x", limits)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(replay.artifact_id, first.unwrap().artifact_id);
|
|
||||||
assert!(matches!(
|
|
||||||
store.write_uploaded_file(session_id, "overflow.txt", "text/plain", b"x", limits),
|
|
||||||
Err(StoreError::ArtifactQuotaExceeded)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn uploaded_files_are_session_scoped_integrity_checked_and_removable() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
let store = FsStore::new(tmp.path()).unwrap();
|
|
||||||
let owner = new_session_id();
|
|
||||||
let other = new_session_id();
|
|
||||||
let limits = UploadedFileLimits {
|
|
||||||
max_file_bytes: 16,
|
|
||||||
max_session_bytes: 16,
|
|
||||||
};
|
|
||||||
let reference = store
|
|
||||||
.write_uploaded_file(owner, "notes.txt", "text/plain", b"hello", limits)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(reference.file_name, "notes.txt");
|
|
||||||
assert_eq!(reference.media_type, "text/plain");
|
|
||||||
assert_eq!(reference.byte_len, 5);
|
|
||||||
assert_eq!(reference.source_entry_id, None);
|
|
||||||
assert_eq!(
|
|
||||||
store.read_uploaded_file(owner, &reference).unwrap(),
|
|
||||||
b"hello"
|
|
||||||
);
|
|
||||||
assert!(store.read_uploaded_file(other, &reference).is_err());
|
|
||||||
|
|
||||||
let mut forged = reference.clone();
|
|
||||||
forged.file_name = "other.txt".to_string();
|
|
||||||
assert!(matches!(
|
|
||||||
store.read_uploaded_file(owner, &forged),
|
|
||||||
Err(StoreError::ArtifactIntegrityMismatch)
|
|
||||||
));
|
|
||||||
assert!(
|
|
||||||
store
|
|
||||||
.delete_uploaded_file(owner, &reference.artifact_id)
|
|
||||||
.unwrap()
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!store
|
|
||||||
.delete_uploaded_file(owner, &reference.artifact_id)
|
|
||||||
.unwrap()
|
|
||||||
);
|
|
||||||
assert!(store.read_uploaded_file(owner, &reference).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pending_upload_pin_survives_cleanup_until_release_or_history_binding() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
let store = FsStore::new(tmp.path()).unwrap();
|
|
||||||
let session_id = new_session_id();
|
|
||||||
let limits = UploadedFileLimits {
|
|
||||||
max_file_bytes: 64,
|
|
||||||
max_session_bytes: 128,
|
|
||||||
};
|
|
||||||
let pending = store
|
|
||||||
.write_uploaded_file(session_id, "pending.txt", "text/plain", b"pending", limits)
|
|
||||||
.unwrap();
|
|
||||||
store
|
|
||||||
.pin_uploaded_file(session_id, &pending, "submission-1")
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
store.pin_uploaded_file(session_id, &pending, "submission-other"),
|
|
||||||
Err(StoreError::ArtifactAlreadyCommitted)
|
|
||||||
));
|
|
||||||
drop(store);
|
|
||||||
let store = FsStore::new(tmp.path()).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
store
|
|
||||||
.read_uploaded_file_by_id(session_id, &pending.artifact_id)
|
|
||||||
.unwrap()
|
|
||||||
.1,
|
|
||||||
b"pending"
|
|
||||||
);
|
|
||||||
|
|
||||||
let fork_session_id = new_session_id();
|
|
||||||
assert_eq!(
|
|
||||||
store
|
|
||||||
.copy_committed_uploaded_files(session_id, fork_session_id)
|
|
||||||
.unwrap(),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
store
|
|
||||||
.read_uploaded_file_by_id(fork_session_id, &pending.artifact_id)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
|
|
||||||
let committed = store
|
|
||||||
.bind_uploaded_file(session_id, &pending, "entry-1")
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
store
|
|
||||||
.read_uploaded_file_by_id(session_id, &pending.artifact_id)
|
|
||||||
.is_ok()
|
|
||||||
);
|
|
||||||
store
|
|
||||||
.create_segment(
|
|
||||||
session_id,
|
|
||||||
new_segment_id(),
|
|
||||||
&[LogEntry::InputSegmentsCheckpoint {
|
|
||||||
ts: 1,
|
|
||||||
user_segments: vec![vec![protocol::Segment::UploadedFile {
|
|
||||||
file: committed.clone(),
|
|
||||||
}]],
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
store
|
|
||||||
.release_uploaded_file_pin(session_id, &pending.artifact_id, "submission-1")
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
|
|
||||||
let releasable = store
|
|
||||||
.write_uploaded_file(session_id, "cancelled.txt", "text/plain", b"cancel", limits)
|
|
||||||
.unwrap();
|
|
||||||
store
|
|
||||||
.pin_uploaded_file(session_id, &releasable, "submission-2")
|
|
||||||
.unwrap();
|
|
||||||
store
|
|
||||||
.release_uploaded_file_pin(session_id, &releasable.artifact_id, "submission-2")
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
store
|
|
||||||
.read_uploaded_file_by_id(session_id, &releasable.artifact_id)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn uploaded_file_validation_and_shared_quota_fail_closed() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
let store = FsStore::new(tmp.path()).unwrap();
|
|
||||||
let session_id = new_session_id();
|
|
||||||
let limits = UploadedFileLimits {
|
|
||||||
max_file_bytes: 8,
|
|
||||||
max_session_bytes: 8,
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
store.write_uploaded_file(session_id, "../secret", "text/plain", b"x", limits),
|
|
||||||
Err(StoreError::InvalidUploadedFileName)
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
store.write_uploaded_file(session_id, "notes.txt", "not a type", b"x", limits),
|
|
||||||
Err(StoreError::InvalidUploadedFileMediaType)
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
store.write_uploaded_file(
|
|
||||||
session_id,
|
|
||||||
"safe\u{202e}txt.exe",
|
|
||||||
"text/plain",
|
|
||||||
b"x",
|
|
||||||
limits
|
|
||||||
),
|
|
||||||
Err(StoreError::InvalidUploadedFileName)
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
store.write_uploaded_file(session_id, "image.png", "image/png", b"not a png", limits),
|
|
||||||
Err(StoreError::ArtifactIntegrityMismatch)
|
|
||||||
));
|
|
||||||
let pending = store
|
|
||||||
.write_uploaded_file(session_id, "Readme.txt", "text/plain", b"x", limits)
|
|
||||||
.unwrap();
|
|
||||||
let replay = store
|
|
||||||
.write_uploaded_file(session_id, "Readme.txt", "text/plain", b"x", limits)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(replay.artifact_id, pending.artifact_id);
|
|
||||||
assert!(matches!(
|
|
||||||
store.write_uploaded_file(session_id, "README.txt", "text/plain", b"changed", limits),
|
|
||||||
Err(StoreError::InvalidUploadedFileName)
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
store.write_uploaded_file(session_id, "README.txt", "text/plain", b"y", limits),
|
|
||||||
Err(StoreError::InvalidUploadedFileName)
|
|
||||||
));
|
|
||||||
store
|
|
||||||
.bind_uploaded_file(session_id, &pending, "entry-from-failed-submit")
|
|
||||||
.unwrap();
|
|
||||||
let bound = store
|
|
||||||
.bind_uploaded_file(session_id, &pending, "entry-upload")
|
|
||||||
.unwrap();
|
|
||||||
store
|
|
||||||
.create_segment(
|
|
||||||
session_id,
|
|
||||||
new_segment_id(),
|
|
||||||
&[LogEntry::InputSegmentsCheckpoint {
|
|
||||||
ts: 1,
|
|
||||||
user_segments: vec![vec![protocol::Segment::UploadedFile {
|
|
||||||
file: bound.clone(),
|
|
||||||
}]],
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let other = store
|
|
||||||
.write_uploaded_file(session_id, "other.txt", "text/plain", b"z", limits)
|
|
||||||
.unwrap();
|
|
||||||
let stale = store
|
|
||||||
.write_uploaded_file(session_id, "stale.txt", "text/plain", b"s", limits)
|
|
||||||
.unwrap();
|
|
||||||
store
|
|
||||||
.bind_uploaded_file(session_id, &stale, "entry-never-committed")
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
store.delete_uncommitted_uploaded_files(session_id).unwrap(),
|
|
||||||
2
|
|
||||||
);
|
|
||||||
assert!(store.read_uploaded_file(session_id, &other).is_err());
|
|
||||||
assert!(store.read_uploaded_file(session_id, &stale).is_err());
|
|
||||||
assert_eq!(store.read_uploaded_file(session_id, &bound).unwrap(), b"x");
|
|
||||||
let fork_session_id = new_session_id();
|
|
||||||
assert_eq!(
|
|
||||||
store
|
|
||||||
.copy_committed_uploaded_files(session_id, fork_session_id)
|
|
||||||
.unwrap(),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
store.read_uploaded_file(fork_session_id, &bound).unwrap(),
|
|
||||||
b"x"
|
|
||||||
);
|
|
||||||
store
|
|
||||||
.write_paste_artifact(
|
|
||||||
session_id,
|
|
||||||
"entry-1",
|
|
||||||
"1234",
|
|
||||||
PasteArtifactLimits {
|
|
||||||
max_artifact_bytes: 8,
|
|
||||||
max_session_bytes: 8,
|
|
||||||
max_session_artifacts: 4,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
store.write_uploaded_file(session_id, "notes.txt", "text/plain", b"56789", limits),
|
|
||||||
Err(StoreError::ArtifactQuotaExceeded)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn uploaded_file_names_reject_format_mixed_script_and_confusable_forms() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
let store = FsStore::new(tmp.path()).unwrap();
|
|
||||||
let session_id = new_session_id();
|
|
||||||
let limits = UploadedFileLimits::default();
|
|
||||||
|
|
||||||
for file_name in [
|
|
||||||
"safe\u{00ad}name.txt",
|
|
||||||
"safe\u{061c}name.txt",
|
|
||||||
"safe\u{180e}name.txt",
|
|
||||||
"safe\u{e0001}name.txt",
|
|
||||||
"p\u{0430}ypal.txt",
|
|
||||||
"report.\u{03c1}df",
|
|
||||||
"\u{0440}\u{0430}\u{0443}\u{0440}\u{0430}\u{04cf}.txt",
|
|
||||||
"\u{ff26}\u{ff49}\u{ff4c}\u{ff45}.txt",
|
|
||||||
"re\u{0301}sume\u{0301}.txt",
|
|
||||||
] {
|
|
||||||
assert!(matches!(
|
|
||||||
store.write_uploaded_file(session_id, file_name, "text/plain", b"safe", limits),
|
|
||||||
Err(StoreError::InvalidUploadedFileName)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
for file_name in ["notes.txt", "résumé.txt", "日本語.txt", "📎.txt"] {
|
|
||||||
store
|
|
||||||
.write_uploaded_file(session_id, file_name, "text/plain", b"safe", limits)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn paste_artifact_limits_and_corruption_fail_closed() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
let store = FsStore::new(tmp.path()).unwrap();
|
|
||||||
let session_id = new_session_id();
|
|
||||||
let limits = PasteArtifactLimits {
|
|
||||||
max_artifact_bytes: 5,
|
|
||||||
max_session_bytes: 8,
|
|
||||||
max_session_artifacts: 2,
|
|
||||||
};
|
|
||||||
let first = store
|
|
||||||
.write_paste_artifact(session_id, "entry-1", "1234", limits)
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
store.write_paste_artifact(session_id, "entry-2", "56789", limits),
|
|
||||||
Err(StoreError::PasteArtifactLimit(_))
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
store.write_paste_artifact(session_id, "entry-2", "5678", limits),
|
|
||||||
Ok(_)
|
|
||||||
));
|
|
||||||
std::fs::write(
|
|
||||||
store.paste_artifact_path(session_id, &first.artifact_id),
|
|
||||||
b"{}",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
store.read_paste_artifact(session_id, &first.artifact_id),
|
|
||||||
Err(StoreError::Serde(_)) | Err(StoreError::PasteArtifactIntegrity(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
//! Serializable history entries with restore-authoritative logical identity and origin.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::LoggedItem;
|
|
||||||
|
|
||||||
/// 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 = LoggedHistoryEntry {
|
|
||||||
item: LoggedItem::Message {
|
|
||||||
role: LoggedRole::Assistant,
|
|
||||||
content: vec![crate::LoggedContentPart::Text {
|
|
||||||
text: "answer".into(),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
metadata: LoggedSessionHistoryMetadata::legacy_unknown(),
|
|
||||||
};
|
|
||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
//! Versioned decoder for Session schemas that predate canonical annotated history.
|
|
||||||
//!
|
|
||||||
//! These types are intentionally private to `session-store`. Current writers,
|
|
||||||
//! replay, and public projections use [`crate::LogEntry`] exclusively; only the
|
|
||||||
//! Worker Session schema migration is allowed to deserialize these shapes.
|
|
||||||
|
|
||||||
use agen::llm_client::types::RequestConfig;
|
|
||||||
use protocol::Segment;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
LogEntry, LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId,
|
|
||||||
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry, SegmentId,
|
|
||||||
SegmentOrigin, SessionExtension, SessionId, SystemItem,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
||||||
enum LegacyHistoryLogEntry {
|
|
||||||
SegmentStart {
|
|
||||||
ts: u64,
|
|
||||||
session_id: SessionId,
|
|
||||||
system_prompt: Option<String>,
|
|
||||||
config: RequestConfig,
|
|
||||||
history: Vec<LoggedItem>,
|
|
||||||
#[serde(default)]
|
|
||||||
forked_from: Option<SegmentOrigin>,
|
|
||||||
#[serde(default)]
|
|
||||||
compacted_from: Option<SegmentOrigin>,
|
|
||||||
},
|
|
||||||
UserInput {
|
|
||||||
ts: u64,
|
|
||||||
segments: Vec<Segment>,
|
|
||||||
#[serde(default)]
|
|
||||||
extensions: Vec<SessionExtension>,
|
|
||||||
},
|
|
||||||
AssistantItem {
|
|
||||||
ts: u64,
|
|
||||||
item: LoggedItem,
|
|
||||||
},
|
|
||||||
ToolResult {
|
|
||||||
ts: u64,
|
|
||||||
item: LoggedItem,
|
|
||||||
},
|
|
||||||
SystemItem {
|
|
||||||
ts: u64,
|
|
||||||
item: SystemItem,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Schema-v1 decoder. Non-history records already had their current shape, so
|
|
||||||
/// they pass through `LogEntry`; legacy history records are converted below.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
enum LegacySessionLogEntryV1 {
|
|
||||||
History(LegacyHistoryLogEntry),
|
|
||||||
Current(LogEntry),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Schema v2 retained the v1 history shapes while adding non-history records.
|
|
||||||
/// Keep a distinct type so supported source versions remain explicit rather
|
|
||||||
/// than turning migration compatibility into the current `LogEntry` contract.
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
enum LegacySessionLogEntryV2 {
|
|
||||||
History(LegacyHistoryLogEntry),
|
|
||||||
Current(LogEntry),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn decode_entry(
|
|
||||||
schema_version: u32,
|
|
||||||
line: &str,
|
|
||||||
session_id: SessionId,
|
|
||||||
segment_id: SegmentId,
|
|
||||||
line_index: usize,
|
|
||||||
) -> Result<LogEntry, serde_json::Error> {
|
|
||||||
let entry = match schema_version {
|
|
||||||
1 => match serde_json::from_str::<LegacySessionLogEntryV1>(line)? {
|
|
||||||
LegacySessionLogEntryV1::History(entry) => Entry::History(entry),
|
|
||||||
LegacySessionLogEntryV1::Current(entry) => Entry::Current(entry),
|
|
||||||
},
|
|
||||||
2 => match serde_json::from_str::<LegacySessionLogEntryV2>(line)? {
|
|
||||||
LegacySessionLogEntryV2::History(entry) => Entry::History(entry),
|
|
||||||
LegacySessionLogEntryV2::Current(entry) => Entry::Current(entry),
|
|
||||||
},
|
|
||||||
_ => unreachable!("legacy decoder called for unsupported schema {schema_version}"),
|
|
||||||
};
|
|
||||||
Ok(match entry {
|
|
||||||
Entry::History(entry) => {
|
|
||||||
canonicalize_history_entry(session_id, segment_id, line_index, entry)
|
|
||||||
}
|
|
||||||
Entry::Current(entry) => entry,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Entry {
|
|
||||||
History(LegacyHistoryLogEntry),
|
|
||||||
Current(LogEntry),
|
|
||||||
}
|
|
||||||
|
|
||||||
fn legacy_metadata(
|
|
||||||
segment_id: SegmentId,
|
|
||||||
line_index: usize,
|
|
||||||
item_index: usize,
|
|
||||||
) -> LoggedSessionHistoryMetadata {
|
|
||||||
let mut identity = Vec::with_capacity(32);
|
|
||||||
identity.extend_from_slice(segment_id.as_bytes());
|
|
||||||
identity.extend_from_slice(&(line_index as u64).to_be_bytes());
|
|
||||||
identity.extend_from_slice(&(item_index as u64).to_be_bytes());
|
|
||||||
LoggedSessionHistoryMetadata {
|
|
||||||
entry_id: LoggedSessionHistoryEntryId(format!(
|
|
||||||
"l-{}",
|
|
||||||
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, identity)
|
|
||||||
)),
|
|
||||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
|
||||||
derivation: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn canonicalize_history_entry(
|
|
||||||
_session_id: SessionId,
|
|
||||||
segment_id: SegmentId,
|
|
||||||
line_index: usize,
|
|
||||||
entry: LegacyHistoryLogEntry,
|
|
||||||
) -> LogEntry {
|
|
||||||
match entry {
|
|
||||||
LegacyHistoryLogEntry::SegmentStart {
|
|
||||||
ts,
|
|
||||||
session_id,
|
|
||||||
system_prompt,
|
|
||||||
config,
|
|
||||||
history,
|
|
||||||
forked_from,
|
|
||||||
compacted_from,
|
|
||||||
} => LogEntry::AnnotatedSegmentStart {
|
|
||||||
ts,
|
|
||||||
session_id,
|
|
||||||
system_prompt,
|
|
||||||
config,
|
|
||||||
history: history
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(item_index, item)| LoggedHistoryEntry {
|
|
||||||
item,
|
|
||||||
metadata: legacy_metadata(segment_id, line_index, item_index),
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
forked_from,
|
|
||||||
compacted_from,
|
|
||||||
},
|
|
||||||
LegacyHistoryLogEntry::UserInput {
|
|
||||||
ts,
|
|
||||||
segments,
|
|
||||||
extensions,
|
|
||||||
} => LogEntry::AnnotatedUserInput {
|
|
||||||
ts,
|
|
||||||
history: vec![LoggedHistoryEntry {
|
|
||||||
item: LoggedItem::from(agen::Item::user_message(Segment::flatten_to_text(
|
|
||||||
&segments,
|
|
||||||
))),
|
|
||||||
metadata: legacy_metadata(segment_id, line_index, 0),
|
|
||||||
}],
|
|
||||||
segments,
|
|
||||||
extensions,
|
|
||||||
},
|
|
||||||
LegacyHistoryLogEntry::AssistantItem { ts, item } => LogEntry::AnnotatedAssistantItem {
|
|
||||||
ts,
|
|
||||||
entry: LoggedHistoryEntry {
|
|
||||||
item,
|
|
||||||
metadata: legacy_metadata(segment_id, line_index, 0),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
LegacyHistoryLogEntry::ToolResult { ts, item } => LogEntry::AnnotatedToolResult {
|
|
||||||
ts,
|
|
||||||
entry: LoggedHistoryEntry {
|
|
||||||
item,
|
|
||||||
metadata: legacy_metadata(segment_id, line_index, 0),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
LegacyHistoryLogEntry::SystemItem { ts, item } => LogEntry::AnnotatedSystemItem {
|
|
||||||
ts,
|
|
||||||
entry: LoggedSystemHistoryEntry {
|
|
||||||
item,
|
|
||||||
metadata: legacy_metadata(segment_id, line_index, 0),
|
|
||||||
},
|
|
||||||
extensions: Vec::new(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,23 +26,17 @@
|
|||||||
//! let (session_id, segment_id) = create_segment(&store, SegmentStartState {
|
//! let (session_id, segment_id) = create_segment(&store, SegmentStartState {
|
||||||
//! system_prompt: None,
|
//! system_prompt: None,
|
||||||
//! config: &config,
|
//! config: &config,
|
||||||
//! history: Vec::new(),
|
//! history: &[],
|
||||||
//! user_segments: Vec::new(),
|
|
||||||
//! })?;
|
//! })?;
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub mod event_trace;
|
pub mod event_trace;
|
||||||
pub mod fs_store;
|
pub mod fs_store;
|
||||||
pub mod history;
|
|
||||||
mod legacy_session_log;
|
|
||||||
pub mod logged_item;
|
pub mod logged_item;
|
||||||
mod paste_artifact;
|
|
||||||
pub mod public_snapshot;
|
|
||||||
pub mod segment;
|
pub mod segment;
|
||||||
pub mod segment_log;
|
pub mod segment_log;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod system_item;
|
pub mod system_item;
|
||||||
pub mod uploaded_file;
|
|
||||||
pub mod worker_metadata;
|
pub mod worker_metadata;
|
||||||
pub mod worker_session_store;
|
pub mod worker_session_store;
|
||||||
|
|
||||||
@@ -50,15 +44,9 @@ 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,
|
|
||||||
};
|
|
||||||
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
||||||
pub use paste_artifact::PasteArtifactLimits;
|
|
||||||
pub use segment::{
|
pub use segment::{
|
||||||
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
|
SegmentStartState, append_entry, append_system_item, classify_history_item,
|
||||||
create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork,
|
create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork,
|
||||||
fork_at, restore, restore_by_segment, save_config_changed, save_delta, save_extension,
|
fork_at, restore, restore_by_segment, save_config_changed, save_delta, save_extension,
|
||||||
save_run_completed, save_run_errored, save_turn_end, save_usage, save_user_input,
|
save_run_completed, save_run_errored, save_turn_end, save_usage, save_user_input,
|
||||||
@@ -68,11 +56,6 @@ pub use store::{Store, StoreError};
|
|||||||
pub use system_item::{
|
pub use system_item::{
|
||||||
PromptRenderProvenance, SystemItem, SystemReminder, SystemReminderSource, render_worker_event,
|
PromptRenderProvenance, SystemItem, SystemReminder, SystemReminderSource, render_worker_event,
|
||||||
};
|
};
|
||||||
pub use uploaded_file::{
|
|
||||||
DEFAULT_MAX_FILES_PER_SUBMISSION, DEFAULT_MAX_SESSION_ARTIFACT_BYTES,
|
|
||||||
DEFAULT_MAX_SESSION_UPLOADED_FILES, DEFAULT_MAX_UPLOADED_FILE_BYTES, UploadedFileLimits,
|
|
||||||
UploadedFileUploadContext,
|
|
||||||
};
|
|
||||||
pub use worker_metadata::{
|
pub use worker_metadata::{
|
||||||
CombinedStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerAggregateStore, WorkerMetadata,
|
CombinedStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerAggregateStore, WorkerMetadata,
|
||||||
WorkerMetadataStore, WorkerPeer, WorkerReclaimedChild, WorkerSpawnedChild,
|
WorkerMetadataStore, WorkerPeer, WorkerReclaimedChild, WorkerSpawnedChild,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
use agen::{
|
use agen::{
|
||||||
llm_client::types::{ContentPart, Item, Role},
|
llm_client::types::{ContentPart, Item, Role},
|
||||||
tool::{Attachment, ImageAttachment, ToolResultDisposition},
|
tool::{Attachment, ImageAttachment},
|
||||||
};
|
};
|
||||||
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,8 +61,6 @@ 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,
|
||||||
},
|
},
|
||||||
@@ -130,7 +128,6 @@ impl From<&Item> for LoggedItem {
|
|||||||
summary,
|
summary,
|
||||||
content,
|
content,
|
||||||
attachments,
|
attachments,
|
||||||
disposition,
|
|
||||||
is_error,
|
is_error,
|
||||||
..
|
..
|
||||||
} => Self::ToolResult {
|
} => Self::ToolResult {
|
||||||
@@ -138,7 +135,6 @@ 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 {
|
||||||
@@ -188,24 +184,15 @@ impl From<LoggedItem> for Item {
|
|||||||
summary,
|
summary,
|
||||||
content,
|
content,
|
||||||
attachments,
|
attachments,
|
||||||
disposition,
|
|
||||||
is_error,
|
is_error,
|
||||||
} => {
|
} => Item::ToolResult {
|
||||||
let disposition = if is_error && disposition.is_success() {
|
id: None,
|
||||||
ToolResultDisposition::Error
|
call_id,
|
||||||
} else {
|
summary,
|
||||||
disposition
|
content,
|
||||||
};
|
is_error,
|
||||||
Item::ToolResult {
|
attachments: attachments.into_iter().map(Attachment::from).collect(),
|
||||||
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,
|
||||||
@@ -443,42 +430,6 @@ 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(
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
//! Session-owned storage for large pasted-input artifacts.
|
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
use std::io::Write as _;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use fs4::fs_std::FileExt;
|
|
||||||
use protocol::{PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
use crate::StoreError;
|
|
||||||
|
|
||||||
/// Bounded storage policy applied before a large paste becomes durable input.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct PasteArtifactLimits {
|
|
||||||
pub max_artifact_bytes: u64,
|
|
||||||
pub max_session_bytes: u64,
|
|
||||||
pub max_session_artifacts: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for PasteArtifactLimits {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
max_artifact_bytes: 8 * 1024 * 1024,
|
|
||||||
max_session_bytes: 64 * 1024 * 1024,
|
|
||||||
max_session_artifacts: 1_024,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Integrity-bearing on-disk record. The body and metadata are committed in one
|
|
||||||
/// atomic file replacement so readers never observe a half-written artifact.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub(crate) struct StoredPasteArtifact {
|
|
||||||
pub reference: PasteArtifactRef,
|
|
||||||
pub content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn stored_paste_usage(artifact_dir: &Path) -> Result<(u64, u64), StoreError> {
|
|
||||||
if !artifact_dir.exists() {
|
|
||||||
return Ok((0, 0));
|
|
||||||
}
|
|
||||||
let mut aggregate = 0_u64;
|
|
||||||
let mut artifact_count = 0_u64;
|
|
||||||
for entry in fs::read_dir(artifact_dir)? {
|
|
||||||
let path = entry?.path();
|
|
||||||
let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if !name.ends_with(".json") || name.ends_with(".file.json") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let stored: StoredPasteArtifact = serde_json::from_slice(&fs::read(&path)?)?;
|
|
||||||
verify(&stored, &stored.reference.artifact_id)?;
|
|
||||||
artifact_count = artifact_count.checked_add(1).ok_or_else(|| {
|
|
||||||
StoreError::PasteArtifactLimit("session artifact count overflow".to_string())
|
|
||||||
})?;
|
|
||||||
aggregate = aggregate
|
|
||||||
.checked_add(stored.reference.byte_len)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Ok((aggregate, artifact_count))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn write_to_dir(
|
|
||||||
artifact_dir: &Path,
|
|
||||||
source_entry_id: &str,
|
|
||||||
content: &str,
|
|
||||||
limits: PasteArtifactLimits,
|
|
||||||
) -> Result<PasteArtifactRef, StoreError> {
|
|
||||||
let byte_len = content.len() as u64;
|
|
||||||
if byte_len > limits.max_artifact_bytes {
|
|
||||||
return Err(StoreError::PasteArtifactLimit(format!(
|
|
||||||
"artifact has {byte_len} bytes; maximum is {}",
|
|
||||||
limits.max_artifact_bytes
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
fs::create_dir_all(artifact_dir)?;
|
|
||||||
let aggregate_lock = fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(artifact_dir.join(".aggregate.lock"))?;
|
|
||||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
|
||||||
let (paste_bytes, artifact_count) = stored_paste_usage(artifact_dir)?;
|
|
||||||
let (uploaded_bytes, uploaded_count) =
|
|
||||||
crate::uploaded_file::stored_uploaded_file_usage(artifact_dir)?;
|
|
||||||
let aggregate = paste_bytes.checked_add(uploaded_bytes).ok_or_else(|| {
|
|
||||||
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
|
|
||||||
})?;
|
|
||||||
let artifact_count = artifact_count.checked_add(uploaded_count).ok_or_else(|| {
|
|
||||||
StoreError::PasteArtifactLimit("session artifact count overflow".to_string())
|
|
||||||
})?;
|
|
||||||
let projected = aggregate.checked_add(byte_len).ok_or_else(|| {
|
|
||||||
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
|
|
||||||
})?;
|
|
||||||
if projected > limits.max_session_bytes {
|
|
||||||
return Err(StoreError::PasteArtifactLimit(format!(
|
|
||||||
"session artifacts would use {projected} bytes; maximum is {}",
|
|
||||||
limits.max_session_bytes
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
if artifact_count >= limits.max_session_artifacts {
|
|
||||||
return Err(StoreError::PasteArtifactLimit(format!(
|
|
||||||
"session already has {artifact_count} artifacts; maximum is {}",
|
|
||||||
limits.max_session_artifacts
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let artifact_id = uuid::Uuid::now_v7().to_string();
|
|
||||||
let created_at_ms = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map_err(|error| StoreError::PasteArtifactIntegrity(error.to_string()))?
|
|
||||||
.as_millis() as u64;
|
|
||||||
let reference = PasteArtifactRef {
|
|
||||||
artifact_id: artifact_id.clone(),
|
|
||||||
created_at_ms,
|
|
||||||
media_type: PasteArtifactMediaType::TextPlainUtf8,
|
|
||||||
availability: PasteArtifactAvailability::Available,
|
|
||||||
byte_len,
|
|
||||||
char_count: content.chars().count() as u64,
|
|
||||||
line_count: line_count(content),
|
|
||||||
sha256: sha256_hex(content),
|
|
||||||
source_entry_id: source_entry_id.to_string(),
|
|
||||||
};
|
|
||||||
let bytes = serde_json::to_vec(&StoredPasteArtifact {
|
|
||||||
reference: reference.clone(),
|
|
||||||
content: content.to_string(),
|
|
||||||
})?;
|
|
||||||
let target = artifact_dir.join(format!("{artifact_id}.json"));
|
|
||||||
let temporary = artifact_dir.join(format!(".{artifact_id}.tmp"));
|
|
||||||
let mut file = fs::OpenOptions::new()
|
|
||||||
.create_new(true)
|
|
||||||
.write(true)
|
|
||||||
.open(&temporary)?;
|
|
||||||
if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
|
|
||||||
let _ = fs::remove_file(&temporary);
|
|
||||||
return Err(error.into());
|
|
||||||
}
|
|
||||||
if let Err(error) = fs::rename(&temporary, &target) {
|
|
||||||
let _ = fs::remove_file(&temporary);
|
|
||||||
return Err(error.into());
|
|
||||||
}
|
|
||||||
if let Ok(directory) = fs::File::open(artifact_dir) {
|
|
||||||
directory.sync_all()?;
|
|
||||||
}
|
|
||||||
Ok(reference)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn read_from_dir(
|
|
||||||
artifact_dir: &Path,
|
|
||||||
artifact_id: &str,
|
|
||||||
) -> Result<(PasteArtifactRef, String), StoreError> {
|
|
||||||
let parsed = uuid::Uuid::parse_str(artifact_id)
|
|
||||||
.map_err(|_| StoreError::PasteArtifactNotFound(artifact_id.to_string()))?;
|
|
||||||
if parsed.to_string() != artifact_id {
|
|
||||||
return Err(StoreError::PasteArtifactNotFound(artifact_id.to_string()));
|
|
||||||
}
|
|
||||||
let path = artifact_dir.join(format!("{artifact_id}.json"));
|
|
||||||
let bytes = match fs::read(path) {
|
|
||||||
Ok(bytes) => bytes,
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
|
||||||
return Err(StoreError::PasteArtifactNotFound(artifact_id.to_string()));
|
|
||||||
}
|
|
||||||
Err(error) => return Err(error.into()),
|
|
||||||
};
|
|
||||||
let stored: StoredPasteArtifact = serde_json::from_slice(&bytes)?;
|
|
||||||
verify(&stored, artifact_id)?;
|
|
||||||
Ok((stored.reference, stored.content))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify(stored: &StoredPasteArtifact, artifact_id: &str) -> Result<(), StoreError> {
|
|
||||||
let actual_digest = sha256_hex(&stored.content);
|
|
||||||
if stored.reference.artifact_id != artifact_id
|
|
||||||
|| stored.reference.created_at_ms == 0
|
|
||||||
|| stored.reference.media_type != PasteArtifactMediaType::TextPlainUtf8
|
|
||||||
|| stored.reference.availability != PasteArtifactAvailability::Available
|
|
||||||
|| stored.reference.byte_len != stored.content.len() as u64
|
|
||||||
|| stored.reference.char_count != stored.content.chars().count() as u64
|
|
||||||
|| stored.reference.line_count != line_count(&stored.content)
|
|
||||||
|| stored.reference.sha256 != actual_digest
|
|
||||||
{
|
|
||||||
return Err(StoreError::PasteArtifactIntegrity(artifact_id.to_string()));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sha256_hex(content: &str) -> String {
|
|
||||||
Sha256::digest(content.as_bytes())
|
|
||||||
.iter()
|
|
||||||
.map(|byte| format!("{byte:02x}"))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn line_count(content: &str) -> u64 {
|
|
||||||
if content.is_empty() {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
content.lines().count().max(1) as u64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,562 +0,0 @@
|
|||||||
use base64::{
|
|
||||||
Engine as _,
|
|
||||||
engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD},
|
|
||||||
};
|
|
||||||
use protocol::{
|
|
||||||
Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole, SessionSnapshot,
|
|
||||||
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole,
|
|
||||||
LoggedSessionHistoryOrigin, SessionId, SystemItem,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Project a complete current-segment log. A valid segment starts with one
|
|
||||||
/// canonical annotated SegmentStart record; malformed partial input uses the
|
|
||||||
/// nil session only to keep the public failure projection deterministic.
|
|
||||||
pub fn project_current_session_snapshot(log: &[LogEntry]) -> SessionSnapshot {
|
|
||||||
let session_id = log.iter().find_map(|entry| match entry {
|
|
||||||
LogEntry::AnnotatedSegmentStart { session_id, .. } => Some(*session_id),
|
|
||||||
_ => None,
|
|
||||||
});
|
|
||||||
project_session_snapshot(session_id.unwrap_or_else(SessionId::nil), log)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Project the current durable segment into the only public session-history
|
|
||||||
/// representation. Append-log records remain an internal persistence format.
|
|
||||||
pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> SessionSnapshot {
|
|
||||||
let mut session_key = session_id;
|
|
||||||
let mut entries = Vec::new();
|
|
||||||
|
|
||||||
for (log_index, record) in log.iter().enumerate() {
|
|
||||||
match record {
|
|
||||||
LogEntry::AnnotatedSegmentStart {
|
|
||||||
ts,
|
|
||||||
session_id,
|
|
||||||
history,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
session_key = *session_id;
|
|
||||||
entries.clear();
|
|
||||||
extend_history(&mut entries, history, None, *ts);
|
|
||||||
}
|
|
||||||
LogEntry::InputSegmentsCheckpoint { user_segments, .. } => {
|
|
||||||
let mut segments = user_segments.iter();
|
|
||||||
for entry in &mut entries {
|
|
||||||
let is_user = matches!(
|
|
||||||
&entry.data,
|
|
||||||
SessionSnapshotEntryData::UserInput { .. }
|
|
||||||
| SessionSnapshotEntryData::Message {
|
|
||||||
role: SessionMessageRole::User,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if is_user && let Some(checkpoint) = segments.next() {
|
|
||||||
entry.data = SessionSnapshotEntryData::UserInput {
|
|
||||||
segments: checkpoint.clone(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LogEntry::AnnotatedUserInput {
|
|
||||||
ts,
|
|
||||||
segments,
|
|
||||||
history,
|
|
||||||
..
|
|
||||||
} => extend_history(&mut entries, history, Some(segments), *ts),
|
|
||||||
LogEntry::AnnotatedAssistantItem { ts, entry }
|
|
||||||
| LogEntry::AnnotatedToolResult { ts, entry } => {
|
|
||||||
if let Some(data) = project_item(&entry.item) {
|
|
||||||
entries.push(history_entry(entry, *ts, data));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LogEntry::AnnotatedSystemItem { ts, entry, .. } => entries.push(system_entry(
|
|
||||||
&entry.item,
|
|
||||||
entry.metadata.entry_id.0.clone(),
|
|
||||||
*ts,
|
|
||||||
provenance(&entry.metadata.origin),
|
|
||||||
derivation_ids(entry),
|
|
||||||
)),
|
|
||||||
LogEntry::RunErrored { ts, message, .. } => entries.push(legacy_entry(
|
|
||||||
&session_key,
|
|
||||||
log_index,
|
|
||||||
0,
|
|
||||||
*ts,
|
|
||||||
SessionSnapshotEntryData::RunError {
|
|
||||||
message: message.clone(),
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
// Run checkpoints, configuration, usage, and extension state are
|
|
||||||
// controller/storage authority rather than committed conversation.
|
|
||||||
LogEntry::Invoke { .. }
|
|
||||||
| LogEntry::TurnEnd { .. }
|
|
||||||
| LogEntry::RunCompleted { .. }
|
|
||||||
| LogEntry::ActiveRunCheckpoint { .. }
|
|
||||||
| LogEntry::PausedTurnAbandoned { .. }
|
|
||||||
| LogEntry::ConfigChanged { .. }
|
|
||||||
| LogEntry::LlmUsage { .. }
|
|
||||||
| LogEntry::Extension { .. } => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SessionSnapshot {
|
|
||||||
pending_submissions: protocol::PendingSubmissionsSnapshot::default(),
|
|
||||||
entries,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extend_history(
|
|
||||||
output: &mut Vec<SessionSnapshotEntry>,
|
|
||||||
history: &[LoggedHistoryEntry],
|
|
||||||
input_segments: Option<&Vec<Segment>>,
|
|
||||||
timestamp: u64,
|
|
||||||
) {
|
|
||||||
let mut attached_segments = false;
|
|
||||||
for entry in history {
|
|
||||||
let data = if !attached_segments
|
|
||||||
&& input_segments.is_some()
|
|
||||||
&& matches!(
|
|
||||||
&entry.item,
|
|
||||||
LoggedItem::Message {
|
|
||||||
role: LoggedRole::User,
|
|
||||||
..
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
attached_segments = true;
|
|
||||||
SessionSnapshotEntryData::UserInput {
|
|
||||||
segments: input_segments.cloned().unwrap_or_default(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let Some(data) = project_item(&entry.item) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
data
|
|
||||||
};
|
|
||||||
output.push(history_entry(entry, timestamp, data));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn history_entry(
|
|
||||||
entry: &LoggedHistoryEntry,
|
|
||||||
timestamp: u64,
|
|
||||||
data: SessionSnapshotEntryData,
|
|
||||||
) -> SessionSnapshotEntry {
|
|
||||||
SessionSnapshotEntry {
|
|
||||||
entry_id: entry.metadata.entry_id.0.clone(),
|
|
||||||
timestamp,
|
|
||||||
provenance: provenance(&entry.metadata.origin),
|
|
||||||
derived_from: entry
|
|
||||||
.metadata
|
|
||||||
.derivation
|
|
||||||
.as_ref()
|
|
||||||
.map(|derivation| {
|
|
||||||
derivation
|
|
||||||
.sources
|
|
||||||
.iter()
|
|
||||||
.map(|source| source.0.clone())
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default(),
|
|
||||||
data,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn derivation_ids(entry: &crate::LoggedSystemHistoryEntry) -> Vec<String> {
|
|
||||||
entry
|
|
||||||
.metadata
|
|
||||||
.derivation
|
|
||||||
.as_ref()
|
|
||||||
.map(|derivation| {
|
|
||||||
derivation
|
|
||||||
.sources
|
|
||||||
.iter()
|
|
||||||
.map(|source| source.0.clone())
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn legacy_entry(
|
|
||||||
session_key: &SessionId,
|
|
||||||
log_index: usize,
|
|
||||||
item_index: usize,
|
|
||||||
timestamp: u64,
|
|
||||||
data: SessionSnapshotEntryData,
|
|
||||||
) -> SessionSnapshotEntry {
|
|
||||||
SessionSnapshotEntry {
|
|
||||||
entry_id: legacy_entry_id(session_key, log_index, item_index),
|
|
||||||
timestamp,
|
|
||||||
provenance: SessionEntryProvenance::LegacyUnknown,
|
|
||||||
derived_from: Vec::new(),
|
|
||||||
data,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn legacy_entry_id(session_key: &SessionId, log_index: usize, item_index: usize) -> String {
|
|
||||||
let mut identity = Vec::with_capacity(32);
|
|
||||||
identity.extend_from_slice(session_key.as_bytes());
|
|
||||||
identity.extend_from_slice(&(log_index as u64).to_be_bytes());
|
|
||||||
identity.extend_from_slice(&(item_index as u64).to_be_bytes());
|
|
||||||
format!("l-{}", URL_SAFE_NO_PAD.encode(identity))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance {
|
|
||||||
match origin {
|
|
||||||
LoggedSessionHistoryOrigin::HumanInput { .. } => SessionEntryProvenance::HumanInput,
|
|
||||||
LoggedSessionHistoryOrigin::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
|
|
||||||
LoggedSessionHistoryOrigin::FlowInstruction { .. } => {
|
|
||||||
SessionEntryProvenance::FlowInstruction
|
|
||||||
}
|
|
||||||
LoggedSessionHistoryOrigin::BackendInstruction { .. } => {
|
|
||||||
SessionEntryProvenance::BackendInstruction
|
|
||||||
}
|
|
||||||
LoggedSessionHistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
|
|
||||||
LoggedSessionHistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
|
|
||||||
LoggedSessionHistoryOrigin::DerivedSummary => SessionEntryProvenance::DerivedSummary,
|
|
||||||
LoggedSessionHistoryOrigin::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn project_item(item: &LoggedItem) -> Option<SessionSnapshotEntryData> {
|
|
||||||
match item {
|
|
||||||
LoggedItem::Message { role, content } => {
|
|
||||||
let role = match role {
|
|
||||||
LoggedRole::User => SessionMessageRole::User,
|
|
||||||
LoggedRole::Assistant => SessionMessageRole::Assistant,
|
|
||||||
// System prompts and instruction history never cross the public
|
|
||||||
// snapshot boundary. Typed SystemItems have separate records.
|
|
||||||
LoggedRole::System => return None,
|
|
||||||
};
|
|
||||||
Some(SessionSnapshotEntryData::Message {
|
|
||||||
role,
|
|
||||||
content: content
|
|
||||||
.iter()
|
|
||||||
.map(|part| match part {
|
|
||||||
LoggedContentPart::Text { text } => {
|
|
||||||
SessionContentPart::Text { text: text.clone() }
|
|
||||||
}
|
|
||||||
LoggedContentPart::Refusal { refusal } => SessionContentPart::Refusal {
|
|
||||||
refusal: refusal.clone(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
LoggedItem::ToolCall {
|
|
||||||
call_id,
|
|
||||||
name,
|
|
||||||
arguments,
|
|
||||||
} => Some(SessionSnapshotEntryData::ToolCall {
|
|
||||||
call_id: call_id.clone(),
|
|
||||||
name: name.clone(),
|
|
||||||
arguments: arguments.clone(),
|
|
||||||
}),
|
|
||||||
LoggedItem::ToolResult {
|
|
||||||
call_id,
|
|
||||||
summary,
|
|
||||||
content,
|
|
||||||
is_error,
|
|
||||||
attachments,
|
|
||||||
..
|
|
||||||
} => Some(SessionSnapshotEntryData::ToolResult {
|
|
||||||
call_id: call_id.clone(),
|
|
||||||
summary: summary.clone(),
|
|
||||||
content: content.clone(),
|
|
||||||
is_error: *is_error,
|
|
||||||
attachments: attachments
|
|
||||||
.iter()
|
|
||||||
.map(|attachment| match attachment {
|
|
||||||
crate::logged_item::LoggedAttachment::Image { mime_type, data } => {
|
|
||||||
SessionToolAttachment {
|
|
||||||
media_type: mime_type.clone(),
|
|
||||||
data_base64: BASE64.encode(data),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
}),
|
|
||||||
// Hidden model reasoning is never observable.
|
|
||||||
LoggedItem::Reasoning { .. } => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn system_entry(
|
|
||||||
item: &SystemItem,
|
|
||||||
entry_id: String,
|
|
||||||
timestamp: u64,
|
|
||||||
provenance: SessionEntryProvenance,
|
|
||||||
derived_from: Vec<String>,
|
|
||||||
) -> SessionSnapshotEntry {
|
|
||||||
let mut data = serde_json::to_value(item).ok();
|
|
||||||
if let Some(serde_json::Value::Object(object)) = data.as_mut() {
|
|
||||||
object.remove("prompt_provenance");
|
|
||||||
}
|
|
||||||
let item_kind = data
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|value| value.get("kind"))
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.unwrap_or("system_item")
|
|
||||||
.to_owned();
|
|
||||||
SessionSnapshotEntry {
|
|
||||||
entry_id,
|
|
||||||
timestamp,
|
|
||||||
provenance,
|
|
||||||
derived_from,
|
|
||||||
data: SessionSnapshotEntryData::SystemItem {
|
|
||||||
item_kind,
|
|
||||||
content: item.history_text(),
|
|
||||||
data,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use agen::llm_client::RequestConfig;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use crate::{
|
|
||||||
LoggedHistoryDerivation, LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata,
|
|
||||||
LoggedWorkerSubject,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn current_projection_is_stable_and_hides_reasoning_and_system_prompts() {
|
|
||||||
let session_id = crate::new_session_id();
|
|
||||||
let log = vec![LogEntry::AnnotatedSegmentStart {
|
|
||||||
ts: 1,
|
|
||||||
session_id,
|
|
||||||
system_prompt: None,
|
|
||||||
config: RequestConfig::default(),
|
|
||||||
history: vec![
|
|
||||||
LoggedItem::Message {
|
|
||||||
role: LoggedRole::System,
|
|
||||||
content: vec![LoggedContentPart::Text {
|
|
||||||
text: "secret prompt".into(),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
LoggedItem::Reasoning {
|
|
||||||
text: "secret reasoning".into(),
|
|
||||||
summary: Vec::new(),
|
|
||||||
encrypted_content: None,
|
|
||||||
signature: None,
|
|
||||||
},
|
|
||||||
LoggedItem::Message {
|
|
||||||
role: LoggedRole::Assistant,
|
|
||||||
content: vec![LoggedContentPart::Text {
|
|
||||||
text: "visible".into(),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.map(|item| LoggedHistoryEntry {
|
|
||||||
item,
|
|
||||||
metadata: LoggedSessionHistoryMetadata {
|
|
||||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
|
||||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
|
||||||
derivation: None,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.collect(),
|
|
||||||
forked_from: None,
|
|
||||||
compacted_from: None,
|
|
||||||
}];
|
|
||||||
|
|
||||||
let first = project_session_snapshot(session_id, &log);
|
|
||||||
let second = project_session_snapshot(session_id, &log);
|
|
||||||
assert_eq!(first, second);
|
|
||||||
assert_eq!(first.entries.len(), 1);
|
|
||||||
assert_eq!(first.entries[0].timestamp, 1);
|
|
||||||
assert_eq!(
|
|
||||||
first.entries[0].provenance,
|
|
||||||
SessionEntryProvenance::LegacyUnknown
|
|
||||||
);
|
|
||||||
let json = serde_json::to_string(&first).unwrap();
|
|
||||||
assert!(!json.contains("secret prompt"));
|
|
||||||
assert!(!json.contains("secret reasoning"));
|
|
||||||
assert!(json.contains("visible"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compacted_checkpoint_restores_uploaded_file_segments() {
|
|
||||||
let session_id = crate::new_session_id();
|
|
||||||
let user_entry_id = LoggedSessionHistoryEntryId::new();
|
|
||||||
let file = protocol::UploadedFileRef {
|
|
||||||
artifact_id: "019ca7c8-57b6-7f05-8edf-524147aba7b3".into(),
|
|
||||||
file_name: "notes.md".into(),
|
|
||||||
media_type: "text/markdown".into(),
|
|
||||||
created_at_ms: 7,
|
|
||||||
availability: protocol::UploadedFileAvailability::Available,
|
|
||||||
byte_len: 12,
|
|
||||||
sha256: "a".repeat(64),
|
|
||||||
source_entry_id: Some(user_entry_id.0.clone()),
|
|
||||||
};
|
|
||||||
let segment = Segment::UploadedFile { file };
|
|
||||||
let log = vec![
|
|
||||||
LogEntry::AnnotatedSegmentStart {
|
|
||||||
ts: 10,
|
|
||||||
session_id,
|
|
||||||
system_prompt: None,
|
|
||||||
config: RequestConfig::default(),
|
|
||||||
history: vec![LoggedHistoryEntry {
|
|
||||||
item: LoggedItem::Message {
|
|
||||||
role: LoggedRole::User,
|
|
||||||
content: vec![LoggedContentPart::Text {
|
|
||||||
text: "[Attached file: notes.md]".into(),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
metadata: LoggedSessionHistoryMetadata {
|
|
||||||
entry_id: user_entry_id,
|
|
||||||
origin: LoggedSessionHistoryOrigin::HumanInput {
|
|
||||||
account_id: "account-1".into(),
|
|
||||||
},
|
|
||||||
derivation: None,
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
forked_from: None,
|
|
||||||
compacted_from: Some(crate::SegmentOrigin {
|
|
||||||
segment_id: crate::new_segment_id(),
|
|
||||||
at_turn_index: 1,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
LogEntry::InputSegmentsCheckpoint {
|
|
||||||
ts: 10,
|
|
||||||
user_segments: vec![vec![segment.clone()]],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let snapshot = project_current_session_snapshot(&log);
|
|
||||||
assert_eq!(
|
|
||||||
snapshot.entries[0].data,
|
|
||||||
SessionSnapshotEntryData::UserInput {
|
|
||||||
segments: vec![segment]
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotated_user_input_attaches_segments_to_first_user_role_entry_for_any_origin() {
|
|
||||||
let session_id = crate::new_session_id();
|
|
||||||
let segments = vec![Segment::Text {
|
|
||||||
content: "normal submit".into(),
|
|
||||||
}];
|
|
||||||
|
|
||||||
for origin in [
|
|
||||||
LoggedSessionHistoryOrigin::LegacyUnknown,
|
|
||||||
LoggedSessionHistoryOrigin::FlowInstruction {
|
|
||||||
selector: "builtin:coder-review".into(),
|
|
||||||
definition_id: "flow-definition".into(),
|
|
||||||
definition_revision: 7,
|
|
||||||
instance_id: "flow-instance".into(),
|
|
||||||
state_id: "implement".into(),
|
|
||||||
},
|
|
||||||
] {
|
|
||||||
let user_entry_id = LoggedSessionHistoryEntryId::new();
|
|
||||||
let source_entry_id = LoggedSessionHistoryEntryId::new();
|
|
||||||
let log = vec![
|
|
||||||
LogEntry::AnnotatedSegmentStart {
|
|
||||||
ts: 1,
|
|
||||||
session_id,
|
|
||||||
system_prompt: None,
|
|
||||||
config: RequestConfig::default(),
|
|
||||||
history: Vec::new(),
|
|
||||||
forked_from: None,
|
|
||||||
compacted_from: None,
|
|
||||||
},
|
|
||||||
LogEntry::AnnotatedUserInput {
|
|
||||||
ts: 2,
|
|
||||||
segments: segments.clone(),
|
|
||||||
history: vec![
|
|
||||||
LoggedHistoryEntry {
|
|
||||||
item: LoggedItem::Message {
|
|
||||||
role: LoggedRole::System,
|
|
||||||
content: vec![LoggedContentPart::Text {
|
|
||||||
text: "flow instruction".into(),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
metadata: LoggedSessionHistoryMetadata {
|
|
||||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
|
||||||
origin: LoggedSessionHistoryOrigin::FlowInstruction {
|
|
||||||
selector: "builtin:coder-review".into(),
|
|
||||||
definition_id: "flow-definition".into(),
|
|
||||||
definition_revision: 7,
|
|
||||||
instance_id: "flow-instance".into(),
|
|
||||||
state_id: "implement".into(),
|
|
||||||
},
|
|
||||||
derivation: None,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
LoggedHistoryEntry {
|
|
||||||
item: LoggedItem::Message {
|
|
||||||
role: LoggedRole::User,
|
|
||||||
content: vec![LoggedContentPart::Text {
|
|
||||||
text: "normal submit".into(),
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
metadata: LoggedSessionHistoryMetadata {
|
|
||||||
entry_id: user_entry_id.clone(),
|
|
||||||
origin: origin.clone(),
|
|
||||||
derivation: Some(LoggedHistoryDerivation {
|
|
||||||
sources: vec![source_entry_id.clone()],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
extensions: Vec::new(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let snapshot = project_current_session_snapshot(&log);
|
|
||||||
assert_eq!(snapshot.entries.len(), 1);
|
|
||||||
assert_eq!(snapshot.entries[0].entry_id, user_entry_id.0);
|
|
||||||
assert_eq!(snapshot.entries[0].provenance, provenance(&origin));
|
|
||||||
assert_eq!(snapshot.entries[0].derived_from, vec![source_entry_id.0]);
|
|
||||||
assert_eq!(
|
|
||||||
snapshot.entries[0].data,
|
|
||||||
SessionSnapshotEntryData::UserInput {
|
|
||||||
segments: segments.clone(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn annotated_projection_preserves_identity_and_provenance() {
|
|
||||||
let session_id = crate::new_session_id();
|
|
||||||
let metadata = LoggedSessionHistoryMetadata {
|
|
||||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
|
||||||
origin: LoggedSessionHistoryOrigin::ModelOutput {
|
|
||||||
worker: LoggedWorkerSubject {
|
|
||||||
workspace_id: None,
|
|
||||||
runtime_id: None,
|
|
||||||
worker_id: "worker".into(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
derivation: None,
|
|
||||||
};
|
|
||||||
let expected_id = metadata.entry_id.0.clone();
|
|
||||||
let log = vec![LogEntry::AnnotatedSegmentStart {
|
|
||||||
ts: 1,
|
|
||||||
session_id,
|
|
||||||
system_prompt: None,
|
|
||||||
config: RequestConfig::default(),
|
|
||||||
history: vec![LoggedHistoryEntry {
|
|
||||||
item: LoggedItem::Message {
|
|
||||||
role: LoggedRole::Assistant,
|
|
||||||
content: vec![LoggedContentPart::Text { text: "ok".into() }],
|
|
||||||
},
|
|
||||||
metadata,
|
|
||||||
}],
|
|
||||||
forked_from: None,
|
|
||||||
compacted_from: None,
|
|
||||||
}];
|
|
||||||
|
|
||||||
let snapshot = project_session_snapshot(session_id, &log);
|
|
||||||
assert_eq!(snapshot.entries[0].entry_id, expected_id);
|
|
||||||
assert_eq!(
|
|
||||||
snapshot.entries[0].provenance,
|
|
||||||
SessionEntryProvenance::ModelOutput
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,11 @@
|
|||||||
//! The caller (typically Worker) holds the Engine directly and calls these
|
//! The caller (typically Worker) holds the Engine directly and calls these
|
||||||
//! functions after state-mutating operations.
|
//! functions after state-mutating operations.
|
||||||
|
|
||||||
|
use crate::logged_item::{LoggedItem, to_logged};
|
||||||
use crate::segment_log::{self, LogEntry, SegmentOrigin};
|
use crate::segment_log::{self, LogEntry, SegmentOrigin};
|
||||||
use crate::store::{Store, StoreError};
|
use crate::store::{Store, StoreError};
|
||||||
use crate::{LoggedHistoryEntry, LoggedSystemHistoryEntry, SegmentId, SessionId};
|
use crate::system_item::SystemItem;
|
||||||
|
use crate::{SegmentId, SessionId};
|
||||||
use agen::EngineResult;
|
use agen::EngineResult;
|
||||||
use agen::llm_client::RequestConfig;
|
use agen::llm_client::RequestConfig;
|
||||||
use agen::llm_client::types::Item;
|
use agen::llm_client::types::Item;
|
||||||
@@ -16,34 +18,7 @@ use protocol::Segment;
|
|||||||
pub struct SegmentStartState<'a> {
|
pub struct SegmentStartState<'a> {
|
||||||
pub system_prompt: Option<&'a str>,
|
pub system_prompt: Option<&'a str>,
|
||||||
pub config: &'a RequestConfig,
|
pub config: &'a RequestConfig,
|
||||||
pub history: Vec<LoggedHistoryEntry>,
|
pub history: &'a [Item],
|
||||||
pub user_segments: Vec<Vec<Segment>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn seed_entries(
|
|
||||||
ts: u64,
|
|
||||||
session_id: SessionId,
|
|
||||||
state: SegmentStartState<'_>,
|
|
||||||
forked_from: Option<SegmentOrigin>,
|
|
||||||
compacted_from: Option<SegmentOrigin>,
|
|
||||||
) -> Vec<LogEntry> {
|
|
||||||
let entry = LogEntry::AnnotatedSegmentStart {
|
|
||||||
ts,
|
|
||||||
session_id,
|
|
||||||
system_prompt: state.system_prompt.map(String::from),
|
|
||||||
config: state.config.clone(),
|
|
||||||
history: state.history,
|
|
||||||
forked_from,
|
|
||||||
compacted_from,
|
|
||||||
};
|
|
||||||
let mut entries = vec![entry];
|
|
||||||
if !state.user_segments.is_empty() {
|
|
||||||
entries.push(LogEntry::InputSegmentsCheckpoint {
|
|
||||||
ts,
|
|
||||||
user_segments: state.user_segments,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
entries
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new session + initial segment, writing the initial
|
/// Create a new session + initial segment, writing the initial
|
||||||
@@ -69,8 +44,16 @@ pub fn create_segment_with_ids(
|
|||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
state: SegmentStartState<'_>,
|
state: SegmentStartState<'_>,
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
let entries = seed_entries(segment_log::now_millis(), session_id, state, None, None);
|
let entry = LogEntry::SegmentStart {
|
||||||
store.create_segment(session_id, segment_id, &entries)
|
ts: segment_log::now_millis(),
|
||||||
|
session_id,
|
||||||
|
system_prompt: state.system_prompt.map(String::from),
|
||||||
|
config: state.config.clone(),
|
||||||
|
history: to_logged(state.history),
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: None,
|
||||||
|
};
|
||||||
|
store.append(session_id, segment_id, &entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a compacted segment from an existing one. Inherits the source's
|
/// Create a compacted segment from an existing one. Inherits the source's
|
||||||
@@ -87,17 +70,19 @@ pub fn create_compacted_segment(
|
|||||||
source_turn_count: usize,
|
source_turn_count: usize,
|
||||||
) -> Result<SegmentId, StoreError> {
|
) -> Result<SegmentId, StoreError> {
|
||||||
let segment_id = crate::new_segment_id();
|
let segment_id = crate::new_segment_id();
|
||||||
let entries = seed_entries(
|
let entry = LogEntry::SegmentStart {
|
||||||
segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
source_session_id,
|
session_id: source_session_id,
|
||||||
state,
|
system_prompt: state.system_prompt.map(String::from),
|
||||||
None,
|
config: state.config.clone(),
|
||||||
Some(SegmentOrigin {
|
history: to_logged(state.history),
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: Some(SegmentOrigin {
|
||||||
segment_id: source_segment_id,
|
segment_id: source_segment_id,
|
||||||
at_turn_index: source_turn_count,
|
at_turn_index: source_turn_count,
|
||||||
}),
|
}),
|
||||||
);
|
};
|
||||||
store.create_segment(source_session_id, segment_id, &entries)?;
|
store.append(source_session_id, segment_id, &entry)?;
|
||||||
Ok(segment_id)
|
Ok(segment_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,19 +154,21 @@ pub fn ensure_head_or_fork(
|
|||||||
}
|
}
|
||||||
let source_segment_id = *segment_id;
|
let source_segment_id = *segment_id;
|
||||||
let fork_id = crate::new_segment_id();
|
let fork_id = crate::new_segment_id();
|
||||||
let entries = seed_entries(
|
let entry = LogEntry::SegmentStart {
|
||||||
segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
session_id,
|
session_id,
|
||||||
state,
|
system_prompt: state.system_prompt.map(String::from),
|
||||||
Some(SegmentOrigin {
|
config: state.config.clone(),
|
||||||
|
history: to_logged(state.history),
|
||||||
|
forked_from: Some(SegmentOrigin {
|
||||||
segment_id: source_segment_id,
|
segment_id: source_segment_id,
|
||||||
at_turn_index,
|
at_turn_index,
|
||||||
}),
|
}),
|
||||||
None,
|
compacted_from: None,
|
||||||
);
|
};
|
||||||
store.create_segment(session_id, fork_id, &entries)?;
|
store.create_segment(session_id, fork_id, &[entry])?;
|
||||||
*segment_id = fork_id;
|
*segment_id = fork_id;
|
||||||
*entries_written = entries.len();
|
*entries_written = 1;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,9 +183,8 @@ pub fn save_user_input(
|
|||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
segments: Vec<Segment>,
|
segments: Vec<Segment>,
|
||||||
history: Vec<LoggedHistoryEntry>,
|
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
save_user_input_with_extensions(store, session_id, segment_id, segments, history, Vec::new())
|
save_user_input_with_extensions(store, session_id, segment_id, segments, Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically persist one typed user submission and Runtime-owned session
|
/// Atomically persist one typed user submission and Runtime-owned session
|
||||||
@@ -208,17 +194,15 @@ pub fn save_user_input_with_extensions(
|
|||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
segments: Vec<Segment>,
|
segments: Vec<Segment>,
|
||||||
history: Vec<LoggedHistoryEntry>,
|
|
||||||
extensions: Vec<segment_log::SessionExtension>,
|
extensions: Vec<segment_log::SessionExtension>,
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
append_entry(
|
append_entry(
|
||||||
store,
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
segment_id,
|
segment_id,
|
||||||
LogEntry::AnnotatedUserInput {
|
LogEntry::UserInput {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
segments,
|
segments,
|
||||||
history,
|
|
||||||
extensions,
|
extensions,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -236,58 +220,64 @@ pub fn save_delta(
|
|||||||
store: &impl Store,
|
store: &impl Store,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
new_items: &[LoggedHistoryEntry],
|
new_items: &[Item],
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
if new_items.is_empty() {
|
if new_items.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let ts = segment_log::now_millis();
|
let ts = segment_log::now_millis();
|
||||||
for entry in new_items {
|
for item in new_items {
|
||||||
let item = Item::from(entry.item.clone());
|
|
||||||
if item.is_user_message() {
|
if item.is_user_message() {
|
||||||
// Already persisted by save_user_input at submit time.
|
// Already persisted by save_user_input at submit time.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let entry = classify_logged_history_entry(entry.clone(), ts);
|
let entry = classify_history_item(item, ts);
|
||||||
append_entry(store, session_id, segment_id, entry)?;
|
append_entry(store, session_id, segment_id, entry)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map one annotated history entry to its singular `LogEntry` form. Used by
|
/// Map one history item to its singular `LogEntry` form. Used by the
|
||||||
/// the fallback `save_delta` path and the controller's worker-callback
|
/// fallback `save_delta` path and the controller's worker-callback
|
||||||
/// classifier so write classification lives in one place without discarding
|
/// classifier so write classification lives in one place.
|
||||||
/// identity or provenance.
|
pub fn classify_history_item(item: &Item, ts: u64) -> LogEntry {
|
||||||
/// Map one already-annotated history entry to its singular canonical record
|
|
||||||
/// without changing its identity or provenance.
|
|
||||||
pub fn classify_logged_history_entry(entry: LoggedHistoryEntry, ts: u64) -> LogEntry {
|
|
||||||
let item = Item::from(entry.item.clone());
|
|
||||||
if item.is_tool_result() {
|
if item.is_tool_result() {
|
||||||
LogEntry::AnnotatedToolResult { ts, entry }
|
LogEntry::ToolResult {
|
||||||
|
ts,
|
||||||
|
item: LoggedItem::from(item),
|
||||||
|
}
|
||||||
|
} else if item.is_assistant_message() || item.is_tool_call() || item.is_reasoning() {
|
||||||
|
LogEntry::AssistantItem {
|
||||||
|
ts,
|
||||||
|
item: LoggedItem::from(item),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Assistant messages, tool calls, reasoning, and future non-user
|
// Defensive: anything else (future Item kinds) routes through
|
||||||
// items all use the assistant-side canonical record.
|
// AssistantItem rather than getting silently dropped.
|
||||||
LogEntry::AnnotatedAssistantItem { ts, entry }
|
LogEntry::AssistantItem {
|
||||||
|
ts,
|
||||||
|
item: LoggedItem::from(item),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append one typed system item and its history metadata as a canonical
|
/// Append a single typed system item as `LogEntry::SystemItem`. Helper
|
||||||
/// `LogEntry::AnnotatedSystemItem`.
|
/// for the Worker-side interceptor commit path; mirrors the per-item
|
||||||
|
/// commit shape used for assistant / tool result entries.
|
||||||
pub fn append_system_item(
|
pub fn append_system_item(
|
||||||
store: &impl Store,
|
store: &impl Store,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
entry: LoggedSystemHistoryEntry,
|
item: SystemItem,
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
append_entry(
|
append_entry(
|
||||||
store,
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
segment_id,
|
segment_id,
|
||||||
LogEntry::AnnotatedSystemItem {
|
LogEntry::SystemItem {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
entry,
|
item,
|
||||||
extensions: Vec::new(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -317,7 +307,6 @@ pub fn save_run_completed(
|
|||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
result: EngineResult,
|
result: EngineResult,
|
||||||
interrupted: bool,
|
interrupted: bool,
|
||||||
active_run_turn_count: Option<usize>,
|
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
append_entry(
|
append_entry(
|
||||||
store,
|
store,
|
||||||
@@ -327,7 +316,6 @@ pub fn save_run_completed(
|
|||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
interrupted,
|
interrupted,
|
||||||
result,
|
result,
|
||||||
active_run_turn_count,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -436,14 +424,20 @@ pub fn save_config_changed(
|
|||||||
/// [`fork_at`] or [`ensure_head_or_fork`] instead.
|
/// [`fork_at`] or [`ensure_head_or_fork`] instead.
|
||||||
pub fn fork(
|
pub fn fork(
|
||||||
store: &impl Store,
|
store: &impl Store,
|
||||||
source_session_id: SessionId,
|
|
||||||
state: SegmentStartState<'_>,
|
state: SegmentStartState<'_>,
|
||||||
) -> Result<(SessionId, SegmentId), StoreError> {
|
) -> Result<(SessionId, SegmentId), StoreError> {
|
||||||
let session_id = crate::new_session_id();
|
let session_id = crate::new_session_id();
|
||||||
let fork_id = crate::new_segment_id();
|
let fork_id = crate::new_segment_id();
|
||||||
let entries = seed_entries(segment_log::now_millis(), session_id, state, None, None);
|
let entry = LogEntry::SegmentStart {
|
||||||
store.create_segment(session_id, fork_id, &entries)?;
|
ts: segment_log::now_millis(),
|
||||||
store.copy_committed_uploaded_files(source_session_id, session_id)?;
|
session_id,
|
||||||
|
system_prompt: state.system_prompt.map(String::from),
|
||||||
|
config: state.config.clone(),
|
||||||
|
history: to_logged(state.history),
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: None,
|
||||||
|
};
|
||||||
|
store.create_segment(session_id, fork_id, &[entry])?;
|
||||||
Ok((session_id, fork_id))
|
Ok((session_id, fork_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,18 +464,11 @@ pub fn fork_at(
|
|||||||
) -> Result<SegmentId, StoreError> {
|
) -> Result<SegmentId, StoreError> {
|
||||||
let entries = store.read_all(source_session_id, source_id)?;
|
let entries = store.read_all(source_session_id, source_id)?;
|
||||||
let cut = if at_turn_index == 0 {
|
let cut = if at_turn_index == 0 {
|
||||||
// Branch from the seeded state before any new turn completes. A typed
|
// Branch directly after the SegmentStart (or whatever opens the
|
||||||
// input checkpoint immediately following SegmentStart is part of that
|
// segment), before any turn completes.
|
||||||
// seed and must stay atomic with its annotated history.
|
|
||||||
entries
|
entries
|
||||||
.iter()
|
.iter()
|
||||||
.position(|entry| {
|
.position(|e| !matches!(e, LogEntry::SegmentStart { .. }))
|
||||||
!matches!(
|
|
||||||
entry,
|
|
||||||
LogEntry::AnnotatedSegmentStart { .. }
|
|
||||||
| LogEntry::InputSegmentsCheckpoint { .. }
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or(entries.len())
|
.unwrap_or(entries.len())
|
||||||
} else {
|
} else {
|
||||||
entries
|
entries
|
||||||
@@ -493,27 +480,19 @@ pub fn fork_at(
|
|||||||
let state = segment_log::collect_state(&entries[..cut]);
|
let state = segment_log::collect_state(&entries[..cut]);
|
||||||
|
|
||||||
let fork_id = crate::new_segment_id();
|
let fork_id = crate::new_segment_id();
|
||||||
let ts = segment_log::now_millis();
|
let entry = LogEntry::SegmentStart {
|
||||||
let entry = LogEntry::AnnotatedSegmentStart {
|
ts: segment_log::now_millis(),
|
||||||
ts,
|
|
||||||
session_id: source_session_id,
|
session_id: source_session_id,
|
||||||
system_prompt: state.system_prompt,
|
system_prompt: state.system_prompt,
|
||||||
config: state.config,
|
config: state.config,
|
||||||
history: state.annotated_history,
|
history: to_logged(&state.history),
|
||||||
forked_from: Some(SegmentOrigin {
|
forked_from: Some(SegmentOrigin {
|
||||||
segment_id: source_id,
|
segment_id: source_id,
|
||||||
at_turn_index,
|
at_turn_index,
|
||||||
}),
|
}),
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
};
|
};
|
||||||
let mut fork_entries = vec![entry];
|
store.create_segment(source_session_id, fork_id, &[entry])?;
|
||||||
if !state.user_segments.is_empty() {
|
|
||||||
fork_entries.push(LogEntry::InputSegmentsCheckpoint {
|
|
||||||
ts,
|
|
||||||
user_segments: state.user_segments,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
store.create_segment(source_session_id, fork_id, &fork_entries)?;
|
|
||||||
Ok(fork_id)
|
Ok(fork_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ 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;
|
||||||
|
|
||||||
/// A single segment log entry, serialized as one JSONL line.
|
/// A single segment log entry, serialized as one JSONL line.
|
||||||
///
|
///
|
||||||
@@ -49,28 +49,27 @@ impl SessionExtension {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum LogEntry {
|
pub enum LogEntry {
|
||||||
/// Canonical segment seed. Retained entries keep their stable logical
|
/// Segment start. Always the first entry in a segment log.
|
||||||
/// identity and origin across fork/compaction/restore.
|
/// For forked segments, `history` contains the seed state from the parent.
|
||||||
AnnotatedSegmentStart {
|
SegmentStart {
|
||||||
ts: u64,
|
ts: u64,
|
||||||
|
/// Session this segment belongs to. Compaction / fork inherits
|
||||||
|
/// the source segment's session_id; only fresh "new conversation"
|
||||||
|
/// segments mint a new session_id.
|
||||||
session_id: crate::SessionId,
|
session_id: crate::SessionId,
|
||||||
system_prompt: Option<String>,
|
system_prompt: Option<String>,
|
||||||
config: RequestConfig,
|
config: RequestConfig,
|
||||||
history: Vec<LoggedHistoryEntry>,
|
history: Vec<LoggedItem>,
|
||||||
|
/// Origin: forked from a sibling segment at a specific turn boundary.
|
||||||
|
/// The referenced segment is guaranteed to share `session_id`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
forked_from: Option<SegmentOrigin>,
|
forked_from: Option<SegmentOrigin>,
|
||||||
|
/// Origin: compacted from a sibling segment at a specific turn boundary.
|
||||||
|
/// The referenced segment is guaranteed to share `session_id`.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
compacted_from: Option<SegmentOrigin>,
|
compacted_from: Option<SegmentOrigin>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Typed user-segment projection accompanying a compacted or forked
|
|
||||||
/// SegmentStart history snapshot. This keeps attachment identity and
|
|
||||||
/// metadata aligned with retained user entries without embedding bodies.
|
|
||||||
InputSegmentsCheckpoint {
|
|
||||||
ts: u64,
|
|
||||||
user_segments: Vec<Vec<Segment>>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// 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
|
||||||
@@ -91,45 +90,46 @@ pub enum LogEntry {
|
|||||||
/// restore conservatively instead of re-running a dangling tool call.
|
/// restore conservatively instead of re-running a dangling tool call.
|
||||||
Invoke { ts: u64, trigger: InvokeKind },
|
Invoke { ts: u64, trigger: InvokeKind },
|
||||||
|
|
||||||
/// Canonical user submission with its exact model-visible entries. Typed
|
/// User input accepted at submit time. Carries the original typed
|
||||||
/// Flow instructions and caller-attributed input remain separate entries.
|
/// `Vec<Segment>` so clients can re-render typed atoms (paste chips,
|
||||||
AnnotatedUserInput {
|
/// file refs) on segment restore.
|
||||||
|
/// Replay flattens these into a `Item::user_message` for the worker
|
||||||
|
/// history; the worker layer never sees segments directly.
|
||||||
|
UserInput {
|
||||||
ts: u64,
|
ts: u64,
|
||||||
segments: Vec<Segment>,
|
segments: Vec<Segment>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
/// Typed durable state committed atomically with this input record.
|
||||||
extensions: Vec<SessionExtension>,
|
/// Runtime-owned Flow invocation uses this to avoid a Backend-instance
|
||||||
history: Vec<LoggedHistoryEntry>,
|
/// commit that can get ahead of Worker history.
|
||||||
},
|
|
||||||
|
|
||||||
/// Canonical model output and metadata committed as one journal record.
|
|
||||||
AnnotatedAssistantItem { ts: u64, entry: LoggedHistoryEntry },
|
|
||||||
|
|
||||||
/// Canonical tool output and metadata committed as one journal record.
|
|
||||||
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
|
|
||||||
|
|
||||||
/// Canonical typed system event and model-visible metadata committed
|
|
||||||
/// together.
|
|
||||||
AnnotatedSystemItem {
|
|
||||||
ts: u64,
|
|
||||||
entry: LoggedSystemHistoryEntry,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
extensions: Vec<SessionExtension>,
|
extensions: Vec<SessionExtension>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// One assistant-side item appended to history — assistant message,
|
||||||
|
/// reasoning, or tool call. Singular: one entry per history item so
|
||||||
|
/// the wire-side `Event::*` lane and on-disk LogEntry stay 1:1.
|
||||||
|
AssistantItem { ts: u64, item: LoggedItem },
|
||||||
|
|
||||||
|
/// One tool-execution result appended to history.
|
||||||
|
ToolResult { ts: u64, item: LoggedItem },
|
||||||
|
|
||||||
|
/// One typed agent-injected system item: notification, child-Worker
|
||||||
|
/// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each
|
||||||
|
/// `SystemItem` carries kind metadata that the LLM
|
||||||
|
/// itself never sees (the LLM gets `Item::system_message` with the
|
||||||
|
/// item's denormalised `body`), but live clients and replay paths
|
||||||
|
/// dispatch on `kind` for typed rendering.
|
||||||
|
SystemItem { ts: u64, item: SystemItem },
|
||||||
|
|
||||||
/// Turn boundary. Records the turn count after increment.
|
/// Turn boundary. Records the turn count after increment.
|
||||||
TurnEnd { ts: u64, turn_count: usize },
|
TurnEnd { ts: u64, turn_count: usize },
|
||||||
|
|
||||||
/// `run()` / `resume()` が `EngineResult` で正常終了した。
|
/// `run()` / `resume()` が `EngineResult` で正常終了した。
|
||||||
/// Replay restores both interruption state and any resumable logical-run
|
/// Audit-only metadata: replay は `interrupted` のみ反映する。
|
||||||
/// turn budget.
|
|
||||||
RunCompleted {
|
RunCompleted {
|
||||||
ts: u64,
|
ts: u64,
|
||||||
interrupted: bool,
|
interrupted: bool,
|
||||||
result: EngineResult,
|
result: EngineResult,
|
||||||
/// AgentTurns consumed by a paused/yielded logical run. Terminal
|
|
||||||
/// outcomes persist `None`.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
active_run_turn_count: Option<usize>,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/// `run()` / `resume()` が `EngineError` で終了した。
|
/// `run()` / `resume()` が `EngineError` で終了した。
|
||||||
@@ -141,15 +141,6 @@ pub enum LogEntry {
|
|||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Restores an active logical-run budget at a segment boundary, notably
|
|
||||||
/// after compaction replaced the segment that held the original Invoke and
|
|
||||||
/// RunCompleted entries.
|
|
||||||
ActiveRunCheckpoint {
|
|
||||||
ts: u64,
|
|
||||||
active_turn_count: usize,
|
|
||||||
total_turn_count: usize,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// A paused interrupted turn was explicitly abandoned without calling
|
/// A paused interrupted turn was explicitly abandoned without calling
|
||||||
/// `run()` or `resume()` again. Replay clears the interrupted marker so
|
/// `run()` or `resume()` again. Replay clears the interrupted marker so
|
||||||
/// the restored Worker is idle and future user input starts a normal new turn.
|
/// the restored Worker is idle and future user input starts a normal new turn.
|
||||||
@@ -217,13 +208,7 @@ pub struct RestoredState {
|
|||||||
pub system_prompt: Option<String>,
|
pub system_prompt: Option<String>,
|
||||||
pub config: RequestConfig,
|
pub config: RequestConfig,
|
||||||
pub history: Vec<Item>,
|
pub history: Vec<Item>,
|
||||||
/// Canonical persisted history with stable identity and provenance. This is
|
|
||||||
/// the authority for rewrites, forks, and annotated restore; `history` is
|
|
||||||
/// retained as the model-facing item projection.
|
|
||||||
pub annotated_history: Vec<LoggedHistoryEntry>,
|
|
||||||
pub turn_count: usize,
|
pub turn_count: usize,
|
||||||
/// AgentTurns consumed by the active paused/yielded logical run.
|
|
||||||
pub active_run_turn_count: Option<usize>,
|
|
||||||
pub last_run_interrupted: bool,
|
pub last_run_interrupted: bool,
|
||||||
/// Number of entries replayed. `0` means the segment log was empty.
|
/// Number of entries replayed. `0` means the segment log was empty.
|
||||||
/// Writers track their own append count via the same counter so
|
/// Writers track their own append count via the same counter so
|
||||||
@@ -237,7 +222,7 @@ pub struct RestoredState {
|
|||||||
/// session-store は domain を不透明扱いし、各ドメインが自前で fold する。
|
/// session-store は domain を不透明扱いし、各ドメインが自前で fold する。
|
||||||
pub extensions: Vec<(String, serde_json::Value)>,
|
pub extensions: Vec<(String, serde_json::Value)>,
|
||||||
/// User submissions in original typed form, in submit order.
|
/// User submissions in original typed form, in submit order.
|
||||||
/// One entry per `LogEntry::AnnotatedUserInput`; the K-th entry corresponds to
|
/// One entry per `LogEntry::UserInput`; the K-th entry corresponds to
|
||||||
/// the K-th `Item::user_message` derived during replay (modulo
|
/// the K-th `Item::user_message` derived during replay (modulo
|
||||||
/// pre-compaction history seeded via `SegmentStart.history`, whose
|
/// pre-compaction history seeded via `SegmentStart.history`, whose
|
||||||
/// original segments are not preserved). Used by clients to re-render
|
/// original segments are not preserved). Used by clients to re-render
|
||||||
@@ -252,9 +237,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
|||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
config: RequestConfig::default(),
|
config: RequestConfig::default(),
|
||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
annotated_history: Vec::new(),
|
|
||||||
turn_count: 0,
|
turn_count: 0,
|
||||||
active_run_turn_count: None,
|
|
||||||
last_run_interrupted: false,
|
last_run_interrupted: false,
|
||||||
entries_count: 0,
|
entries_count: 0,
|
||||||
usage_history: Vec::new(),
|
usage_history: Vec::new(),
|
||||||
@@ -266,7 +249,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
|||||||
state.entries_count += 1;
|
state.entries_count += 1;
|
||||||
|
|
||||||
match entry {
|
match entry {
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::SegmentStart {
|
||||||
session_id,
|
session_id,
|
||||||
system_prompt,
|
system_prompt,
|
||||||
config,
|
config,
|
||||||
@@ -276,32 +259,20 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
|||||||
state.session_id = Some(*session_id);
|
state.session_id = Some(*session_id);
|
||||||
state.system_prompt = system_prompt.clone();
|
state.system_prompt = system_prompt.clone();
|
||||||
state.config = config.clone();
|
state.config = config.clone();
|
||||||
state.annotated_history = history.clone();
|
state.history = history.iter().cloned().map(Item::from).collect();
|
||||||
state.history = history
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.map(|entry| Item::from(entry.item))
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
LogEntry::InputSegmentsCheckpoint { user_segments, .. } => {
|
|
||||||
state.user_segments = user_segments.clone();
|
|
||||||
}
|
}
|
||||||
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.
|
||||||
state.last_run_interrupted = true;
|
state.last_run_interrupted = true;
|
||||||
state.active_run_turn_count = Some(0);
|
|
||||||
}
|
}
|
||||||
LogEntry::AnnotatedUserInput {
|
LogEntry::UserInput {
|
||||||
segments,
|
segments,
|
||||||
extensions,
|
extensions,
|
||||||
history,
|
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
state.annotated_history.extend(history.iter().cloned());
|
let text = Segment::flatten_to_text(segments);
|
||||||
state
|
state.history.push(Item::user_message(text));
|
||||||
.history
|
|
||||||
.extend(history.iter().cloned().map(|entry| Item::from(entry.item)));
|
|
||||||
state.user_segments.push(segments.clone());
|
state.user_segments.push(segments.clone());
|
||||||
state.extensions.extend(
|
state.extensions.extend(
|
||||||
extensions
|
extensions
|
||||||
@@ -309,64 +280,26 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
|||||||
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
|
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
LogEntry::AnnotatedAssistantItem { entry, .. }
|
LogEntry::AssistantItem { item, .. } => {
|
||||||
| LogEntry::AnnotatedToolResult { entry, .. } => {
|
state.history.push(Item::from(item.clone()));
|
||||||
state.annotated_history.push(entry.clone());
|
|
||||||
state.history.push(Item::from(entry.item.clone()));
|
|
||||||
}
|
}
|
||||||
LogEntry::AnnotatedSystemItem {
|
LogEntry::ToolResult { item, .. } => {
|
||||||
entry, extensions, ..
|
state.history.push(Item::from(item.clone()));
|
||||||
} => {
|
}
|
||||||
state.annotated_history.push(LoggedHistoryEntry {
|
LogEntry::SystemItem { item, .. } => {
|
||||||
item: LoggedItem::from(entry.item.to_history_item()),
|
state.history.push(item.to_history_item());
|
||||||
metadata: entry.metadata.clone(),
|
|
||||||
});
|
|
||||||
state.history.push(entry.item.to_history_item());
|
|
||||||
state.extensions.extend(
|
|
||||||
extensions
|
|
||||||
.iter()
|
|
||||||
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
LogEntry::TurnEnd { turn_count, .. } => {
|
LogEntry::TurnEnd { turn_count, .. } => {
|
||||||
if let Some(active_turn_count) = &mut state.active_run_turn_count {
|
|
||||||
*active_turn_count += turn_count.saturating_sub(state.turn_count);
|
|
||||||
}
|
|
||||||
state.turn_count = *turn_count;
|
state.turn_count = *turn_count;
|
||||||
}
|
}
|
||||||
LogEntry::RunCompleted {
|
LogEntry::RunCompleted { interrupted, .. } => {
|
||||||
interrupted,
|
|
||||||
result,
|
|
||||||
active_run_turn_count,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
state.last_run_interrupted = *interrupted;
|
state.last_run_interrupted = *interrupted;
|
||||||
if *interrupted && matches!(result, EngineResult::Paused | EngineResult::Yielded) {
|
|
||||||
// Legacy entries omit the explicit field; retain the
|
|
||||||
// Invoke/TurnEnd-derived count in that case.
|
|
||||||
if let Some(turn_count) = active_run_turn_count {
|
|
||||||
state.active_run_turn_count = Some(*turn_count);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
state.active_run_turn_count = None;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
LogEntry::RunErrored { interrupted, .. } => {
|
LogEntry::RunErrored { interrupted, .. } => {
|
||||||
state.last_run_interrupted = *interrupted;
|
state.last_run_interrupted = *interrupted;
|
||||||
state.active_run_turn_count = None;
|
|
||||||
}
|
|
||||||
LogEntry::ActiveRunCheckpoint {
|
|
||||||
active_turn_count,
|
|
||||||
total_turn_count,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
state.active_run_turn_count = Some(*active_turn_count);
|
|
||||||
state.turn_count = *total_turn_count;
|
|
||||||
state.last_run_interrupted = true;
|
|
||||||
}
|
}
|
||||||
LogEntry::PausedTurnAbandoned { .. } => {
|
LogEntry::PausedTurnAbandoned { .. } => {
|
||||||
state.last_run_interrupted = false;
|
state.last_run_interrupted = false;
|
||||||
state.active_run_turn_count = None;
|
|
||||||
}
|
}
|
||||||
LogEntry::ConfigChanged { config, .. } => {
|
LogEntry::ConfigChanged { config, .. } => {
|
||||||
state.config = config.clone();
|
state.config = config.clone();
|
||||||
@@ -409,20 +342,6 @@ pub fn now_millis() -> u64 {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{
|
|
||||||
LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn annotated(item: Item) -> LoggedHistoryEntry {
|
|
||||||
LoggedHistoryEntry {
|
|
||||||
item: LoggedItem::from(item),
|
|
||||||
metadata: LoggedSessionHistoryMetadata {
|
|
||||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
|
||||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
|
||||||
derivation: None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_empty() {
|
fn replay_empty() {
|
||||||
@@ -434,12 +353,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_segment_start_sets_initial_state() {
|
fn replay_segment_start_sets_initial_state() {
|
||||||
let state = collect_state(&[LogEntry::AnnotatedSegmentStart {
|
let state = collect_state(&[LogEntry::SegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: Some("You are helpful.".into()),
|
system_prompt: Some("You are helpful.".into()),
|
||||||
config: RequestConfig::default().with_max_tokens(1024),
|
config: RequestConfig::default().with_max_tokens(1024),
|
||||||
history: vec![annotated(Item::user_message("seed"))],
|
history: vec![Item::user_message("seed").into()],
|
||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
}]);
|
}]);
|
||||||
@@ -452,7 +371,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_full_turn() {
|
fn replay_full_turn() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::SegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -461,15 +380,14 @@ mod tests {
|
|||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
},
|
},
|
||||||
LogEntry::AnnotatedUserInput {
|
LogEntry::UserInput {
|
||||||
ts: 2000,
|
ts: 2000,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![Segment::text("Hello")],
|
segments: vec![Segment::text("Hello")],
|
||||||
history: vec![annotated(Item::user_message("Hello"))],
|
|
||||||
},
|
},
|
||||||
LogEntry::AnnotatedAssistantItem {
|
LogEntry::AssistantItem {
|
||||||
ts: 3000,
|
ts: 3000,
|
||||||
entry: annotated(Item::assistant_message("Hi!")),
|
item: Item::assistant_message("Hi!").into(),
|
||||||
},
|
},
|
||||||
LogEntry::TurnEnd {
|
LogEntry::TurnEnd {
|
||||||
ts: 3100,
|
ts: 3100,
|
||||||
@@ -479,7 +397,6 @@ mod tests {
|
|||||||
ts: 3200,
|
ts: 3200,
|
||||||
interrupted: false,
|
interrupted: false,
|
||||||
result: EngineResult::Finished,
|
result: EngineResult::Finished,
|
||||||
active_run_turn_count: None,
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert_eq!(state.history.len(), 2);
|
assert_eq!(state.history.len(), 2);
|
||||||
@@ -490,7 +407,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_incomplete_invoke_is_interrupted() {
|
fn replay_incomplete_invoke_is_interrupted() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::SegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -503,15 +420,14 @@ mod tests {
|
|||||||
ts: 2000,
|
ts: 2000,
|
||||||
trigger: InvokeKind::UserSend,
|
trigger: InvokeKind::UserSend,
|
||||||
},
|
},
|
||||||
LogEntry::AnnotatedUserInput {
|
LogEntry::UserInput {
|
||||||
ts: 2001,
|
ts: 2001,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![Segment::text("run a tool")],
|
segments: vec![Segment::text("run a tool")],
|
||||||
history: vec![annotated(Item::user_message("run a tool"))],
|
|
||||||
},
|
},
|
||||||
LogEntry::AnnotatedAssistantItem {
|
LogEntry::AssistantItem {
|
||||||
ts: 3000,
|
ts: 3000,
|
||||||
entry: annotated(Item::tool_call("call_1", "side_effect", "{}")),
|
item: Item::tool_call("call_1", "side_effect", "{}").into(),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -521,7 +437,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_with_tool_calls() {
|
fn replay_with_tool_calls() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::SegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -530,27 +446,22 @@ mod tests {
|
|||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
},
|
},
|
||||||
LogEntry::AnnotatedUserInput {
|
LogEntry::UserInput {
|
||||||
ts: 2000,
|
ts: 2000,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![Segment::text("Check weather")],
|
segments: vec![Segment::text("Check weather")],
|
||||||
history: vec![annotated(Item::user_message("Check weather"))],
|
|
||||||
},
|
},
|
||||||
LogEntry::AnnotatedAssistantItem {
|
LogEntry::AssistantItem {
|
||||||
ts: 3000,
|
ts: 3000,
|
||||||
entry: annotated(Item::tool_call(
|
item: Item::tool_call("call_1", "get_weather", r#"{"city":"Tokyo"}"#).into(),
|
||||||
"call_1",
|
|
||||||
"get_weather",
|
|
||||||
r#"{"city":"Tokyo"}"#,
|
|
||||||
)),
|
|
||||||
},
|
},
|
||||||
LogEntry::AnnotatedToolResult {
|
LogEntry::ToolResult {
|
||||||
ts: 3500,
|
ts: 3500,
|
||||||
entry: annotated(Item::tool_result("call_1", "Sunny, 25C")),
|
item: Item::tool_result("call_1", "Sunny, 25C").into(),
|
||||||
},
|
},
|
||||||
LogEntry::AnnotatedAssistantItem {
|
LogEntry::AssistantItem {
|
||||||
ts: 4000,
|
ts: 4000,
|
||||||
entry: annotated(Item::assistant_message("It's sunny in Tokyo!")),
|
item: Item::assistant_message("It's sunny in Tokyo!").into(),
|
||||||
},
|
},
|
||||||
LogEntry::TurnEnd {
|
LogEntry::TurnEnd {
|
||||||
ts: 4100,
|
ts: 4100,
|
||||||
@@ -564,9 +475,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_restores_durable_tool_image_detail() {
|
fn replay_restores_durable_tool_image_detail() {
|
||||||
let entry = LogEntry::AnnotatedToolResult {
|
let entry = LogEntry::ToolResult {
|
||||||
ts: 3500,
|
ts: 3500,
|
||||||
entry: annotated(Item::tool_result_item_with_attachments(
|
item: Item::tool_result_item_with_attachments(
|
||||||
"call_image",
|
"call_image",
|
||||||
"attached",
|
"attached",
|
||||||
None,
|
None,
|
||||||
@@ -574,7 +485,8 @@ mod tests {
|
|||||||
vec![agen::tool::Attachment::Image(
|
vec![agen::tool::Attachment::Image(
|
||||||
agen::tool::ImageAttachment::new("image/png", b"durable-image".to_vec()),
|
agen::tool::ImageAttachment::new("image/png", b"durable-image".to_vec()),
|
||||||
)],
|
)],
|
||||||
)),
|
)
|
||||||
|
.into(),
|
||||||
};
|
};
|
||||||
let persisted = serde_json::to_string(&entry).unwrap();
|
let persisted = serde_json::to_string(&entry).unwrap();
|
||||||
let restored_entry: LogEntry = serde_json::from_str(&persisted).unwrap();
|
let restored_entry: LogEntry = serde_json::from_str(&persisted).unwrap();
|
||||||
@@ -594,7 +506,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_config_changed() {
|
fn replay_config_changed() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::SegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -614,7 +526,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_llm_usage_appends_to_usage_history() {
|
fn replay_llm_usage_appends_to_usage_history() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::SegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -623,11 +535,10 @@ mod tests {
|
|||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
},
|
},
|
||||||
LogEntry::AnnotatedUserInput {
|
LogEntry::UserInput {
|
||||||
ts: 2000,
|
ts: 2000,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![Segment::text("hi")],
|
segments: vec![Segment::text("hi")],
|
||||||
history: vec![annotated(Item::user_message("hi"))],
|
|
||||||
},
|
},
|
||||||
LogEntry::LlmUsage {
|
LogEntry::LlmUsage {
|
||||||
ts: 2100,
|
ts: 2100,
|
||||||
@@ -637,9 +548,9 @@ mod tests {
|
|||||||
cache_write_tokens: 0,
|
cache_write_tokens: 0,
|
||||||
output_tokens: 10,
|
output_tokens: 10,
|
||||||
},
|
},
|
||||||
LogEntry::AnnotatedAssistantItem {
|
LogEntry::AssistantItem {
|
||||||
ts: 2200,
|
ts: 2200,
|
||||||
entry: annotated(Item::assistant_message("yo")),
|
item: Item::assistant_message("yo").into(),
|
||||||
},
|
},
|
||||||
LogEntry::LlmUsage {
|
LogEntry::LlmUsage {
|
||||||
ts: 3100,
|
ts: 3100,
|
||||||
@@ -663,7 +574,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_without_llm_usage_keeps_usage_history_empty() {
|
fn replay_without_llm_usage_keeps_usage_history_empty() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::SegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -672,11 +583,10 @@ mod tests {
|
|||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
},
|
},
|
||||||
LogEntry::AnnotatedUserInput {
|
LogEntry::UserInput {
|
||||||
ts: 2000,
|
ts: 2000,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![Segment::text("hi")],
|
segments: vec![Segment::text("hi")],
|
||||||
history: vec![annotated(Item::user_message("hi"))],
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert!(state.usage_history.is_empty());
|
assert!(state.usage_history.is_empty());
|
||||||
@@ -737,7 +647,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_invoke_marker_only_mutates_interrupted_state() {
|
fn replay_invoke_marker_only_mutates_interrupted_state() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::SegmentStart {
|
||||||
ts: 0,
|
ts: 0,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -750,11 +660,10 @@ mod tests {
|
|||||||
ts: 100,
|
ts: 100,
|
||||||
trigger: InvokeKind::UserSend,
|
trigger: InvokeKind::UserSend,
|
||||||
},
|
},
|
||||||
LogEntry::AnnotatedUserInput {
|
LogEntry::UserInput {
|
||||||
ts: 101,
|
ts: 101,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![Segment::text("hi")],
|
segments: vec![Segment::text("hi")],
|
||||||
history: vec![annotated(Item::user_message("hi"))],
|
|
||||||
},
|
},
|
||||||
LogEntry::TurnEnd {
|
LogEntry::TurnEnd {
|
||||||
ts: 200,
|
ts: 200,
|
||||||
@@ -773,7 +682,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_paused_turn_abandoned_clears_interrupted_marker() {
|
fn replay_paused_turn_abandoned_clears_interrupted_marker() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::SegmentStart {
|
||||||
ts: 0,
|
ts: 0,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -786,93 +695,10 @@ mod tests {
|
|||||||
ts: 100,
|
ts: 100,
|
||||||
interrupted: true,
|
interrupted: true,
|
||||||
result: EngineResult::Paused,
|
result: EngineResult::Paused,
|
||||||
active_run_turn_count: Some(1),
|
|
||||||
},
|
},
|
||||||
LogEntry::PausedTurnAbandoned { ts: 200 },
|
LogEntry::PausedTurnAbandoned { ts: 200 },
|
||||||
]);
|
]);
|
||||||
assert!(!state.last_run_interrupted);
|
assert!(!state.last_run_interrupted);
|
||||||
assert_eq!(state.active_run_turn_count, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn replay_restores_active_run_budget_across_compaction_checkpoint() {
|
|
||||||
let state = collect_state(&[
|
|
||||||
LogEntry::AnnotatedSegmentStart {
|
|
||||||
ts: 0,
|
|
||||||
session_id: uuid::Uuid::nil(),
|
|
||||||
system_prompt: None,
|
|
||||||
config: RequestConfig::default(),
|
|
||||||
history: vec![],
|
|
||||||
forked_from: None,
|
|
||||||
compacted_from: None,
|
|
||||||
},
|
|
||||||
LogEntry::ActiveRunCheckpoint {
|
|
||||||
ts: 100,
|
|
||||||
active_turn_count: 3,
|
|
||||||
total_turn_count: 9,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert_eq!(state.turn_count, 9);
|
|
||||||
assert_eq!(state.active_run_turn_count, Some(3));
|
|
||||||
assert!(state.last_run_interrupted);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn legacy_interrupted_run_derives_budget_from_invoke_and_turn_end() {
|
|
||||||
let entry: LogEntry = serde_json::from_value(serde_json::json!({
|
|
||||||
"kind": "run_completed",
|
|
||||||
"ts": 300,
|
|
||||||
"interrupted": true,
|
|
||||||
"result": "paused"
|
|
||||||
}))
|
|
||||||
.expect("legacy run-completed entry");
|
|
||||||
let state = collect_state(&[
|
|
||||||
LogEntry::AnnotatedSegmentStart {
|
|
||||||
ts: 0,
|
|
||||||
session_id: uuid::Uuid::nil(),
|
|
||||||
system_prompt: None,
|
|
||||||
config: RequestConfig::default(),
|
|
||||||
history: vec![],
|
|
||||||
forked_from: None,
|
|
||||||
compacted_from: None,
|
|
||||||
},
|
|
||||||
LogEntry::Invoke {
|
|
||||||
ts: 100,
|
|
||||||
trigger: InvokeKind::UserSend,
|
|
||||||
},
|
|
||||||
LogEntry::TurnEnd {
|
|
||||||
ts: 200,
|
|
||||||
turn_count: 2,
|
|
||||||
},
|
|
||||||
entry,
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert_eq!(state.active_run_turn_count, Some(2));
|
|
||||||
assert!(state.last_run_interrupted);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_resumable_interruption_clears_the_active_run_budget() {
|
|
||||||
let state = collect_state(&[
|
|
||||||
LogEntry::Invoke {
|
|
||||||
ts: 100,
|
|
||||||
trigger: InvokeKind::UserSend,
|
|
||||||
},
|
|
||||||
LogEntry::TurnEnd {
|
|
||||||
ts: 200,
|
|
||||||
turn_count: 2,
|
|
||||||
},
|
|
||||||
LogEntry::RunCompleted {
|
|
||||||
ts: 300,
|
|
||||||
interrupted: true,
|
|
||||||
result: EngineResult::LimitReached,
|
|
||||||
active_run_turn_count: None,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert!(state.last_run_interrupted);
|
|
||||||
assert_eq!(state.active_run_turn_count, None);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -891,7 +717,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_extension_collects_domain_payload_pairs() {
|
fn replay_extension_collects_domain_payload_pairs() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::SegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -950,12 +776,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn user_input_extensions_restore_with_the_same_committed_input() {
|
fn user_input_extensions_restore_with_the_same_committed_input() {
|
||||||
let segments = vec![Segment::text("Flow instructions"), Segment::text("Ticket")];
|
let segments = vec![Segment::text("Flow instructions"), Segment::text("Ticket")];
|
||||||
let entry = LogEntry::AnnotatedUserInput {
|
let entry = LogEntry::UserInput {
|
||||||
ts: 9999,
|
ts: 9999,
|
||||||
segments: segments.clone(),
|
segments: segments.clone(),
|
||||||
history: vec![annotated(Item::user_message(Segment::flatten_to_text(
|
|
||||||
&segments,
|
|
||||||
)))],
|
|
||||||
extensions: vec![SessionExtension::new(
|
extensions: vec![SessionExtension::new(
|
||||||
"flow.runtime.v1",
|
"flow.runtime.v1",
|
||||||
serde_json::json!({ "state": "implement", "revision": 0 }),
|
serde_json::json!({ "state": "implement", "revision": 0 }),
|
||||||
@@ -970,7 +793,7 @@ mod tests {
|
|||||||
assert_eq!(state.extensions[0].1["state"], "implement");
|
assert_eq!(state.extensions[0].1["state"], "implement");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mixed segments survive a JSON round-trip through `LogEntry::AnnotatedUserInput`,
|
/// Mixed segments survive a JSON round-trip through `LogEntry::UserInput`,
|
||||||
/// and `collect_state` derives `Item::user_message` from the flattened
|
/// and `collect_state` derives `Item::user_message` from the flattened
|
||||||
/// text while preserving the original segments separately. This covers
|
/// text while preserving the original segments separately. This covers
|
||||||
/// the segments → flatten → Item replay path from the ticket.
|
/// the segments → flatten → Item replay path from the ticket.
|
||||||
@@ -990,19 +813,16 @@ mod tests {
|
|||||||
path: "src/main.rs".into(),
|
path: "src/main.rs".into(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let entry = LogEntry::AnnotatedUserInput {
|
let entry = LogEntry::UserInput {
|
||||||
ts: 4242,
|
ts: 4242,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: segments.clone(),
|
segments: segments.clone(),
|
||||||
history: vec![annotated(Item::user_message(Segment::flatten_to_text(
|
|
||||||
&segments,
|
|
||||||
)))],
|
|
||||||
};
|
};
|
||||||
// JSON round-trip preserves the variant byte-for-byte.
|
// JSON round-trip preserves the variant byte-for-byte.
|
||||||
let json = serde_json::to_string(&entry).unwrap();
|
let json = serde_json::to_string(&entry).unwrap();
|
||||||
let parsed: LogEntry = serde_json::from_str(&json).unwrap();
|
let parsed: LogEntry = serde_json::from_str(&json).unwrap();
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::SegmentStart {
|
||||||
ts: 1,
|
ts: 1,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
|
|||||||
@@ -13,10 +13,7 @@
|
|||||||
|
|
||||||
use crate::event_trace::TraceEntry;
|
use crate::event_trace::TraceEntry;
|
||||||
use crate::segment_log::LogEntry;
|
use crate::segment_log::LogEntry;
|
||||||
use crate::{
|
use crate::{SegmentId, SessionId};
|
||||||
PasteArtifactLimits, SegmentId, SessionId, UploadedFileLimits, UploadedFileUploadContext,
|
|
||||||
};
|
|
||||||
use protocol::{PasteArtifactRef, UploadedFileRef};
|
|
||||||
|
|
||||||
/// Errors from the persistence store.
|
/// Errors from the persistence store.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -32,42 +29,6 @@ pub enum StoreError {
|
|||||||
|
|
||||||
#[error("log corrupted at line {line}: {message}")]
|
#[error("log corrupted at line {line}: {message}")]
|
||||||
Corrupt { line: usize, message: String },
|
Corrupt { line: usize, message: String },
|
||||||
|
|
||||||
#[error("paste artifact storage is unavailable")]
|
|
||||||
PasteArtifactUnsupported,
|
|
||||||
|
|
||||||
#[error("paste artifact not found: {0}")]
|
|
||||||
PasteArtifactNotFound(String),
|
|
||||||
|
|
||||||
#[error("paste artifact integrity check failed: {0}")]
|
|
||||||
PasteArtifactIntegrity(String),
|
|
||||||
|
|
||||||
#[error("paste artifact size limit exceeded: {0}")]
|
|
||||||
PasteArtifactLimit(String),
|
|
||||||
|
|
||||||
#[error("uploaded file is too large")]
|
|
||||||
ArtifactTooLarge,
|
|
||||||
|
|
||||||
#[error("session artifact aggregate quota exceeded")]
|
|
||||||
ArtifactQuotaExceeded,
|
|
||||||
|
|
||||||
#[error("uploaded file reference integrity check failed")]
|
|
||||||
ArtifactIntegrityMismatch,
|
|
||||||
|
|
||||||
#[error("uploaded file name is invalid")]
|
|
||||||
InvalidUploadedFileName,
|
|
||||||
|
|
||||||
#[error("uploaded file media type is invalid")]
|
|
||||||
InvalidUploadedFileMediaType,
|
|
||||||
|
|
||||||
#[error("uploaded file is already committed to session history")]
|
|
||||||
ArtifactAlreadyCommitted,
|
|
||||||
|
|
||||||
#[error("artifact id is invalid")]
|
|
||||||
InvalidArtifactId,
|
|
||||||
|
|
||||||
#[error("artifact timestamp is invalid")]
|
|
||||||
InvalidTimestamp,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync persistence backend for segment logs.
|
/// Sync persistence backend for segment logs.
|
||||||
@@ -156,138 +117,6 @@ pub trait Store: Send + Sync {
|
|||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
) -> Result<usize, StoreError>;
|
) -> Result<usize, StoreError>;
|
||||||
|
|
||||||
/// Store a large paste before its reference is committed to history.
|
|
||||||
fn write_paste_artifact(
|
|
||||||
&self,
|
|
||||||
_session_id: SessionId,
|
|
||||||
_source_entry_id: &str,
|
|
||||||
_content: &str,
|
|
||||||
_limits: PasteArtifactLimits,
|
|
||||||
) -> Result<PasteArtifactRef, StoreError> {
|
|
||||||
Err(StoreError::PasteArtifactUnsupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read and verify one artifact owned by `session_id`.
|
|
||||||
fn read_paste_artifact(
|
|
||||||
&self,
|
|
||||||
_session_id: SessionId,
|
|
||||||
_artifact_id: &str,
|
|
||||||
) -> Result<(PasteArtifactRef, String), StoreError> {
|
|
||||||
Err(StoreError::PasteArtifactUnsupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persist a client-local file before a submission references it.
|
|
||||||
fn write_uploaded_file(
|
|
||||||
&self,
|
|
||||||
_session_id: SessionId,
|
|
||||||
_file_name: &str,
|
|
||||||
_media_type: &str,
|
|
||||||
_content: &[u8],
|
|
||||||
_limits: UploadedFileLimits,
|
|
||||||
) -> Result<UploadedFileRef, StoreError> {
|
|
||||||
Err(StoreError::PasteArtifactUnsupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_uploaded_file_with_context(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
file_name: &str,
|
|
||||||
media_type: &str,
|
|
||||||
content: &[u8],
|
|
||||||
_context: &UploadedFileUploadContext,
|
|
||||||
limits: UploadedFileLimits,
|
|
||||||
) -> Result<UploadedFileRef, StoreError> {
|
|
||||||
self.write_uploaded_file(session_id, file_name, media_type, content, limits)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read and integrity-check an uploaded file owned by `session_id`.
|
|
||||||
fn read_uploaded_file(
|
|
||||||
&self,
|
|
||||||
_session_id: SessionId,
|
|
||||||
_reference: &UploadedFileRef,
|
|
||||||
) -> Result<Vec<u8>, StoreError> {
|
|
||||||
Err(StoreError::PasteArtifactUnsupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_uploaded_file_by_id(
|
|
||||||
&self,
|
|
||||||
_session_id: SessionId,
|
|
||||||
_artifact_id: &str,
|
|
||||||
) -> Result<(UploadedFileRef, Vec<u8>), StoreError> {
|
|
||||||
Err(StoreError::PasteArtifactUnsupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bind_uploaded_file(
|
|
||||||
&self,
|
|
||||||
_session_id: SessionId,
|
|
||||||
_reference: &UploadedFileRef,
|
|
||||||
_source_entry_id: &str,
|
|
||||||
) -> Result<UploadedFileRef, StoreError> {
|
|
||||||
Err(StoreError::PasteArtifactUnsupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retain an uploaded file while a durable pending operation owns it.
|
|
||||||
fn pin_uploaded_file(
|
|
||||||
&self,
|
|
||||||
_session_id: SessionId,
|
|
||||||
_reference: &UploadedFileRef,
|
|
||||||
_owner_id: &str,
|
|
||||||
) -> Result<(), StoreError> {
|
|
||||||
Err(StoreError::PasteArtifactUnsupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Release a pending-operation pin without changing committed ownership.
|
|
||||||
fn release_uploaded_file_pin(
|
|
||||||
&self,
|
|
||||||
_session_id: SessionId,
|
|
||||||
_artifact_id: &str,
|
|
||||||
_owner_id: &str,
|
|
||||||
) -> Result<(), StoreError> {
|
|
||||||
Err(StoreError::PasteArtifactUnsupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Complete the pending-to-history handoff after the history entry commits.
|
|
||||||
fn finalize_uploaded_file_binding(
|
|
||||||
&self,
|
|
||||||
_session_id: SessionId,
|
|
||||||
_artifact_id: &str,
|
|
||||||
_source_entry_id: &str,
|
|
||||||
) -> Result<(), StoreError> {
|
|
||||||
Err(StoreError::PasteArtifactUnsupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear pending-operation pins that have no owner in restored durable
|
|
||||||
/// Worker Session state. This repairs an interrupted pin-before-checkpoint
|
|
||||||
/// acceptance without disturbing live queue owners or committed history.
|
|
||||||
fn reconcile_uploaded_file_pins(
|
|
||||||
&self,
|
|
||||||
_session_id: SessionId,
|
|
||||||
_live_owner_ids: &[String],
|
|
||||||
) -> Result<u64, StoreError> {
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete an uncommitted uploaded file owned by `session_id`.
|
|
||||||
fn delete_uploaded_file(
|
|
||||||
&self,
|
|
||||||
_session_id: SessionId,
|
|
||||||
_artifact_id: &str,
|
|
||||||
) -> Result<bool, StoreError> {
|
|
||||||
Err(StoreError::PasteArtifactUnsupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn delete_uncommitted_uploaded_files(&self, _session_id: SessionId) -> Result<u64, StoreError> {
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn copy_committed_uploaded_files(
|
|
||||||
&self,
|
|
||||||
_source_session_id: SessionId,
|
|
||||||
_target_session_id: SessionId,
|
|
||||||
) -> Result<u64, StoreError> {
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append a trace entry to the debug event trace file.
|
/// Append a trace entry to the debug event trace file.
|
||||||
fn append_trace(
|
fn append_trace(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//! `kind` instead of parsing text prefixes like `[Notification] …` or
|
//! `kind` instead of parsing text prefixes like `[Notification] …` or
|
||||||
//! `[File: …]`.
|
//! `[File: …]`.
|
||||||
//!
|
//!
|
||||||
//! Persisted as the payload of [`crate::LogEntry::AnnotatedSystemItem`] (one
|
//! Persisted as the payload of [`crate::LogEntry::SystemItem`] (one
|
||||||
//! entry per item), and broadcast live as the payload of
|
//! entry per item), and broadcast live as the payload of
|
||||||
//! `Event::SystemItem` on the wire.
|
//! `Event::SystemItem` on the wire.
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -1,675 +0,0 @@
|
|||||||
use std::{
|
|
||||||
fs,
|
|
||||||
path::Path,
|
|
||||||
time::{SystemTime, UNIX_EPOCH},
|
|
||||||
};
|
|
||||||
|
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
|
||||||
use fs4::fs_std::FileExt;
|
|
||||||
use protocol::{UploadedFileAvailability, UploadedFileRef};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
use unicode_normalization::UnicodeNormalization;
|
|
||||||
use unicode_properties::general_category::{GeneralCategory, UnicodeGeneralCategory};
|
|
||||||
use unicode_security::{confusable_detection::skeleton, mixed_script::MixedScript};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::StoreError;
|
|
||||||
|
|
||||||
type Result<T> = std::result::Result<T, StoreError>;
|
|
||||||
|
|
||||||
pub const DEFAULT_MAX_UPLOADED_FILE_BYTES: u64 = 10 * 1024 * 1024;
|
|
||||||
pub const DEFAULT_MAX_SESSION_ARTIFACT_BYTES: u64 = 32 * 1024 * 1024;
|
|
||||||
pub const DEFAULT_MAX_FILES_PER_SUBMISSION: usize = 8;
|
|
||||||
pub const DEFAULT_MAX_SESSION_UPLOADED_FILES: u64 = 256;
|
|
||||||
const MAX_FILE_NAME_CHARS: usize = 255;
|
|
||||||
const MAX_MEDIA_TYPE_BYTES: usize = 127;
|
|
||||||
fn validate_pending_owner_id(owner_id: &str) -> Result<()> {
|
|
||||||
if owner_id.is_empty() || owner_id.len() > 256 {
|
|
||||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct UploadedFileLimits {
|
|
||||||
pub max_file_bytes: u64,
|
|
||||||
pub max_session_bytes: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for UploadedFileLimits {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
max_file_bytes: DEFAULT_MAX_UPLOADED_FILE_BYTES,
|
|
||||||
max_session_bytes: DEFAULT_MAX_SESSION_ARTIFACT_BYTES,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct UploadedFileUploadContext {
|
|
||||||
pub upload_id: String,
|
|
||||||
pub principal_id: String,
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub runtime_id: String,
|
|
||||||
pub worker_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct StoredUploadedFile {
|
|
||||||
file_name: String,
|
|
||||||
media_type: String,
|
|
||||||
created_at_ms: u64,
|
|
||||||
byte_len: u64,
|
|
||||||
sha256: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
source_entry_id: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pending_owner_id: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
upload_context: Option<UploadedFileUploadContext>,
|
|
||||||
content_base64: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn validate_file_name(file_name: &str) -> Result<()> {
|
|
||||||
let normalized: String = file_name.nfkc().collect();
|
|
||||||
let has_unsafe_component = file_name
|
|
||||||
.split('.')
|
|
||||||
.filter(|part| !part.is_empty())
|
|
||||||
.any(|part| {
|
|
||||||
let confusable_skeleton: String = skeleton(part).collect();
|
|
||||||
let ascii_confusable = part.chars().any(|ch| !ch.is_ascii())
|
|
||||||
&& confusable_skeleton.is_ascii()
|
|
||||||
&& !confusable_skeleton.eq_ignore_ascii_case(part);
|
|
||||||
!part.is_single_script() || ascii_confusable
|
|
||||||
});
|
|
||||||
|
|
||||||
if file_name.is_empty()
|
|
||||||
|| file_name.chars().count() > MAX_FILE_NAME_CHARS
|
|
||||||
|| file_name == "."
|
|
||||||
|| file_name == ".."
|
|
||||||
|| normalized != file_name
|
|
||||||
|| has_unsafe_component
|
|
||||||
|| file_name.chars().any(|ch| {
|
|
||||||
ch.is_control()
|
|
||||||
|| ch.general_category() == GeneralCategory::Format
|
|
||||||
|| matches!(ch, '/' | '\\')
|
|
||||||
})
|
|
||||||
{
|
|
||||||
return Err(StoreError::InvalidUploadedFileName);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn validate_media_type(media_type: &str) -> Result<()> {
|
|
||||||
let valid = !media_type.is_empty()
|
|
||||||
&& media_type.len() <= MAX_MEDIA_TYPE_BYTES
|
|
||||||
&& media_type.is_ascii()
|
|
||||||
&& !media_type
|
|
||||||
.bytes()
|
|
||||||
.any(|byte| byte.is_ascii_control() || byte == b' ')
|
|
||||||
&& media_type.split_once('/').is_some_and(|(kind, subtype)| {
|
|
||||||
!kind.is_empty()
|
|
||||||
&& !subtype.is_empty()
|
|
||||||
&& kind.bytes().chain(subtype.bytes()).all(|byte| {
|
|
||||||
byte.is_ascii_alphanumeric()
|
|
||||||
|| matches!(
|
|
||||||
byte,
|
|
||||||
b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
let allowed = media_type.starts_with("text/")
|
|
||||||
|| matches!(
|
|
||||||
media_type,
|
|
||||||
"application/json"
|
|
||||||
| "application/pdf"
|
|
||||||
| "image/png"
|
|
||||||
| "image/jpeg"
|
|
||||||
| "image/gif"
|
|
||||||
| "image/webp"
|
|
||||||
);
|
|
||||||
if !valid || !allowed {
|
|
||||||
return Err(StoreError::InvalidUploadedFileMediaType);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalized_file_name(file_name: &str) -> String {
|
|
||||||
file_name.nfkc().flat_map(char::to_lowercase).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_content(media_type: &str, content: &[u8]) -> Result<()> {
|
|
||||||
if content.is_empty() {
|
|
||||||
return Err(StoreError::InvalidUploadedFileMediaType);
|
|
||||||
}
|
|
||||||
let matches_declared_type = if media_type.starts_with("text/") {
|
|
||||||
std::str::from_utf8(content).is_ok()
|
|
||||||
} else {
|
|
||||||
match media_type {
|
|
||||||
"application/json" => serde_json::from_slice::<serde_json::Value>(content).is_ok(),
|
|
||||||
"application/pdf" => content.starts_with(b"%PDF-"),
|
|
||||||
"image/png" => content.starts_with(b"\x89PNG\r\n\x1a\n"),
|
|
||||||
"image/jpeg" => content.starts_with(&[0xff, 0xd8, 0xff]),
|
|
||||||
"image/gif" => content.starts_with(b"GIF87a") || content.starts_with(b"GIF89a"),
|
|
||||||
"image/webp" => {
|
|
||||||
content.len() >= 12 && content.starts_with(b"RIFF") && &content[8..12] == b"WEBP"
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if !matches_declared_type {
|
|
||||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_path(dir: &Path, artifact_id: &str) -> Result<std::path::PathBuf> {
|
|
||||||
let id = Uuid::parse_str(artifact_id).map_err(|_| StoreError::InvalidArtifactId)?;
|
|
||||||
Ok(dir.join(format!("{id}.file.json")))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_ms() -> Result<u64> {
|
|
||||||
let value = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map_err(|_| StoreError::InvalidTimestamp)?
|
|
||||||
.as_millis();
|
|
||||||
u64::try_from(value).map_err(|_| StoreError::InvalidTimestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn digest(bytes: &[u8]) -> String {
|
|
||||||
Sha256::digest(bytes)
|
|
||||||
.iter()
|
|
||||||
.map(|byte| format!("{byte:02x}"))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn stored_uploaded_file_usage(dir: &Path) -> Result<(u64, u64)> {
|
|
||||||
if !dir.exists() {
|
|
||||||
return Ok((0, 0));
|
|
||||||
}
|
|
||||||
let mut bytes = 0_u64;
|
|
||||||
let mut count = 0_u64;
|
|
||||||
for entry in fs::read_dir(dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
if !entry.file_type()?.is_file()
|
|
||||||
|| !path
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.is_some_and(|name| name.ends_with(".file.json"))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
|
||||||
bytes = bytes
|
|
||||||
.checked_add(stored.byte_len)
|
|
||||||
.ok_or(StoreError::ArtifactQuotaExceeded)?;
|
|
||||||
count = count
|
|
||||||
.checked_add(1)
|
|
||||||
.ok_or(StoreError::ArtifactQuotaExceeded)?;
|
|
||||||
}
|
|
||||||
Ok((bytes, count))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn write_uploaded_file(
|
|
||||||
dir: &Path,
|
|
||||||
file_name: &str,
|
|
||||||
media_type: &str,
|
|
||||||
content: &[u8],
|
|
||||||
context: Option<&UploadedFileUploadContext>,
|
|
||||||
limits: UploadedFileLimits,
|
|
||||||
) -> Result<UploadedFileRef> {
|
|
||||||
validate_file_name(file_name)?;
|
|
||||||
validate_media_type(media_type)?;
|
|
||||||
validate_content(media_type, content)?;
|
|
||||||
let byte_len = u64::try_from(content.len()).map_err(|_| StoreError::ArtifactTooLarge)?;
|
|
||||||
let sha256 = digest(content);
|
|
||||||
if byte_len > limits.max_file_bytes {
|
|
||||||
return Err(StoreError::ArtifactTooLarge);
|
|
||||||
}
|
|
||||||
|
|
||||||
fs::create_dir_all(dir)?;
|
|
||||||
let aggregate_lock = fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(dir.join(".aggregate.lock"))?;
|
|
||||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
|
||||||
let (paste_bytes, _) = crate::paste_artifact::stored_paste_usage(dir)?;
|
|
||||||
let (file_bytes, file_count) = stored_uploaded_file_usage(dir)?;
|
|
||||||
let normalized_name = normalized_file_name(file_name);
|
|
||||||
for entry in fs::read_dir(dir)? {
|
|
||||||
let path = entry?.path();
|
|
||||||
if !path
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.is_some_and(|name| name.ends_with(".file.json"))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
|
||||||
let same_context = context.is_some() && stored.upload_context.as_ref() == context;
|
|
||||||
let same_uncommitted_name = stored.source_entry_id.is_none()
|
|
||||||
&& normalized_file_name(&stored.file_name) == normalized_name;
|
|
||||||
if same_context || same_uncommitted_name {
|
|
||||||
if stored.file_name == file_name
|
|
||||||
&& stored.media_type == media_type
|
|
||||||
&& stored.byte_len == byte_len
|
|
||||||
&& stored.sha256 == sha256
|
|
||||||
&& stored.upload_context.as_ref() == context
|
|
||||||
{
|
|
||||||
let artifact_id = path
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.and_then(|name| name.strip_suffix(".file.json"))
|
|
||||||
.ok_or(StoreError::InvalidArtifactId)?
|
|
||||||
.to_string();
|
|
||||||
return Ok(UploadedFileRef {
|
|
||||||
artifact_id,
|
|
||||||
file_name: stored.file_name,
|
|
||||||
media_type: stored.media_type,
|
|
||||||
created_at_ms: stored.created_at_ms,
|
|
||||||
availability: UploadedFileAvailability::Available,
|
|
||||||
byte_len: stored.byte_len,
|
|
||||||
sha256: stored.sha256,
|
|
||||||
source_entry_id: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Err(StoreError::InvalidUploadedFileName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if file_count >= DEFAULT_MAX_SESSION_UPLOADED_FILES {
|
|
||||||
return Err(StoreError::ArtifactQuotaExceeded);
|
|
||||||
}
|
|
||||||
if paste_bytes
|
|
||||||
.checked_add(file_bytes)
|
|
||||||
.and_then(|total| total.checked_add(byte_len))
|
|
||||||
.is_none_or(|total| total > limits.max_session_bytes)
|
|
||||||
{
|
|
||||||
return Err(StoreError::ArtifactQuotaExceeded);
|
|
||||||
}
|
|
||||||
|
|
||||||
let artifact_id = Uuid::now_v7().to_string();
|
|
||||||
let created_at_ms = now_ms()?;
|
|
||||||
let stored = StoredUploadedFile {
|
|
||||||
file_name: file_name.to_owned(),
|
|
||||||
media_type: media_type.to_owned(),
|
|
||||||
created_at_ms,
|
|
||||||
byte_len,
|
|
||||||
sha256: sha256.clone(),
|
|
||||||
source_entry_id: None,
|
|
||||||
pending_owner_id: None,
|
|
||||||
upload_context: context.cloned(),
|
|
||||||
content_base64: BASE64.encode(content),
|
|
||||||
};
|
|
||||||
let path = record_path(dir, &artifact_id)?;
|
|
||||||
let temp = dir.join(format!(".{artifact_id}.file.tmp"));
|
|
||||||
fs::write(&temp, serde_json::to_vec(&stored)?)?;
|
|
||||||
fs::rename(&temp, &path)?;
|
|
||||||
|
|
||||||
Ok(UploadedFileRef {
|
|
||||||
artifact_id,
|
|
||||||
file_name: file_name.to_owned(),
|
|
||||||
media_type: media_type.to_owned(),
|
|
||||||
created_at_ms,
|
|
||||||
availability: UploadedFileAvailability::Available,
|
|
||||||
byte_len,
|
|
||||||
sha256,
|
|
||||||
source_entry_id: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn read_uploaded_file_by_id(
|
|
||||||
dir: &Path,
|
|
||||||
artifact_id: &str,
|
|
||||||
) -> Result<(UploadedFileRef, Vec<u8>)> {
|
|
||||||
let stored: StoredUploadedFile =
|
|
||||||
serde_json::from_slice(&fs::read(record_path(dir, artifact_id)?)?)?;
|
|
||||||
let content = BASE64
|
|
||||||
.decode(&stored.content_base64)
|
|
||||||
.map_err(|_| StoreError::ArtifactIntegrityMismatch)?;
|
|
||||||
if u64::try_from(content.len()).ok() != Some(stored.byte_len)
|
|
||||||
|| digest(&content) != stored.sha256
|
|
||||||
{
|
|
||||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
|
||||||
}
|
|
||||||
let reference = UploadedFileRef {
|
|
||||||
artifact_id: artifact_id.to_owned(),
|
|
||||||
file_name: stored.file_name,
|
|
||||||
media_type: stored.media_type,
|
|
||||||
created_at_ms: stored.created_at_ms,
|
|
||||||
availability: UploadedFileAvailability::Available,
|
|
||||||
byte_len: stored.byte_len,
|
|
||||||
sha256: stored.sha256,
|
|
||||||
source_entry_id: stored.source_entry_id,
|
|
||||||
};
|
|
||||||
Ok((reference, content))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn uploaded_file_has_pending_owner(dir: &Path, artifact_id: &str) -> Result<bool> {
|
|
||||||
let path = record_path(dir, artifact_id)?;
|
|
||||||
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(path)?)?;
|
|
||||||
Ok(stored.pending_owner_id.is_some())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn read_uploaded_file(dir: &Path, reference: &UploadedFileRef) -> Result<Vec<u8>> {
|
|
||||||
let (stored_reference, content) = read_uploaded_file_by_id(dir, &reference.artifact_id)?;
|
|
||||||
if stored_reference.file_name != reference.file_name
|
|
||||||
|| stored_reference.media_type != reference.media_type
|
|
||||||
|| stored_reference.created_at_ms != reference.created_at_ms
|
|
||||||
|| stored_reference.byte_len != reference.byte_len
|
|
||||||
|| stored_reference.sha256 != reference.sha256
|
|
||||||
|| stored_reference.source_entry_id != reference.source_entry_id
|
|
||||||
{
|
|
||||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
|
||||||
}
|
|
||||||
Ok(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn clear_uploaded_file_binding(
|
|
||||||
dir: &Path,
|
|
||||||
artifact_id: &str,
|
|
||||||
expected_source_entry_id: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
fs::create_dir_all(dir)?;
|
|
||||||
let aggregate_lock = fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(dir.join(".aggregate.lock"))?;
|
|
||||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
|
||||||
let path = record_path(dir, artifact_id)?;
|
|
||||||
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
|
||||||
if stored.source_entry_id.as_deref() != Some(expected_source_entry_id) {
|
|
||||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
|
||||||
}
|
|
||||||
stored.source_entry_id = None;
|
|
||||||
let temp = dir.join(format!(".{artifact_id}.file.unbind.tmp"));
|
|
||||||
fs::write(&temp, serde_json::to_vec(&stored)?)?;
|
|
||||||
fs::rename(temp, path)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn pin_uploaded_file(
|
|
||||||
dir: &Path,
|
|
||||||
reference: &UploadedFileRef,
|
|
||||||
owner_id: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
validate_pending_owner_id(owner_id)?;
|
|
||||||
if reference.source_entry_id.is_some() {
|
|
||||||
return Err(StoreError::ArtifactAlreadyCommitted);
|
|
||||||
}
|
|
||||||
let aggregate_lock = fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(dir.join(".aggregate.lock"))?;
|
|
||||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
|
||||||
let path = record_path(dir, &reference.artifact_id)?;
|
|
||||||
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
|
||||||
if stored.file_name != reference.file_name
|
|
||||||
|| stored.media_type != reference.media_type
|
|
||||||
|| stored.created_at_ms != reference.created_at_ms
|
|
||||||
|| stored.byte_len != reference.byte_len
|
|
||||||
|| stored.sha256 != reference.sha256
|
|
||||||
{
|
|
||||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
|
||||||
}
|
|
||||||
if stored.source_entry_id.is_some() {
|
|
||||||
return Err(StoreError::ArtifactAlreadyCommitted);
|
|
||||||
}
|
|
||||||
if let Some(existing_owner) = stored.pending_owner_id.as_deref() {
|
|
||||||
return if existing_owner == owner_id {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(StoreError::ArtifactAlreadyCommitted)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
stored.pending_owner_id = Some(owner_id.to_owned());
|
|
||||||
let temp = dir.join(format!(".{}.file.pin.tmp", reference.artifact_id));
|
|
||||||
fs::write(&temp, serde_json::to_vec(&stored)?)?;
|
|
||||||
fs::rename(temp, path)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn release_uploaded_file_pin(
|
|
||||||
dir: &Path,
|
|
||||||
artifact_id: &str,
|
|
||||||
owner_id: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
validate_pending_owner_id(owner_id)?;
|
|
||||||
let aggregate_lock = fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(dir.join(".aggregate.lock"))?;
|
|
||||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
|
||||||
let path = record_path(dir, artifact_id)?;
|
|
||||||
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
|
||||||
if stored.pending_owner_id.as_deref() != Some(owner_id) {
|
|
||||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
|
||||||
}
|
|
||||||
stored.pending_owner_id = None;
|
|
||||||
let temp = dir.join(format!(".{artifact_id}.file.unpin.tmp"));
|
|
||||||
fs::write(&temp, serde_json::to_vec(&stored)?)?;
|
|
||||||
fs::rename(temp, path)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn finalize_uploaded_file_binding(
|
|
||||||
dir: &Path,
|
|
||||||
artifact_id: &str,
|
|
||||||
source_entry_id: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let aggregate_lock = fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(dir.join(".aggregate.lock"))?;
|
|
||||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
|
||||||
let path = record_path(dir, artifact_id)?;
|
|
||||||
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
|
||||||
if stored.source_entry_id.as_deref() != Some(source_entry_id) {
|
|
||||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
|
||||||
}
|
|
||||||
if stored.pending_owner_id.is_none() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
stored.pending_owner_id = None;
|
|
||||||
let temp = dir.join(format!(".{artifact_id}.file.finalize.tmp"));
|
|
||||||
fs::write(&temp, serde_json::to_vec(&stored)?)?;
|
|
||||||
fs::rename(temp, path)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn bind_uploaded_file(
|
|
||||||
dir: &Path,
|
|
||||||
reference: &UploadedFileRef,
|
|
||||||
source_entry_id: &str,
|
|
||||||
) -> Result<UploadedFileRef> {
|
|
||||||
if source_entry_id.is_empty() || reference.source_entry_id.is_some() {
|
|
||||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
|
||||||
}
|
|
||||||
let aggregate_lock = fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(dir.join(".aggregate.lock"))?;
|
|
||||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
|
||||||
let (stored_reference, _) = read_uploaded_file_by_id(dir, &reference.artifact_id)?;
|
|
||||||
if stored_reference.file_name != reference.file_name
|
|
||||||
|| stored_reference.media_type != reference.media_type
|
|
||||||
|| stored_reference.created_at_ms != reference.created_at_ms
|
|
||||||
|| stored_reference.byte_len != reference.byte_len
|
|
||||||
|| stored_reference.sha256 != reference.sha256
|
|
||||||
{
|
|
||||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
|
||||||
}
|
|
||||||
let path = record_path(dir, &reference.artifact_id)?;
|
|
||||||
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
|
||||||
if stored.source_entry_id.is_some() {
|
|
||||||
return Err(StoreError::ArtifactAlreadyCommitted);
|
|
||||||
}
|
|
||||||
stored.source_entry_id = Some(source_entry_id.to_owned());
|
|
||||||
let temp = dir.join(format!(".{}.file.bind.tmp", reference.artifact_id));
|
|
||||||
fs::write(&temp, serde_json::to_vec(&stored)?)?;
|
|
||||||
fs::rename(&temp, path)?;
|
|
||||||
let mut bound = reference.clone();
|
|
||||||
bound.source_entry_id = Some(source_entry_id.to_owned());
|
|
||||||
Ok(bound)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn list_uploaded_file_refs(dir: &Path) -> Result<Vec<UploadedFileRef>> {
|
|
||||||
if !dir.exists() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let mut refs = Vec::new();
|
|
||||||
for entry in fs::read_dir(dir)? {
|
|
||||||
let path = entry?.path();
|
|
||||||
let Some(artifact_id) = path
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.and_then(|name| name.strip_suffix(".file.json"))
|
|
||||||
else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
refs.push(read_uploaded_file_by_id(dir, artifact_id)?.0);
|
|
||||||
}
|
|
||||||
Ok(refs)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn copy_committed_uploaded_files(source_dir: &Path, target_dir: &Path) -> Result<u64> {
|
|
||||||
if !source_dir.exists() {
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
fs::create_dir_all(target_dir)?;
|
|
||||||
let target_lock = fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(target_dir.join(".aggregate.lock"))?;
|
|
||||||
FileExt::lock_exclusive(&target_lock)?;
|
|
||||||
let mut copied = 0_u64;
|
|
||||||
for entry in fs::read_dir(source_dir)? {
|
|
||||||
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(".file.json") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let bytes = fs::read(&path)?;
|
|
||||||
let stored: StoredUploadedFile = serde_json::from_slice(&bytes)?;
|
|
||||||
if stored.source_entry_id.is_none() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let target = target_dir.join(name);
|
|
||||||
if target.exists() {
|
|
||||||
let existing: StoredUploadedFile = serde_json::from_slice(&fs::read(&target)?)?;
|
|
||||||
if existing.sha256 != stored.sha256
|
|
||||||
|| existing.file_name != stored.file_name
|
|
||||||
|| existing.source_entry_id != stored.source_entry_id
|
|
||||||
{
|
|
||||||
return Err(StoreError::ArtifactIntegrityMismatch);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let temp = target_dir.join(format!(".{name}.copy.tmp"));
|
|
||||||
fs::write(&temp, &bytes)?;
|
|
||||||
fs::rename(temp, target)?;
|
|
||||||
copied = copied
|
|
||||||
.checked_add(1)
|
|
||||||
.ok_or(StoreError::ArtifactQuotaExceeded)?;
|
|
||||||
}
|
|
||||||
Ok(copied)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn reconcile_uploaded_file_pins(dir: &Path, live_owner_ids: &[String]) -> Result<u64> {
|
|
||||||
fs::create_dir_all(dir)?;
|
|
||||||
let aggregate_lock = fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(dir.join(".aggregate.lock"))?;
|
|
||||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
|
||||||
let mut reconciled = 0_u64;
|
|
||||||
for entry in fs::read_dir(dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(artifact_id) = file_name.strip_suffix(".file.json") else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
|
||||||
let Some(owner_id) = stored.pending_owner_id.as_deref() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if live_owner_ids.iter().any(|live| live == owner_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
stored.pending_owner_id = None;
|
|
||||||
let temp = dir.join(format!(".{artifact_id}.file.reconcile.tmp"));
|
|
||||||
fs::write(&temp, serde_json::to_vec(&stored)?)?;
|
|
||||||
fs::rename(temp, path)?;
|
|
||||||
reconciled = reconciled.saturating_add(1);
|
|
||||||
}
|
|
||||||
Ok(reconciled)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn delete_uncommitted_uploaded_files(dir: &Path) -> Result<u64> {
|
|
||||||
fs::create_dir_all(dir)?;
|
|
||||||
let aggregate_lock = fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(dir.join(".aggregate.lock"))?;
|
|
||||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
|
||||||
let mut removed = 0_u64;
|
|
||||||
for entry in fs::read_dir(dir)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
if !path
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.is_some_and(|name| name.ends_with(".file.json"))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let stored: StoredUploadedFile = serde_json::from_slice(&fs::read(&path)?)?;
|
|
||||||
if stored.source_entry_id.is_none() && stored.pending_owner_id.is_none() {
|
|
||||||
fs::remove_file(path)?;
|
|
||||||
removed = removed
|
|
||||||
.checked_add(1)
|
|
||||||
.ok_or(StoreError::ArtifactQuotaExceeded)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(removed)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn delete_uploaded_file(dir: &Path, artifact_id: &str) -> Result<bool> {
|
|
||||||
fs::create_dir_all(dir)?;
|
|
||||||
let aggregate_lock = fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.open(dir.join(".aggregate.lock"))?;
|
|
||||||
FileExt::lock_exclusive(&aggregate_lock)?;
|
|
||||||
let path = record_path(dir, artifact_id)?;
|
|
||||||
let stored = match fs::read(&path) {
|
|
||||||
Ok(bytes) => serde_json::from_slice::<StoredUploadedFile>(&bytes)?,
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
|
||||||
Err(error) => return Err(error.into()),
|
|
||||||
};
|
|
||||||
if stored.source_entry_id.is_some() || stored.pending_owner_id.is_some() {
|
|
||||||
return Err(StoreError::ArtifactAlreadyCommitted);
|
|
||||||
}
|
|
||||||
match fs::remove_file(path) {
|
|
||||||
Ok(()) => Ok(true),
|
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
|
||||||
Err(error) => Err(error.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,24 +14,8 @@
|
|||||||
|
|
||||||
use crate::{SegmentId, SessionId};
|
use crate::{SegmentId, SessionId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
|
||||||
|
|
||||||
fn metadata_lock(path: &Path) -> Arc<Mutex<()>> {
|
|
||||||
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Weak<Mutex<()>>>>> = OnceLock::new();
|
|
||||||
let mut locks = LOCKS
|
|
||||||
.get_or_init(|| Mutex::new(HashMap::new()))
|
|
||||||
.lock()
|
|
||||||
.expect("metadata lock registry poisoned");
|
|
||||||
if let Some(lock) = locks.get(path).and_then(Weak::upgrade) {
|
|
||||||
return lock;
|
|
||||||
}
|
|
||||||
let lock = Arc::new(Mutex::new(()));
|
|
||||||
locks.insert(path.to_path_buf(), Arc::downgrade(&lock));
|
|
||||||
lock
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Errors from Worker metadata persistence.
|
/// Errors from Worker metadata persistence.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -79,8 +63,6 @@ pub struct WorkerSpawnedScopeRule {
|
|||||||
pub target: PathBuf,
|
pub target: PathBuf,
|
||||||
pub permission: String,
|
pub permission: String,
|
||||||
pub recursive: bool,
|
pub recursive: bool,
|
||||||
#[serde(default)]
|
|
||||||
pub symlink_policy: protocol::SymlinkPolicy,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One child Worker spawned by this Worker and persisted with the spawner's
|
/// One child Worker spawned by this Worker and persisted with the spawner's
|
||||||
@@ -198,24 +180,6 @@ pub trait WorkerMetadataStore: Send + Sync {
|
|||||||
Ok(metadata)
|
Ok(metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compare and swap the active Segment pointer while preserving unrelated metadata.
|
|
||||||
/// Returns `false` without mutation when the durable pointer no longer matches.
|
|
||||||
fn compare_and_swap_active(
|
|
||||||
&self,
|
|
||||||
worker_name: &str,
|
|
||||||
expected: &WorkerActiveSegmentRef,
|
|
||||||
replacement: WorkerActiveSegmentRef,
|
|
||||||
) -> Result<bool, WorkerStoreError> {
|
|
||||||
let mut matched = false;
|
|
||||||
self.update_by_name(worker_name, |metadata| {
|
|
||||||
if metadata.active.as_ref() == Some(expected) {
|
|
||||||
metadata.active = Some(replacement);
|
|
||||||
matched = true;
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
Ok(matched)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot.
|
/// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot.
|
||||||
fn set_active(
|
fn set_active(
|
||||||
&self,
|
&self,
|
||||||
@@ -364,7 +328,6 @@ pub trait WorkerMetadataStore: Send + Sync {
|
|||||||
pub struct WorkerAggregateStore {
|
pub struct WorkerAggregateStore {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
worker_name: String,
|
worker_name: String,
|
||||||
update_lock: Arc<Mutex<()>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerAggregateStore {
|
impl WorkerAggregateStore {
|
||||||
@@ -376,11 +339,7 @@ impl WorkerAggregateStore {
|
|||||||
let worker_name = worker_name.into();
|
let worker_name = worker_name.into();
|
||||||
validate_worker_name(&worker_name)?;
|
validate_worker_name(&worker_name)?;
|
||||||
fs::create_dir_all(&root)?;
|
fs::create_dir_all(&root)?;
|
||||||
Ok(Self {
|
Ok(Self { root, worker_name })
|
||||||
update_lock: metadata_lock(&root),
|
|
||||||
root,
|
|
||||||
worker_name,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
|
fn validate_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
|
||||||
@@ -447,47 +406,6 @@ impl WorkerMetadataStore for WorkerAggregateStore {
|
|||||||
Ok(Some(metadata))
|
Ok(Some(metadata))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_by_name<F>(
|
|
||||||
&self,
|
|
||||||
worker_name: &str,
|
|
||||||
update: F,
|
|
||||||
) -> Result<WorkerMetadata, WorkerStoreError>
|
|
||||||
where
|
|
||||||
F: FnOnce(&mut WorkerMetadata),
|
|
||||||
{
|
|
||||||
let _guard = self
|
|
||||||
.update_lock
|
|
||||||
.lock()
|
|
||||||
.expect("metadata update lock poisoned");
|
|
||||||
let mut metadata = self
|
|
||||||
.read_by_name(worker_name)?
|
|
||||||
.unwrap_or_else(|| WorkerMetadata::new(worker_name, None));
|
|
||||||
update(&mut metadata);
|
|
||||||
self.write(&metadata)?;
|
|
||||||
Ok(metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compare_and_swap_active(
|
|
||||||
&self,
|
|
||||||
worker_name: &str,
|
|
||||||
expected: &WorkerActiveSegmentRef,
|
|
||||||
replacement: WorkerActiveSegmentRef,
|
|
||||||
) -> Result<bool, WorkerStoreError> {
|
|
||||||
let _guard = self
|
|
||||||
.update_lock
|
|
||||||
.lock()
|
|
||||||
.expect("metadata update lock poisoned");
|
|
||||||
let Some(mut metadata) = self.read_by_name(worker_name)? else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
if metadata.active.as_ref() != Some(expected) {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
metadata.active = Some(replacement);
|
|
||||||
self.write(&metadata)?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
||||||
Ok(if self.metadata_path().is_file() {
|
Ok(if self.metadata_path().is_file() {
|
||||||
vec![self.worker_name.clone()]
|
vec![self.worker_name.clone()]
|
||||||
@@ -514,7 +432,6 @@ impl WorkerMetadataStore for WorkerAggregateStore {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct FsWorkerStore {
|
pub struct FsWorkerStore {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
update_lock: Arc<Mutex<()>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FsWorkerStore {
|
impl FsWorkerStore {
|
||||||
@@ -522,10 +439,7 @@ impl FsWorkerStore {
|
|||||||
pub fn new(root: impl Into<PathBuf>) -> Result<Self, WorkerStoreError> {
|
pub fn new(root: impl Into<PathBuf>) -> Result<Self, WorkerStoreError> {
|
||||||
let root = root.into();
|
let root = root.into();
|
||||||
fs::create_dir_all(&root)?;
|
fs::create_dir_all(&root)?;
|
||||||
Ok(Self {
|
Ok(Self { root })
|
||||||
update_lock: metadata_lock(&root),
|
|
||||||
root,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn worker_dir(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> {
|
fn worker_dir(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> {
|
||||||
@@ -541,32 +455,12 @@ impl FsWorkerStore {
|
|||||||
impl WorkerMetadataStore for FsWorkerStore {
|
impl WorkerMetadataStore for FsWorkerStore {
|
||||||
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
|
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
|
||||||
let path = self.metadata_path(&metadata.worker_name)?;
|
let path = self.metadata_path(&metadata.worker_name)?;
|
||||||
let mut content = serde_json::to_vec_pretty(metadata)?;
|
if let Some(parent) = path.parent() {
|
||||||
content.push(b'\n');
|
fs::create_dir_all(parent)?;
|
||||||
let parent = path.parent().expect("metadata path has parent");
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
let temp = parent.join(format!(
|
|
||||||
".metadata.json.tmp-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
uuid::Uuid::now_v7()
|
|
||||||
));
|
|
||||||
let result = (|| -> Result<(), WorkerStoreError> {
|
|
||||||
use std::io::Write;
|
|
||||||
let mut file = std::fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.open(&temp)?;
|
|
||||||
file.write_all(&content)?;
|
|
||||||
file.sync_all()?;
|
|
||||||
drop(file);
|
|
||||||
fs::rename(&temp, &path)?;
|
|
||||||
std::fs::File::open(parent)?.sync_all()?;
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
if result.is_err() {
|
|
||||||
let _ = fs::remove_file(temp);
|
|
||||||
}
|
}
|
||||||
result
|
let content = serde_json::to_vec_pretty(metadata)?;
|
||||||
|
fs::write(path, content)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
||||||
@@ -579,47 +473,6 @@ impl WorkerMetadataStore for FsWorkerStore {
|
|||||||
Ok(Some(serde_json::from_str(&content)?))
|
Ok(Some(serde_json::from_str(&content)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_by_name<F>(
|
|
||||||
&self,
|
|
||||||
worker_name: &str,
|
|
||||||
update: F,
|
|
||||||
) -> Result<WorkerMetadata, WorkerStoreError>
|
|
||||||
where
|
|
||||||
F: FnOnce(&mut WorkerMetadata),
|
|
||||||
{
|
|
||||||
let _guard = self
|
|
||||||
.update_lock
|
|
||||||
.lock()
|
|
||||||
.expect("metadata update lock poisoned");
|
|
||||||
let mut metadata = self
|
|
||||||
.read_by_name(worker_name)?
|
|
||||||
.unwrap_or_else(|| WorkerMetadata::new(worker_name, None));
|
|
||||||
update(&mut metadata);
|
|
||||||
self.write(&metadata)?;
|
|
||||||
Ok(metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compare_and_swap_active(
|
|
||||||
&self,
|
|
||||||
worker_name: &str,
|
|
||||||
expected: &WorkerActiveSegmentRef,
|
|
||||||
replacement: WorkerActiveSegmentRef,
|
|
||||||
) -> Result<bool, WorkerStoreError> {
|
|
||||||
let _guard = self
|
|
||||||
.update_lock
|
|
||||||
.lock()
|
|
||||||
.expect("metadata update lock poisoned");
|
|
||||||
let Some(mut metadata) = self.read_by_name(worker_name)? else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
if metadata.active.as_ref() != Some(expected) {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
metadata.active = Some(replacement);
|
|
||||||
self.write(&metadata)?;
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
||||||
let mut names = Vec::new();
|
let mut names = Vec::new();
|
||||||
if !self.root.exists() {
|
if !self.root.exists() {
|
||||||
@@ -755,24 +608,6 @@ where
|
|||||||
) -> Result<usize, crate::StoreError> {
|
) -> Result<usize, crate::StoreError> {
|
||||||
self.session_store.read_entry_count(session_id, segment_id)
|
self.session_store.read_entry_count(session_id, segment_id)
|
||||||
}
|
}
|
||||||
fn write_paste_artifact(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
source_entry_id: &str,
|
|
||||||
content: &str,
|
|
||||||
limits: crate::PasteArtifactLimits,
|
|
||||||
) -> Result<protocol::PasteArtifactRef, crate::StoreError> {
|
|
||||||
self.session_store
|
|
||||||
.write_paste_artifact(session_id, source_entry_id, content, limits)
|
|
||||||
}
|
|
||||||
fn read_paste_artifact(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
artifact_id: &str,
|
|
||||||
) -> Result<(protocol::PasteArtifactRef, String), crate::StoreError> {
|
|
||||||
self.session_store
|
|
||||||
.read_paste_artifact(session_id, artifact_id)
|
|
||||||
}
|
|
||||||
fn append_trace(
|
fn append_trace(
|
||||||
&self,
|
&self,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
@@ -795,26 +630,6 @@ where
|
|||||||
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
||||||
self.worker_metadata_store.read_by_name(worker_name)
|
self.worker_metadata_store.read_by_name(worker_name)
|
||||||
}
|
}
|
||||||
fn update_by_name<F>(
|
|
||||||
&self,
|
|
||||||
worker_name: &str,
|
|
||||||
update: F,
|
|
||||||
) -> Result<WorkerMetadata, WorkerStoreError>
|
|
||||||
where
|
|
||||||
F: FnOnce(&mut WorkerMetadata),
|
|
||||||
{
|
|
||||||
self.worker_metadata_store
|
|
||||||
.update_by_name(worker_name, update)
|
|
||||||
}
|
|
||||||
fn compare_and_swap_active(
|
|
||||||
&self,
|
|
||||||
worker_name: &str,
|
|
||||||
expected: &WorkerActiveSegmentRef,
|
|
||||||
replacement: WorkerActiveSegmentRef,
|
|
||||||
) -> Result<bool, WorkerStoreError> {
|
|
||||||
self.worker_metadata_store
|
|
||||||
.compare_and_swap_active(worker_name, expected, replacement)
|
|
||||||
}
|
|
||||||
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
||||||
self.worker_metadata_store.list_names()
|
self.worker_metadata_store.list_names()
|
||||||
}
|
}
|
||||||
@@ -829,7 +644,6 @@ where
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{LogEntry, Store};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_metadata_manifest_snapshot_roundtrips() {
|
fn worker_metadata_manifest_snapshot_roundtrips() {
|
||||||
@@ -850,25 +664,6 @@ mod tests {
|
|||||||
assert_eq!(restored, metadata);
|
assert_eq!(restored, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn spawned_scope_rule_defaults_resolved_and_roundtrips_logical_policy() {
|
|
||||||
let legacy: WorkerSpawnedScopeRule = serde_json::from_value(serde_json::json!({
|
|
||||||
"target": "/workspace/src",
|
|
||||||
"permission": "read",
|
|
||||||
"recursive": true
|
|
||||||
}))
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(legacy.symlink_policy, protocol::SymlinkPolicy::Resolved);
|
|
||||||
|
|
||||||
let logical = WorkerSpawnedScopeRule {
|
|
||||||
symlink_policy: protocol::SymlinkPolicy::Logical,
|
|
||||||
..legacy
|
|
||||||
};
|
|
||||||
let restored: WorkerSpawnedScopeRule =
|
|
||||||
serde_json::from_value(serde_json::to_value(&logical).unwrap()).unwrap();
|
|
||||||
assert_eq!(restored, logical);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_aggregate_store_writes_one_fixed_metadata_identity() {
|
fn worker_aggregate_store_writes_one_fixed_metadata_identity() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
@@ -1022,7 +817,6 @@ mod tests {
|
|||||||
target: std::path::Path::new("/tmp/delegated").into(),
|
target: std::path::Path::new("/tmp/delegated").into(),
|
||||||
permission: "write".into(),
|
permission: "write".into(),
|
||||||
recursive: true,
|
recursive: true,
|
||||||
symlink_policy: Default::default(),
|
|
||||||
};
|
};
|
||||||
store
|
store
|
||||||
.set_spawned_children(
|
.set_spawned_children(
|
||||||
@@ -1050,183 +844,4 @@ mod tests {
|
|||||||
assert_eq!(restored.reclaimed_children.len(), 1);
|
assert_eq!(restored.reclaimed_children.len(), 1);
|
||||||
assert_eq!(restored.reclaimed_children[0].scope_delegated, vec![scope]);
|
assert_eq!(restored.reclaimed_children[0].scope_delegated, vec![scope]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn staged_segment_is_invisible_until_cas_and_reopen_selects_committed_history() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let sessions = temp.path().join("sessions");
|
|
||||||
let workers = temp.path().join("workers");
|
|
||||||
let open = || {
|
|
||||||
CombinedStore::new(
|
|
||||||
crate::FsStore::new(&sessions).unwrap(),
|
|
||||||
FsWorkerStore::new(&workers).unwrap(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let store = open();
|
|
||||||
let session_id = crate::new_session_id();
|
|
||||||
let old_segment_id = crate::new_segment_id();
|
|
||||||
let new_segment_id = crate::new_segment_id();
|
|
||||||
let entry = |label: &str| LogEntry::Extension {
|
|
||||||
ts: 1,
|
|
||||||
domain: label.into(),
|
|
||||||
payload: serde_json::json!({}),
|
|
||||||
};
|
|
||||||
store
|
|
||||||
.create_segment(session_id, old_segment_id, &[entry("old-history")])
|
|
||||||
.unwrap();
|
|
||||||
store
|
|
||||||
.write(&WorkerMetadata::new(
|
|
||||||
"agent",
|
|
||||||
Some(WorkerActiveSegmentRef::active_segment(
|
|
||||||
session_id,
|
|
||||||
old_segment_id,
|
|
||||||
)),
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
store
|
|
||||||
.create_segment(session_id, new_segment_id, &[entry("new-history")])
|
|
||||||
.unwrap();
|
|
||||||
drop(store);
|
|
||||||
|
|
||||||
let reopened = open();
|
|
||||||
assert_eq!(
|
|
||||||
reopened
|
|
||||||
.read_by_name("agent")
|
|
||||||
.unwrap()
|
|
||||||
.unwrap()
|
|
||||||
.active
|
|
||||||
.unwrap()
|
|
||||||
.segment_id,
|
|
||||||
Some(old_segment_id)
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
reopened
|
|
||||||
.compare_and_swap_active(
|
|
||||||
"agent",
|
|
||||||
&WorkerActiveSegmentRef::active_segment(session_id, old_segment_id),
|
|
||||||
WorkerActiveSegmentRef::active_segment(session_id, new_segment_id),
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
);
|
|
||||||
drop(reopened);
|
|
||||||
|
|
||||||
let reopened = open();
|
|
||||||
assert_eq!(
|
|
||||||
reopened
|
|
||||||
.read_by_name("agent")
|
|
||||||
.unwrap()
|
|
||||||
.unwrap()
|
|
||||||
.active
|
|
||||||
.unwrap()
|
|
||||||
.segment_id,
|
|
||||||
Some(new_segment_id)
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
reopened.read_all(session_id, new_segment_id).unwrap().as_slice(),
|
|
||||||
[LogEntry::Extension { domain, .. }] if domain == "new-history"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn aggregate_store_uses_expected_old_segment_cas() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let store = WorkerAggregateStore::new(temp.path(), "agent").unwrap();
|
|
||||||
let session_id = crate::new_session_id();
|
|
||||||
let old = WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id());
|
|
||||||
store
|
|
||||||
.write(&WorkerMetadata::new("agent", Some(old.clone())))
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
store
|
|
||||||
.compare_and_swap_active(
|
|
||||||
"agent",
|
|
||||||
&old,
|
|
||||||
WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id()),
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!store
|
|
||||||
.compare_and_swap_active(
|
|
||||||
"agent",
|
|
||||||
&old,
|
|
||||||
WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id()),
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn combined_store_delegates_atomic_active_segment_cas() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let metadata = FsWorkerStore::new(temp.path().join("workers")).unwrap();
|
|
||||||
let store = CombinedStore::new(
|
|
||||||
crate::FsStore::new(temp.path().join("sessions")).unwrap(),
|
|
||||||
metadata,
|
|
||||||
);
|
|
||||||
let session_id = crate::new_session_id();
|
|
||||||
let old = WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id());
|
|
||||||
store
|
|
||||||
.write(&WorkerMetadata::new("agent", Some(old.clone())))
|
|
||||||
.unwrap();
|
|
||||||
let barrier = Arc::new(std::sync::Barrier::new(3));
|
|
||||||
let handles = [crate::new_segment_id(), crate::new_segment_id()].map(|segment_id| {
|
|
||||||
let store = store.clone();
|
|
||||||
let old = old.clone();
|
|
||||||
let barrier = barrier.clone();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
barrier.wait();
|
|
||||||
store
|
|
||||||
.compare_and_swap_active(
|
|
||||||
"agent",
|
|
||||||
&old,
|
|
||||||
WorkerActiveSegmentRef::active_segment(session_id, segment_id),
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
barrier.wait();
|
|
||||||
assert_eq!(
|
|
||||||
handles
|
|
||||||
.into_iter()
|
|
||||||
.map(|handle| handle.join().unwrap())
|
|
||||||
.filter(|won| *won)
|
|
||||||
.count(),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn active_segment_cas_allows_exactly_one_concurrent_winner() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let store = FsWorkerStore::new(temp.path()).unwrap();
|
|
||||||
let session_id = crate::new_session_id();
|
|
||||||
let old = WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id());
|
|
||||||
store
|
|
||||||
.write(&WorkerMetadata::new("agent", Some(old.clone())))
|
|
||||||
.unwrap();
|
|
||||||
let barrier = Arc::new(std::sync::Barrier::new(3));
|
|
||||||
let handles = [crate::new_segment_id(), crate::new_segment_id()].map(|segment_id| {
|
|
||||||
let store = store.clone();
|
|
||||||
let old = old.clone();
|
|
||||||
let barrier = barrier.clone();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
barrier.wait();
|
|
||||||
store
|
|
||||||
.compare_and_swap_active(
|
|
||||||
"agent",
|
|
||||||
&old,
|
|
||||||
WorkerActiveSegmentRef::active_segment(session_id, segment_id),
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
barrier.wait();
|
|
||||||
let winners = handles
|
|
||||||
.into_iter()
|
|
||||||
.map(|handle| handle.join().unwrap())
|
|
||||||
.filter(|won| *won)
|
|
||||||
.count();
|
|
||||||
assert_eq!(winners, 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,9 @@
|
|||||||
//! every later operation must use that same ID.
|
//! every later operation must use that same ID.
|
||||||
|
|
||||||
use crate::event_trace::TraceEntry;
|
use crate::event_trace::TraceEntry;
|
||||||
use crate::paste_artifact::{read_from_dir, write_to_dir};
|
|
||||||
use crate::segment_log::LogEntry;
|
use crate::segment_log::LogEntry;
|
||||||
use crate::store::{Store, StoreError};
|
use crate::store::{Store, StoreError};
|
||||||
use crate::{PasteArtifactLimits, SegmentId, SessionId};
|
use crate::{SegmentId, SessionId};
|
||||||
use protocol::PasteArtifactRef;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs::{self, File, OpenOptions};
|
use std::fs::{self, File, OpenOptions};
|
||||||
use std::io::{Read, Seek, SeekFrom, Write};
|
use std::io::{Read, Seek, SeekFrom, Write};
|
||||||
@@ -22,12 +20,9 @@ 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 = 3;
|
const SESSION_SCHEMA_VERSION: u32 = 1;
|
||||||
const PREVIOUS_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";
|
||||||
const PASTE_ARTIFACTS_DIR: &str = "artifacts/paste";
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct WorkerSessionStore {
|
pub struct WorkerSessionStore {
|
||||||
@@ -49,28 +44,15 @@ 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 mut manifest: SessionManifest = serde_json::from_slice(&bytes)?;
|
let manifest: SessionManifest = serde_json::from_slice(&bytes)?;
|
||||||
match manifest.schema_version {
|
if manifest.schema_version != SESSION_SCHEMA_VERSION {
|
||||||
SESSION_SCHEMA_VERSION => {
|
return Err(StoreError::Corrupt {
|
||||||
validate_canonical_segment_logs(&root)?;
|
line: 0,
|
||||||
}
|
message: format!(
|
||||||
PREVIOUS_SESSION_SCHEMA_VERSION | LEGACY_SESSION_SCHEMA_VERSION => {
|
"unsupported Worker Session schema version {}, expected {}",
|
||||||
migrate_segment_logs_to_v3(
|
manifest.schema_version, SESSION_SCHEMA_VERSION
|
||||||
&root,
|
),
|
||||||
manifest.session_id,
|
});
|
||||||
manifest.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)
|
||||||
}
|
}
|
||||||
@@ -154,41 +136,6 @@ impl WorkerSessionStore {
|
|||||||
.join(format!("{segment_id}.trace.jsonl"))
|
.join(format!("{segment_id}.trace.jsonl"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_log_entry(&self, path: &Path, entry: &LogEntry) -> Result<(), StoreError> {
|
|
||||||
let _guard = self
|
|
||||||
.append_lock
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
|
|
||||||
let mut file = OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.append(true)
|
|
||||||
.open(path)?;
|
|
||||||
let committed_len = truncate_uncommitted_tail(&mut file)?;
|
|
||||||
file.seek(SeekFrom::Start(0))?;
|
|
||||||
let mut existing = Vec::new();
|
|
||||||
file.read_to_end(&mut existing)?;
|
|
||||||
parse_jsonl::<LogEntry>(&existing)?;
|
|
||||||
let line = serde_json::to_string(entry)?;
|
|
||||||
let mut record = Vec::with_capacity(line.len() + 1);
|
|
||||||
record.extend_from_slice(line.as_bytes());
|
|
||||||
record.push(b'\n');
|
|
||||||
if let Err(write_error) = file.write_all(&record) {
|
|
||||||
return match file.set_len(committed_len) {
|
|
||||||
Ok(()) => Err(write_error.into()),
|
|
||||||
Err(rollback_error) => Err(std::io::Error::new(
|
|
||||||
rollback_error.kind(),
|
|
||||||
format!(
|
|
||||||
"session append failed ({write_error}) and rollback failed: {rollback_error}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.into()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
|
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
|
||||||
let _guard = self
|
let _guard = self
|
||||||
.append_lock
|
.append_lock
|
||||||
@@ -228,7 +175,7 @@ impl Store for WorkerSessionStore {
|
|||||||
entry: &LogEntry,
|
entry: &LogEntry,
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
self.ensure_session(session_id, true)?;
|
self.ensure_session(session_id, true)?;
|
||||||
self.append_log_entry(&self.log_path(segment_id), entry)
|
self.append_line(&self.log_path(segment_id), &serde_json::to_string(entry)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_all(
|
fn read_all(
|
||||||
@@ -320,35 +267,6 @@ impl Store for WorkerSessionStore {
|
|||||||
.count())
|
.count())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_paste_artifact(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
source_entry_id: &str,
|
|
||||||
content: &str,
|
|
||||||
limits: PasteArtifactLimits,
|
|
||||||
) -> Result<PasteArtifactRef, StoreError> {
|
|
||||||
self.ensure_session(session_id, true)?;
|
|
||||||
let _guard = self
|
|
||||||
.append_lock
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
|
|
||||||
write_to_dir(
|
|
||||||
&self.root.join(PASTE_ARTIFACTS_DIR),
|
|
||||||
source_entry_id,
|
|
||||||
content,
|
|
||||||
limits,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_paste_artifact(
|
|
||||||
&self,
|
|
||||||
session_id: SessionId,
|
|
||||||
artifact_id: &str,
|
|
||||||
) -> Result<(PasteArtifactRef, String), StoreError> {
|
|
||||||
self.ensure_session(session_id, false)?;
|
|
||||||
read_from_dir(&self.root.join(PASTE_ARTIFACTS_DIR), artifact_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn append_trace(
|
fn append_trace(
|
||||||
&self,
|
&self,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
@@ -360,138 +278,6 @@ impl Store for WorkerSessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn segment_log_paths(root: &Path) -> Result<Vec<(SegmentId, PathBuf)>, StoreError> {
|
|
||||||
let segments = root.join(SEGMENTS_DIR);
|
|
||||||
if !segments.exists() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let mut paths = Vec::new();
|
|
||||||
for entry in fs::read_dir(&segments)? {
|
|
||||||
let entry = entry?;
|
|
||||||
let path = entry.path();
|
|
||||||
let metadata = fs::symlink_metadata(&path)?;
|
|
||||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
|
||||||
return Err(StoreError::Corrupt {
|
|
||||||
line: 0,
|
|
||||||
message: format!("non-UTF-8 Worker Session segment path: {}", path.display()),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
if name.ends_with(".trace.jsonl") || name.starts_with('.') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !name.ends_with(".jsonl") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if !metadata.file_type().is_file() {
|
|
||||||
return Err(StoreError::Corrupt {
|
|
||||||
line: 0,
|
|
||||||
message: format!(
|
|
||||||
"Worker Session segment is not a regular file: {}",
|
|
||||||
path.display()
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let segment_id =
|
|
||||||
name.trim_end_matches(".jsonl")
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| StoreError::Corrupt {
|
|
||||||
line: 0,
|
|
||||||
message: format!("invalid Worker Session segment name: {name}"),
|
|
||||||
})?;
|
|
||||||
paths.push((segment_id, path));
|
|
||||||
}
|
|
||||||
paths.sort_by_key(|(segment_id, _)| *segment_id);
|
|
||||||
Ok(paths)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn migrate_segment_logs_to_v3(
|
|
||||||
root: &Path,
|
|
||||||
session_id: SessionId,
|
|
||||||
source_schema_version: u32,
|
|
||||||
) -> Result<(), StoreError> {
|
|
||||||
struct MigrationPlan {
|
|
||||||
path: PathBuf,
|
|
||||||
source: Vec<u8>,
|
|
||||||
output: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 1 is strictly read-only. Every segment must parse and canonicalize
|
|
||||||
// successfully before the first authoritative byte is replaced.
|
|
||||||
let mut plans = Vec::new();
|
|
||||||
for (segment_id, path) in segment_log_paths(root)? {
|
|
||||||
let source = fs::read(&path)?;
|
|
||||||
let canonical = parse_legacy_jsonl(source_schema_version, session_id, segment_id, &source)
|
|
||||||
.map_err(|error| StoreError::Corrupt {
|
|
||||||
line: 0,
|
|
||||||
message: format!(
|
|
||||||
"cannot migrate Worker Session log {}: {error}",
|
|
||||||
path.display()
|
|
||||||
),
|
|
||||||
})?;
|
|
||||||
let mut output = Vec::new();
|
|
||||||
for entry in canonical {
|
|
||||||
serde_json::to_writer(&mut output, &entry)?;
|
|
||||||
output.push(b'\n');
|
|
||||||
}
|
|
||||||
plans.push(MigrationPlan {
|
|
||||||
path,
|
|
||||||
source,
|
|
||||||
output,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fence the complete preflight snapshot before starting phase 2. Session
|
|
||||||
// open is the exclusive restore boundary; this additionally fails closed
|
|
||||||
// if an unexpected writer raced the preflight.
|
|
||||||
for plan in &plans {
|
|
||||||
if fs::read(&plan.path)? != plan.source {
|
|
||||||
return Err(StoreError::Corrupt {
|
|
||||||
line: 0,
|
|
||||||
message: format!(
|
|
||||||
"Worker Session segment changed during migration: {}",
|
|
||||||
plan.path.display()
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for plan in plans {
|
|
||||||
atomic_write_bytes(&plan.path, &plan.output)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_canonical_segment_logs(root: &Path) -> Result<(), StoreError> {
|
|
||||||
for (_, path) in segment_log_paths(root)? {
|
|
||||||
let _: Vec<LogEntry> = parse_jsonl(&fs::read(&path)?)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_legacy_jsonl(
|
|
||||||
schema_version: u32,
|
|
||||||
session_id: SessionId,
|
|
||||||
segment_id: SegmentId,
|
|
||||||
bytes: &[u8],
|
|
||||||
) -> Result<Vec<LogEntry>, serde_json::Error> {
|
|
||||||
let text = std::str::from_utf8(bytes).map_err(|error| {
|
|
||||||
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
|
|
||||||
})?;
|
|
||||||
text.lines()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|(_, line)| !line.trim().is_empty())
|
|
||||||
.map(|(line_index, line)| {
|
|
||||||
crate::legacy_session_log::decode_entry(
|
|
||||||
schema_version,
|
|
||||||
line,
|
|
||||||
session_id,
|
|
||||||
segment_id,
|
|
||||||
line_index,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
||||||
@@ -593,21 +379,7 @@ fn truncate_uncommitted_tail(file: &mut File) -> std::io::Result<u64> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{
|
use crate::{Store, new_segment_id, new_session_id};
|
||||||
LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata,
|
|
||||||
LoggedSessionHistoryOrigin, Store, new_segment_id, new_session_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn annotated(item: agen::Item) -> LoggedHistoryEntry {
|
|
||||||
LoggedHistoryEntry {
|
|
||||||
item: LoggedItem::from(item),
|
|
||||||
metadata: LoggedSessionHistoryMetadata {
|
|
||||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
|
||||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
|
||||||
derivation: None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_layout_and_single_session_invariant() {
|
fn canonical_layout_and_single_session_invariant() {
|
||||||
@@ -633,367 +405,6 @@ mod tests {
|
|||||||
assert_eq!(store.list_sessions().unwrap(), vec![session_id]);
|
assert_eq!(store.list_sessions().unwrap(), vec![session_id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn worker_session_store_keeps_paste_artifacts_inside_retention_root() {
|
|
||||||
let root = tempfile::tempdir().unwrap();
|
|
||||||
let store = WorkerSessionStore::new(root.path().join("session")).unwrap();
|
|
||||||
let session_id = new_session_id();
|
|
||||||
store
|
|
||||||
.create_segment(session_id, new_segment_id(), &[])
|
|
||||||
.unwrap();
|
|
||||||
let content = "large paste body\n終端\n";
|
|
||||||
let reference = store
|
|
||||||
.write_paste_artifact(
|
|
||||||
session_id,
|
|
||||||
"entry-1",
|
|
||||||
content,
|
|
||||||
PasteArtifactLimits::default(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
root.path()
|
|
||||||
.join(format!(
|
|
||||||
"session/{PASTE_ARTIFACTS_DIR}/{}.json",
|
|
||||||
reference.artifact_id
|
|
||||||
))
|
|
||||||
.is_file()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
store
|
|
||||||
.read_paste_artifact(session_id, &reference.artifact_id)
|
|
||||||
.unwrap()
|
|
||||||
.1,
|
|
||||||
content
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
store.read_paste_artifact(new_session_id(), &reference.artifact_id),
|
|
||||||
Err(StoreError::Corrupt { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn schema_v1_logs_are_rewritten_and_promoted_to_v3() {
|
|
||||||
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_v3_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]
|
|
||||||
fn schema_v2_migration_rewrites_legacy_records_with_stable_unknown_provenance() {
|
|
||||||
let root = tempfile::tempdir().unwrap();
|
|
||||||
let session_id = new_session_id();
|
|
||||||
let segment_id = new_segment_id();
|
|
||||||
fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap();
|
|
||||||
atomic_write_json(
|
|
||||||
&root.path().join(SESSION_FILE),
|
|
||||||
&SessionManifest {
|
|
||||||
schema_version: PREVIOUS_SESSION_SCHEMA_VERSION,
|
|
||||||
session_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let source = vec![
|
|
||||||
serde_json::json!({
|
|
||||||
"kind": "segment_start",
|
|
||||||
"ts": 1,
|
|
||||||
"session_id": session_id,
|
|
||||||
"system_prompt": null,
|
|
||||||
"config": agen::llm_client::RequestConfig::default(),
|
|
||||||
"history": [LoggedItem::from(agen::Item::assistant_message("prior"))],
|
|
||||||
"forked_from": null,
|
|
||||||
"compacted_from": null
|
|
||||||
}),
|
|
||||||
serde_json::json!({
|
|
||||||
"kind": "user_input",
|
|
||||||
"ts": 2,
|
|
||||||
"segments": [{ "kind": "text", "content": "hello" }],
|
|
||||||
"extensions": []
|
|
||||||
}),
|
|
||||||
serde_json::json!({
|
|
||||||
"kind": "assistant_item",
|
|
||||||
"ts": 3,
|
|
||||||
"item": LoggedItem::from(agen::Item::assistant_message("reply"))
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
let path = root
|
|
||||||
.path()
|
|
||||||
.join(SEGMENTS_DIR)
|
|
||||||
.join(format!("{segment_id}.jsonl"));
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
for entry in source {
|
|
||||||
serde_json::to_writer(&mut bytes, &entry).unwrap();
|
|
||||||
bytes.push(b'\n');
|
|
||||||
}
|
|
||||||
fs::write(&path, bytes).unwrap();
|
|
||||||
|
|
||||||
let store = WorkerSessionStore::new(root.path()).unwrap();
|
|
||||||
let first = store.read_all(session_id, segment_id).unwrap();
|
|
||||||
assert!(matches!(first[0], LogEntry::AnnotatedSegmentStart { .. }));
|
|
||||||
assert!(matches!(first[1], LogEntry::AnnotatedUserInput { .. }));
|
|
||||||
assert!(matches!(first[2], LogEntry::AnnotatedAssistantItem { .. }));
|
|
||||||
let first_bytes = fs::read(&path).unwrap();
|
|
||||||
drop(store);
|
|
||||||
|
|
||||||
let reopened = WorkerSessionStore::new(root.path()).unwrap();
|
|
||||||
assert_eq!(fs::read(&path).unwrap(), first_bytes);
|
|
||||||
let snapshot = crate::public_snapshot::project_current_session_snapshot(
|
|
||||||
&reopened.read_all(session_id, segment_id).unwrap(),
|
|
||||||
);
|
|
||||||
assert_eq!(snapshot.entries.len(), 3);
|
|
||||||
assert_eq!(
|
|
||||||
snapshot
|
|
||||||
.entries
|
|
||||||
.iter()
|
|
||||||
.map(|entry| entry.timestamp)
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
vec![1, 2, 3]
|
|
||||||
);
|
|
||||||
assert!(snapshot.entries.iter().all(|entry| {
|
|
||||||
entry.provenance == protocol::SessionEntryProvenance::LegacyUnknown
|
|
||||||
&& entry.entry_id.len() <= 64
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn schema_v2_preflight_keeps_earlier_segments_unchanged_when_later_is_corrupt() {
|
|
||||||
let root = tempfile::tempdir().unwrap();
|
|
||||||
let session_id = new_session_id();
|
|
||||||
let valid_segment = uuid::Uuid::from_u128(1);
|
|
||||||
let corrupt_segment = uuid::Uuid::from_u128(2);
|
|
||||||
fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap();
|
|
||||||
atomic_write_json(
|
|
||||||
&root.path().join(SESSION_FILE),
|
|
||||||
&SessionManifest {
|
|
||||||
schema_version: PREVIOUS_SESSION_SCHEMA_VERSION,
|
|
||||||
session_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let manifest_before = fs::read(root.path().join(SESSION_FILE)).unwrap();
|
|
||||||
|
|
||||||
let valid_path = root
|
|
||||||
.path()
|
|
||||||
.join(SEGMENTS_DIR)
|
|
||||||
.join(format!("{valid_segment}.jsonl"));
|
|
||||||
let valid_entry = serde_json::json!({
|
|
||||||
"kind": "segment_start",
|
|
||||||
"ts": 1,
|
|
||||||
"session_id": session_id,
|
|
||||||
"system_prompt": null,
|
|
||||||
"config": agen::llm_client::RequestConfig::default(),
|
|
||||||
"history": [LoggedItem::from(agen::Item::assistant_message("prior"))],
|
|
||||||
"forked_from": null,
|
|
||||||
"compacted_from": null
|
|
||||||
});
|
|
||||||
let mut valid_bytes = serde_json::to_vec(&valid_entry).unwrap();
|
|
||||||
valid_bytes.push(b'\n');
|
|
||||||
fs::write(&valid_path, &valid_bytes).unwrap();
|
|
||||||
let corrupt_path = root
|
|
||||||
.path()
|
|
||||||
.join(SEGMENTS_DIR)
|
|
||||||
.join(format!("{corrupt_segment}.jsonl"));
|
|
||||||
fs::write(&corrupt_path, b"{not-json}\n").unwrap();
|
|
||||||
let corrupt_before = fs::read(&corrupt_path).unwrap();
|
|
||||||
|
|
||||||
let error = match WorkerSessionStore::new(root.path()) {
|
|
||||||
Ok(_) => panic!("later corrupt segment must fail migration preflight"),
|
|
||||||
Err(error) => error,
|
|
||||||
};
|
|
||||||
assert!(matches!(error, StoreError::Corrupt { .. }));
|
|
||||||
assert_eq!(fs::read(&valid_path).unwrap(), valid_bytes);
|
|
||||||
assert_eq!(fs::read(&corrupt_path).unwrap(), corrupt_before);
|
|
||||||
assert_eq!(
|
|
||||||
fs::read(root.path().join(SESSION_FILE)).unwrap(),
|
|
||||||
manifest_before
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn current_jsonl_requires_annotations_across_append_rewrite_and_reopen() {
|
|
||||||
let root = tempfile::tempdir().unwrap();
|
|
||||||
let session_id = new_session_id();
|
|
||||||
let segment_id = new_segment_id();
|
|
||||||
let store = WorkerSessionStore::new(root.path()).unwrap();
|
|
||||||
store
|
|
||||||
.create_segment(
|
|
||||||
session_id,
|
|
||||||
segment_id,
|
|
||||||
&[LogEntry::AnnotatedSegmentStart {
|
|
||||||
ts: 1,
|
|
||||||
session_id,
|
|
||||||
system_prompt: None,
|
|
||||||
config: agen::llm_client::RequestConfig::default(),
|
|
||||||
history: vec![annotated(agen::Item::user_message("seed"))],
|
|
||||||
forked_from: None,
|
|
||||||
compacted_from: None,
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
store
|
|
||||||
.append(
|
|
||||||
session_id,
|
|
||||||
segment_id,
|
|
||||||
&LogEntry::AnnotatedAssistantItem {
|
|
||||||
ts: 2,
|
|
||||||
entry: annotated(agen::Item::assistant_message("reply")),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let before_rewrite = store.read_all(session_id, segment_id).unwrap();
|
|
||||||
store
|
|
||||||
.create_segment(session_id, segment_id, &before_rewrite)
|
|
||||||
.unwrap();
|
|
||||||
drop(store);
|
|
||||||
|
|
||||||
let reopened = WorkerSessionStore::new(root.path()).unwrap();
|
|
||||||
let restored = reopened.read_all(session_id, segment_id).unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_value(&restored).unwrap(),
|
|
||||||
serde_json::to_value(&before_rewrite).unwrap()
|
|
||||||
);
|
|
||||||
for entry in &restored {
|
|
||||||
match entry {
|
|
||||||
LogEntry::AnnotatedSegmentStart { history, .. } => assert!(history.iter().all(
|
|
||||||
|entry| !entry.metadata.entry_id.0.is_empty()
|
|
||||||
&& matches!(
|
|
||||||
entry.metadata.origin,
|
|
||||||
LoggedSessionHistoryOrigin::LegacyUnknown
|
|
||||||
)
|
|
||||||
)),
|
|
||||||
LogEntry::AnnotatedAssistantItem { entry, .. } => {
|
|
||||||
assert!(!entry.metadata.entry_id.0.is_empty());
|
|
||||||
assert!(matches!(
|
|
||||||
entry.metadata.origin,
|
|
||||||
LoggedSessionHistoryOrigin::LegacyUnknown
|
|
||||||
));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let log = fs::read_to_string(reopened.log_path(segment_id)).unwrap();
|
|
||||||
for line in log.lines() {
|
|
||||||
let value: serde_json::Value = serde_json::from_str(line).unwrap();
|
|
||||||
let kind = value["kind"].as_str().unwrap();
|
|
||||||
assert!(
|
|
||||||
!matches!(
|
|
||||||
kind,
|
|
||||||
"segment_start"
|
|
||||||
| "user_input"
|
|
||||||
| "assistant_item"
|
|
||||||
| "tool_result"
|
|
||||||
| "system_item"
|
|
||||||
),
|
|
||||||
"current-schema JSONL contains legacy history record: {kind}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn schema_v3_rejects_legacy_records_and_new_writes_are_canonical() {
|
|
||||||
let root = tempfile::tempdir().unwrap();
|
|
||||||
let session_id = new_session_id();
|
|
||||||
let segment_id = new_segment_id();
|
|
||||||
let store = WorkerSessionStore::new(root.path()).unwrap();
|
|
||||||
store
|
|
||||||
.create_segment(
|
|
||||||
session_id,
|
|
||||||
segment_id,
|
|
||||||
&[LogEntry::AnnotatedSegmentStart {
|
|
||||||
ts: 1,
|
|
||||||
session_id,
|
|
||||||
system_prompt: None,
|
|
||||||
config: agen::llm_client::RequestConfig::default(),
|
|
||||||
history: vec![annotated(agen::Item::assistant_message("seed"))],
|
|
||||||
forked_from: None,
|
|
||||||
compacted_from: None,
|
|
||||||
}],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
store
|
|
||||||
.append(
|
|
||||||
session_id,
|
|
||||||
segment_id,
|
|
||||||
&LogEntry::AnnotatedUserInput {
|
|
||||||
ts: 2,
|
|
||||||
segments: vec![protocol::Segment::Text {
|
|
||||||
content: "new".into(),
|
|
||||||
}],
|
|
||||||
history: vec![annotated(agen::Item::user_message("new"))],
|
|
||||||
extensions: Vec::new(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let entries = store.read_all(session_id, segment_id).unwrap();
|
|
||||||
assert!(matches!(entries[0], LogEntry::AnnotatedSegmentStart { .. }));
|
|
||||||
assert!(matches!(entries[1], LogEntry::AnnotatedUserInput { .. }));
|
|
||||||
drop(store);
|
|
||||||
|
|
||||||
let path = root
|
|
||||||
.path()
|
|
||||||
.join(SEGMENTS_DIR)
|
|
||||||
.join(format!("{segment_id}.jsonl"));
|
|
||||||
let mut file = OpenOptions::new().append(true).open(path).unwrap();
|
|
||||||
serde_json::to_writer(
|
|
||||||
&mut file,
|
|
||||||
&serde_json::json!({
|
|
||||||
"kind": "system_item",
|
|
||||||
"ts": 3,
|
|
||||||
"item": { "kind": "legacy_ignored", "slug": "legacy" }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
file.write_all(b"\n").unwrap();
|
|
||||||
let error = match WorkerSessionStore::new(root.path()) {
|
|
||||||
Ok(_) => panic!("schema v3 must reject a legacy history record"),
|
|
||||||
Err(error) => error,
|
|
||||||
};
|
|
||||||
assert!(matches!(error, StoreError::Corrupt { .. }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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,25 +1,12 @@
|
|||||||
use agen::EngineResult;
|
use agen::EngineResult;
|
||||||
use agen::llm_client::types::{Item, RequestConfig};
|
use agen::llm_client::types::{Item, RequestConfig};
|
||||||
use session_store::{
|
use session_store::{
|
||||||
FsStore, LogEntry, LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId,
|
FsStore, LogEntry, Store, TraceEntry, collect_state, new_segment_id, new_session_id,
|
||||||
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, Store, TraceEntry, collect_state,
|
|
||||||
new_segment_id, new_session_id,
|
|
||||||
};
|
};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
fn annotated(item: Item) -> LoggedHistoryEntry {
|
|
||||||
LoggedHistoryEntry {
|
|
||||||
item: LoggedItem::from(item),
|
|
||||||
metadata: LoggedSessionHistoryMetadata {
|
|
||||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
|
||||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
|
||||||
derivation: None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nil_session_start(ts: u64, session_id: uuid::Uuid) -> LogEntry {
|
fn nil_session_start(ts: u64, session_id: uuid::Uuid) -> LogEntry {
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::SegmentStart {
|
||||||
ts,
|
ts,
|
||||||
session_id,
|
session_id,
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -38,7 +25,7 @@ fn round_trip_write_and_read() {
|
|||||||
let segid = new_segment_id();
|
let segid = new_segment_id();
|
||||||
|
|
||||||
let entries = vec![
|
let entries = vec![
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::SegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
system_prompt: Some("You are helpful.".into()),
|
system_prompt: Some("You are helpful.".into()),
|
||||||
@@ -47,15 +34,14 @@ fn round_trip_write_and_read() {
|
|||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
},
|
},
|
||||||
LogEntry::AnnotatedUserInput {
|
LogEntry::UserInput {
|
||||||
ts: 2000,
|
ts: 2000,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![protocol::Segment::text("Hello")],
|
segments: vec![protocol::Segment::text("Hello")],
|
||||||
history: vec![annotated(Item::user_message("Hello"))],
|
|
||||||
},
|
},
|
||||||
LogEntry::AnnotatedAssistantItem {
|
LogEntry::AssistantItem {
|
||||||
ts: 3000,
|
ts: 3000,
|
||||||
entry: annotated(Item::assistant_message("Hi there!")),
|
item: Item::assistant_message("Hi there!").into(),
|
||||||
},
|
},
|
||||||
LogEntry::TurnEnd {
|
LogEntry::TurnEnd {
|
||||||
ts: 3100,
|
ts: 3100,
|
||||||
@@ -65,7 +51,6 @@ fn round_trip_write_and_read() {
|
|||||||
ts: 3200,
|
ts: 3200,
|
||||||
interrupted: false,
|
interrupted: false,
|
||||||
result: EngineResult::Finished,
|
result: EngineResult::Finished,
|
||||||
active_run_turn_count: None,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -93,14 +78,14 @@ fn create_segment_writes_all_entries() {
|
|||||||
let sid = new_session_id();
|
let sid = new_session_id();
|
||||||
let segid = new_segment_id();
|
let segid = new_segment_id();
|
||||||
|
|
||||||
let entries = [LogEntry::AnnotatedSegmentStart {
|
let entries = [LogEntry::SegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
config: RequestConfig::default(),
|
config: RequestConfig::default(),
|
||||||
history: vec![
|
history: vec![
|
||||||
annotated(Item::user_message("seed")),
|
Item::user_message("seed").into(),
|
||||||
annotated(Item::assistant_message("ok")),
|
Item::assistant_message("ok").into(),
|
||||||
],
|
],
|
||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
@@ -219,7 +204,7 @@ fn read_entry_count_matches_append_tally() {
|
|||||||
let segid = new_segment_id();
|
let segid = new_segment_id();
|
||||||
|
|
||||||
let entries = [
|
let entries = [
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::SegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -228,11 +213,10 @@ fn read_entry_count_matches_append_tally() {
|
|||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
},
|
},
|
||||||
LogEntry::AnnotatedUserInput {
|
LogEntry::UserInput {
|
||||||
ts: 2000,
|
ts: 2000,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![protocol::Segment::text("Hello")],
|
segments: vec![protocol::Segment::text("Hello")],
|
||||||
history: vec![annotated(Item::user_message("Hello"))],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -269,11 +253,10 @@ fn unterminated_utf8_tail_is_ignored_and_replaced_on_append() {
|
|||||||
assert_eq!(store.read_all(sid, segid).unwrap().len(), 1);
|
assert_eq!(store.read_all(sid, segid).unwrap().len(), 1);
|
||||||
assert_eq!(store.read_entry_count(sid, segid).unwrap(), 1);
|
assert_eq!(store.read_entry_count(sid, segid).unwrap(), 1);
|
||||||
|
|
||||||
let next = LogEntry::AnnotatedUserInput {
|
let next = LogEntry::UserInput {
|
||||||
ts: 2,
|
ts: 2,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![protocol::Segment::text("recovered")],
|
segments: vec![protocol::Segment::text("recovered")],
|
||||||
history: vec![annotated(Item::user_message("recovered"))],
|
|
||||||
};
|
};
|
||||||
store.append(sid, segid, &next).unwrap();
|
store.append(sid, segid, &next).unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,20 @@
|
|||||||
mod common;
|
mod common;
|
||||||
|
|
||||||
use std::ops::{Deref, DerefMut};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use agen::interceptor::{AssistantTurnEndContext, Interceptor, InterceptorResult, TurnEndAction};
|
use agen::Engine;
|
||||||
|
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 protocol::{Segment, SessionSnapshotEntryData, UploadedFileAvailability, UploadedFileRef};
|
|
||||||
use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state};
|
use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Helpers
|
// Helpers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
fn annotated(items: &[Item]) -> Vec<session_store::LoggedHistoryEntry> {
|
|
||||||
items
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.map(|item| session_store::LoggedHistoryEntry {
|
|
||||||
item: session_store::LoggedItem::from(item),
|
|
||||||
metadata: session_store::LoggedSessionHistoryMetadata {
|
|
||||||
entry_id: session_store::LoggedSessionHistoryEntryId::new(),
|
|
||||||
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
|
|
||||||
derivation: None,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn simple_text_events() -> Vec<Event> {
|
fn simple_text_events() -> Vec<Event> {
|
||||||
vec![
|
vec![
|
||||||
Event::text_block_start(0),
|
Event::text_block_start(0),
|
||||||
@@ -100,11 +83,8 @@ struct PausePolicy;
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl Interceptor for PausePolicy {
|
impl Interceptor for PausePolicy {
|
||||||
async fn on_assistant_turn_end(
|
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
|
||||||
&self,
|
TurnEndAction::Pause
|
||||||
_context: AssistantTurnEndContext<'_>,
|
|
||||||
) -> InterceptorResult<TurnEndAction> {
|
|
||||||
Ok(TurnEndAction::Pause)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,47 +94,15 @@ 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(
|
||||||
mut worker: TestWorker,
|
worker: Engine<MockLlmClient>,
|
||||||
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,
|
||||||
) -> (TestWorker, agen::EngineRunExit) {
|
) -> (Engine<MockLlmClient>, agen::EngineResult) {
|
||||||
// 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.
|
||||||
@@ -163,65 +111,44 @@ async fn run_and_persist(
|
|||||||
session_id,
|
session_id,
|
||||||
segment_id,
|
segment_id,
|
||||||
vec![protocol::Segment::text(input)],
|
vec![protocol::Segment::text(input)],
|
||||||
annotated(&[Item::user_message(input)]),
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let history_before = worker.history.len();
|
let history_before = worker.history().len();
|
||||||
|
|
||||||
let mut locked = worker.engine.lock(&worker.history);
|
let mut locked = worker.lock();
|
||||||
let result = locked.run(&mut worker.history, input).await;
|
let result = locked.run(input).await;
|
||||||
worker.engine = locked.unlock();
|
let worker = locked.unlock();
|
||||||
|
|
||||||
let projected = worker.history();
|
let new_items = &worker.history()[history_before..];
|
||||||
let new_items = annotated(&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 {
|
||||||
agen::EngineRunExit::Finished
|
Ok(r) => {
|
||||||
| 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,
|
||||||
legacy_result,
|
r.clone(),
|
||||||
interrupted,
|
worker.last_run_interrupted(),
|
||||||
worker.active_run_turn_count(),
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
agen::EngineRunExit::Interrupted(agen::RunInterruptionReason::LimitReached) => {
|
Err(e) => {
|
||||||
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,
|
||||||
format!("{reason:?}"),
|
e.to_string(),
|
||||||
true,
|
worker.last_run_interrupted(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(worker, result)
|
let r = result.unwrap();
|
||||||
|
(worker, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -232,15 +159,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 = TestWorker::new(Engine::new(client));
|
let worker = 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: annotated(&worker.history()),
|
history: worker.history(),
|
||||||
user_segments: Vec::new(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -258,10 +184,7 @@ async fn session_run_logs_entries() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// First entry is SegmentStart
|
// First entry is SegmentStart
|
||||||
assert!(matches!(
|
assert!(matches!(&entries[0], LogEntry::SegmentStart { .. }));
|
||||||
&entries[0],
|
|
||||||
LogEntry::AnnotatedSegmentStart { .. }
|
|
||||||
));
|
|
||||||
|
|
||||||
// Has a RunCompleted with Finished
|
// Has a RunCompleted with Finished
|
||||||
let has_finished = entries.iter().any(|e| {
|
let has_finished = entries.iter().any(|e| {
|
||||||
@@ -280,7 +203,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 = TestWorker::new(Engine::new(client));
|
let mut worker = 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(
|
||||||
@@ -288,8 +211,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: annotated(&worker.history()),
|
history: worker.history(),
|
||||||
user_segments: Vec::new(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -320,7 +242,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 = TestWorker::new(Engine::new(client));
|
let mut worker = 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(
|
||||||
@@ -328,8 +250,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: annotated(&worker.history()),
|
history: worker.history(),
|
||||||
user_segments: Vec::new(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -340,12 +261,12 @@ async fn session_run_with_tool_call() {
|
|||||||
|
|
||||||
let has_tool_results = entries
|
let has_tool_results = entries
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, LogEntry::AnnotatedToolResult { .. }));
|
.any(|e| matches!(e, LogEntry::ToolResult { .. }));
|
||||||
assert!(has_tool_results, "should have ToolResult entry");
|
assert!(has_tool_results, "should have ToolResult entry");
|
||||||
|
|
||||||
let has_assistant = entries
|
let has_assistant = entries
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, LogEntry::AnnotatedAssistantItem { .. }));
|
.any(|e| matches!(e, LogEntry::AssistantItem { .. }));
|
||||||
assert!(has_assistant, "should have AssistantItem entry");
|
assert!(has_assistant, "should have AssistantItem entry");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,10 +274,9 @@ async fn session_run_with_tool_call() {
|
|||||||
async fn session_resume_after_pause() {
|
async fn session_resume_after_pause() {
|
||||||
let (_dir, store) = make_store();
|
let (_dir, store) = make_store();
|
||||||
|
|
||||||
// First terminal assistant response requests a tool; the assistant-turn
|
// First run: tool call with pause policy → Paused
|
||||||
// interceptor pauses before the Engine enters the tool phase.
|
|
||||||
let client = MockLlmClient::with_responses(tool_call_events());
|
let client = MockLlmClient::with_responses(tool_call_events());
|
||||||
let mut worker = TestWorker::new(Engine::new(client));
|
let mut worker = Engine::new(client);
|
||||||
worker.register_tool(weather_tool_definition());
|
worker.register_tool(weather_tool_definition());
|
||||||
worker.set_interceptor(PausePolicy);
|
worker.set_interceptor(PausePolicy);
|
||||||
|
|
||||||
@@ -365,14 +285,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: annotated(&worker.history()),
|
history: worker.history(),
|
||||||
user_segments: Vec::new(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.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::EngineRunExit::Paused));
|
assert!(matches!(result, agen::EngineResult::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();
|
||||||
@@ -390,14 +309,13 @@ async fn session_resume_after_pause() {
|
|||||||
// Restore state and verify
|
// Restore state and verify
|
||||||
let state = session_store::restore(&store, sid, segid).unwrap();
|
let state = session_store::restore(&store, sid, segid).unwrap();
|
||||||
assert!(state.last_run_interrupted);
|
assert!(state.last_run_interrupted);
|
||||||
assert_eq!(state.active_run_turn_count, Some(1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
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 = TestWorker::new(Engine::new(client));
|
let mut worker = 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(
|
||||||
@@ -405,8 +323,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: annotated(&worker.history()),
|
history: worker.history(),
|
||||||
user_segments: Vec::new(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -414,38 +331,25 @@ async fn session_fork_creates_new_session() {
|
|||||||
let (worker, _) = run_and_persist(worker, &store, sid, segid, "Hello").await;
|
let (worker, _) = run_and_persist(worker, &store, sid, segid, "Hello").await;
|
||||||
|
|
||||||
let original_history_len = worker.history().len();
|
let original_history_len = worker.history().len();
|
||||||
let source_user_segments = session_store::restore(&store, sid, segid)
|
|
||||||
.unwrap()
|
|
||||||
.user_segments;
|
|
||||||
let (fork_sid, fork_segid) = session_store::fork(
|
let (fork_sid, fork_segid) = session_store::fork(
|
||||||
&store,
|
&store,
|
||||||
sid,
|
|
||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: annotated(&worker.history()),
|
history: worker.history(),
|
||||||
user_segments: source_user_segments.clone(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_ne!(fork_sid, sid, "`fork` mints a fresh Session");
|
assert_ne!(fork_sid, sid, "`fork` mints a fresh Session");
|
||||||
|
|
||||||
// Fork should have an annotated seed and typed input checkpoint.
|
// Fork should have a SegmentStart with the current history
|
||||||
let fork_entries = store.read_all(fork_sid, fork_segid).unwrap();
|
let fork_entries = store.read_all(fork_sid, fork_segid).unwrap();
|
||||||
assert_eq!(fork_entries.len(), 2);
|
assert_eq!(fork_entries.len(), 1);
|
||||||
assert!(matches!(
|
assert!(matches!(&fork_entries[0], LogEntry::SegmentStart { .. }));
|
||||||
&fork_entries[0],
|
|
||||||
LogEntry::AnnotatedSegmentStart { .. }
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
&fork_entries[1],
|
|
||||||
LogEntry::InputSegmentsCheckpoint { .. }
|
|
||||||
));
|
|
||||||
|
|
||||||
let fork_state = collect_state(&fork_entries);
|
let fork_state = collect_state(&fork_entries);
|
||||||
assert_eq!(fork_state.session_id, Some(fork_sid));
|
assert_eq!(fork_state.session_id, Some(fork_sid));
|
||||||
assert_eq!(fork_state.history.len(), original_history_len);
|
assert_eq!(fork_state.history.len(), original_history_len);
|
||||||
assert_eq!(fork_state.user_segments, source_user_segments);
|
|
||||||
assert_eq!(fork_state.system_prompt.as_deref(), Some("System prompt"));
|
assert_eq!(fork_state.system_prompt.as_deref(), Some("System prompt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,15 +357,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 = TestWorker::new(Engine::new(client));
|
let worker = 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: annotated(&worker.history()),
|
history: worker.history(),
|
||||||
user_segments: Vec::new(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -475,11 +378,7 @@ async fn session_fork_at_truncates_within_session() {
|
|||||||
let fork_segid = session_store::fork_at(&store, sid, segid, worker.turn_count()).unwrap();
|
let fork_segid = session_store::fork_at(&store, sid, segid, worker.turn_count()).unwrap();
|
||||||
|
|
||||||
let fork_entries = store.read_all(sid, fork_segid).unwrap();
|
let fork_entries = store.read_all(sid, fork_segid).unwrap();
|
||||||
assert_eq!(fork_entries.len(), 2);
|
assert_eq!(fork_entries.len(), 1); // Just the new SegmentStart
|
||||||
assert!(matches!(
|
|
||||||
&fork_entries[1],
|
|
||||||
LogEntry::InputSegmentsCheckpoint { .. }
|
|
||||||
));
|
|
||||||
|
|
||||||
let fork_state = collect_state(&fork_entries);
|
let fork_state = collect_state(&fork_entries);
|
||||||
assert_eq!(fork_state.session_id, Some(sid), "fork_at inherits Session");
|
assert_eq!(fork_state.session_id, Some(sid), "fork_at inherits Session");
|
||||||
@@ -491,25 +390,7 @@ async fn session_fork_at_truncates_within_session() {
|
|||||||
.position(|e| matches!(e, LogEntry::TurnEnd { turn_count, .. } if *turn_count == worker.turn_count()))
|
.position(|e| matches!(e, LogEntry::TurnEnd { turn_count, .. } if *turn_count == worker.turn_count()))
|
||||||
.expect("source segment has the matching TurnEnd");
|
.expect("source segment has the matching TurnEnd");
|
||||||
let source_state_at_fork = collect_state(&all_entries[..=turn_end_pos]);
|
let source_state_at_fork = collect_state(&all_entries[..=turn_end_pos]);
|
||||||
assert_eq!(fork_state.user_segments, source_state_at_fork.user_segments);
|
|
||||||
assert_eq!(fork_state.history.len(), source_state_at_fork.history.len());
|
assert_eq!(fork_state.history.len(), source_state_at_fork.history.len());
|
||||||
assert_eq!(
|
|
||||||
fork_state.annotated_history, source_state_at_fork.annotated_history,
|
|
||||||
"fork_at must preserve every retained history entry identity and provenance",
|
|
||||||
);
|
|
||||||
assert!(fork_state.annotated_history.iter().all(|entry| {
|
|
||||||
!entry.metadata.entry_id.0.is_empty()
|
|
||||||
&& matches!(
|
|
||||||
entry.metadata.origin,
|
|
||||||
session_store::LoggedSessionHistoryOrigin::LegacyUnknown
|
|
||||||
| session_store::LoggedSessionHistoryOrigin::HumanInput { .. }
|
|
||||||
| session_store::LoggedSessionHistoryOrigin::WorkerInput { .. }
|
|
||||||
| session_store::LoggedSessionHistoryOrigin::BackendInstruction { .. }
|
|
||||||
| session_store::LoggedSessionHistoryOrigin::ModelOutput { .. }
|
|
||||||
| session_store::LoggedSessionHistoryOrigin::ToolOutput { .. }
|
|
||||||
| session_store::LoggedSessionHistoryOrigin::DerivedSummary
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
|
|
||||||
// list_segments should show both source and fork in the same Session.
|
// list_segments should show both source and fork in the same Session.
|
||||||
let segs = store.list_segments(sid).unwrap();
|
let segs = store.list_segments(sid).unwrap();
|
||||||
@@ -517,97 +398,18 @@ async fn session_fork_at_truncates_within_session() {
|
|||||||
assert!(segs.contains(&fork_segid));
|
assert!(segs.contains(&fork_segid));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rewound_fork_preserves_uploaded_file_segments_in_snapshot() {
|
|
||||||
let (_dir, store) = make_store();
|
|
||||||
let config = RequestConfig::default();
|
|
||||||
let (sid, segid) = session_store::create_segment(
|
|
||||||
&store,
|
|
||||||
SegmentStartState {
|
|
||||||
system_prompt: Some("System prompt"),
|
|
||||||
config: &config,
|
|
||||||
history: Vec::new(),
|
|
||||||
user_segments: Vec::new(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let uploaded = UploadedFileRef {
|
|
||||||
artifact_id: "uploaded-file-1".into(),
|
|
||||||
file_name: "notes.txt".into(),
|
|
||||||
media_type: "text/plain".into(),
|
|
||||||
created_at_ms: 123,
|
|
||||||
availability: UploadedFileAvailability::Available,
|
|
||||||
byte_len: 5,
|
|
||||||
sha256: "a".repeat(64),
|
|
||||||
source_entry_id: Some("entry-1".into()),
|
|
||||||
};
|
|
||||||
let segments = vec![Segment::UploadedFile {
|
|
||||||
file: uploaded.clone(),
|
|
||||||
}];
|
|
||||||
session_store::save_user_input(
|
|
||||||
&store,
|
|
||||||
sid,
|
|
||||||
segid,
|
|
||||||
segments.clone(),
|
|
||||||
annotated(&[Item::user_message(Segment::flatten_to_text(&segments))]),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
session_store::save_turn_end(&store, sid, segid, 1).unwrap();
|
|
||||||
|
|
||||||
let fork_segid = session_store::fork_at(&store, sid, segid, 1).unwrap();
|
|
||||||
let fork_entries = store.read_all(sid, fork_segid).unwrap();
|
|
||||||
let snapshot = session_store::public_snapshot::project_session_snapshot(sid, &fork_entries);
|
|
||||||
|
|
||||||
assert!(fork_entries.iter().any(|entry| matches!(
|
|
||||||
entry,
|
|
||||||
LogEntry::InputSegmentsCheckpoint { user_segments, .. }
|
|
||||||
if user_segments == &vec![segments.clone()]
|
|
||||||
)));
|
|
||||||
assert!(snapshot.entries.iter().any(|entry| matches!(
|
|
||||||
&entry.data,
|
|
||||||
SessionSnapshotEntryData::UserInput { segments: restored }
|
|
||||||
if restored == &segments
|
|
||||||
)));
|
|
||||||
|
|
||||||
let fork_state = collect_state(&fork_entries);
|
|
||||||
let (copied_session_id, copied_segment_id) = session_store::fork(
|
|
||||||
&store,
|
|
||||||
sid,
|
|
||||||
SegmentStartState {
|
|
||||||
system_prompt: fork_state.system_prompt.as_deref(),
|
|
||||||
config: &fork_state.config,
|
|
||||||
history: fork_state.annotated_history.clone(),
|
|
||||||
user_segments: fork_state.user_segments.clone(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let copied_entries = store
|
|
||||||
.read_all(copied_session_id, copied_segment_id)
|
|
||||||
.unwrap();
|
|
||||||
let copied_snapshot = session_store::public_snapshot::project_session_snapshot(
|
|
||||||
copied_session_id,
|
|
||||||
&copied_entries,
|
|
||||||
);
|
|
||||||
assert!(copied_snapshot.entries.iter().any(|entry| matches!(
|
|
||||||
&entry.data,
|
|
||||||
SessionSnapshotEntryData::UserInput { segments: restored }
|
|
||||||
if restored == &segments
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
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 = TestWorker::new(Engine::new(client));
|
let mut worker = 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: annotated(&worker.history()),
|
history: worker.history(),
|
||||||
user_segments: Vec::new(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -633,15 +435,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 = TestWorker::new(Engine::new(client_a));
|
let worker_a = 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: annotated(&worker_a.history()),
|
history: worker_a.history(),
|
||||||
user_segments: Vec::new(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -650,14 +451,12 @@ async fn session_auto_forks_on_conflict() {
|
|||||||
let mut entries_written: usize = 1;
|
let mut entries_written: usize = 1;
|
||||||
|
|
||||||
// Simulate another Worker writing to the same segment behind our back.
|
// Simulate another Worker writing to the same segment behind our back.
|
||||||
session_store::save_user_input(
|
let extra_entry = LogEntry::UserInput {
|
||||||
&store,
|
ts: 9999,
|
||||||
sid,
|
extensions: vec![],
|
||||||
original_segid,
|
segments: vec![protocol::Segment::text("Interloper")],
|
||||||
vec![protocol::Segment::text("Interloper")],
|
};
|
||||||
annotated(&[Item::user_message("Interloper")]),
|
store.append(sid, original_segid, &extra_entry).unwrap();
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Now the on-disk count exceeds our tally — ensure_head_or_fork should auto-fork.
|
// Now the on-disk count exceeds our tally — ensure_head_or_fork should auto-fork.
|
||||||
session_store::ensure_head_or_fork(
|
session_store::ensure_head_or_fork(
|
||||||
@@ -669,8 +468,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: annotated(&worker_a.history()),
|
history: worker_a.history(),
|
||||||
user_segments: Vec::new(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -691,7 +489,7 @@ async fn session_auto_forks_on_conflict() {
|
|||||||
// The new segment records its lineage forward via forked_from; the
|
// The new segment records its lineage forward via forked_from; the
|
||||||
// source segment is left immutable (no terminal marker written back).
|
// source segment is left immutable (no terminal marker written back).
|
||||||
match &fork_entries[0] {
|
match &fork_entries[0] {
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::SegmentStart {
|
||||||
forked_from: Some(origin),
|
forked_from: Some(origin),
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
@@ -711,7 +509,7 @@ async fn session_auto_forks_on_conflict() {
|
|||||||
);
|
);
|
||||||
let has_interloper = original_entries
|
let has_interloper = original_entries
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, LogEntry::AnnotatedUserInput { .. }));
|
.any(|e| matches!(e, LogEntry::UserInput { .. }));
|
||||||
assert!(has_interloper);
|
assert!(has_interloper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,15 +520,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 = TestWorker::new(Engine::new(client));
|
let worker = 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: annotated(&worker.history()),
|
history: worker.history(),
|
||||||
user_segments: Vec::new(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -761,20 +558,13 @@ async fn nested_past_fork_leaves_ancestors_immutable() {
|
|||||||
let fork1_entries = store.read_all(sid, fork1).unwrap();
|
let fork1_entries = store.read_all(sid, fork1).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fork1_entries.len(),
|
fork1_entries.len(),
|
||||||
2,
|
1,
|
||||||
"fork1 stores its SegmentStart and typed input checkpoint"
|
"fork1 is just its SegmentStart seed"
|
||||||
);
|
);
|
||||||
|
|
||||||
// fork2's lineage points at fork1, not the root, and the typed seed remains
|
// fork2's lineage points at fork1, not the root.
|
||||||
// intact across the nested turn-zero fork.
|
match &store.read_all(sid, fork2).unwrap()[0] {
|
||||||
let fork2_entries = store.read_all(sid, fork2).unwrap();
|
LogEntry::SegmentStart {
|
||||||
assert_eq!(fork2_entries.len(), 2);
|
|
||||||
assert_eq!(
|
|
||||||
collect_state(&fork2_entries).user_segments,
|
|
||||||
collect_state(&fork1_entries).user_segments
|
|
||||||
);
|
|
||||||
match &fork2_entries[0] {
|
|
||||||
LogEntry::AnnotatedSegmentStart {
|
|
||||||
forked_from: Some(origin),
|
forked_from: Some(origin),
|
||||||
..
|
..
|
||||||
} => assert_eq!(origin.segment_id, fork1),
|
} => assert_eq!(origin.segment_id, fork1),
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "standalone"
|
|
||||||
description = "In-process standalone Worker host"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition.workspace = true
|
|
||||||
license.workspace = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
agen.workspace = true
|
|
||||||
client.workspace = true
|
|
||||||
fs4.workspace = true
|
|
||||||
manifest.workspace = true
|
|
||||||
protocol.workspace = true
|
|
||||||
serde.workspace = true
|
|
||||||
serde_json.workspace = true
|
|
||||||
session-store.workspace = true
|
|
||||||
thiserror.workspace = true
|
|
||||||
tokio = { workspace = true, features = ["rt", "sync", "time"] }
|
|
||||||
uuid = { workspace = true, features = ["v7"] }
|
|
||||||
worker.workspace = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
async-trait.workspace = true
|
|
||||||
futures.workspace = true
|
|
||||||
tempfile.workspace = true
|
|
||||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
|
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
use std::path::PathBuf;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use agen::llm_client::client::LlmClient;
|
|
||||||
use client::Client;
|
|
||||||
use client::transport::in_process::{Peer as InProcessPeer, Socket as InProcessSocket};
|
|
||||||
use manifest::ScopeRule;
|
|
||||||
use protocol::stream::{decode_method, encode_event};
|
|
||||||
use protocol::{Event, Method, WorkerId};
|
|
||||||
use session_store::{
|
|
||||||
CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore,
|
|
||||||
};
|
|
||||||
use thiserror::Error;
|
|
||||||
use worker::bootstrap::{
|
|
||||||
WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, bash_output_dir_for_worker_id,
|
|
||||||
};
|
|
||||||
use worker::controller::WorkerControllerTransport;
|
|
||||||
use worker::ipc::protocol_session::{
|
|
||||||
WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event,
|
|
||||||
subscribe_worker_protocol_session,
|
|
||||||
};
|
|
||||||
use worker::runtime::worker_allocation::ScopeLockError;
|
|
||||||
use worker::{BootstrappedWorker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext};
|
|
||||||
|
|
||||||
use crate::launch::ResolvedStandaloneLaunch;
|
|
||||||
use crate::store::{
|
|
||||||
StaleLeasePolicy, StandaloneShutdownReason, StandaloneStoreError, StandaloneWorkerLease,
|
|
||||||
StandaloneWorkerRecord, StandaloneWorkerStore,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
|
|
||||||
type StandaloneBackingStore = CombinedStore<FsStore, FsWorkerStore>;
|
|
||||||
|
|
||||||
/// One client-owned top-level Worker and its standalone Worker authority.
|
|
||||||
///
|
|
||||||
/// The host deliberately exposes the existing typed Worker protocol rather than owning an
|
|
||||||
/// HTTP/WebSocket server or creating Runtime/Workspace/Ticket/Workdir domain records.
|
|
||||||
pub struct StandaloneHost {
|
|
||||||
handle: worker::WorkerHandle,
|
|
||||||
shutdown: Option<worker::controller::ShutdownReceiver>,
|
|
||||||
shutdown_timeout: Duration,
|
|
||||||
store: StandaloneWorkerStore,
|
|
||||||
worker_store: FsWorkerStore,
|
|
||||||
record: StandaloneWorkerRecord,
|
|
||||||
lease: Option<StandaloneWorkerLease>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
|
||||||
pub enum StandaloneStartupError {
|
|
||||||
#[error("the standalone state store could not be opened or validated")]
|
|
||||||
StateStore,
|
|
||||||
#[error("the standalone Worker is already active")]
|
|
||||||
WorkerActive,
|
|
||||||
#[error("the standalone Worker lease cannot be observed safely; recovery is rejected")]
|
|
||||||
LeaseLivenessUnknown,
|
|
||||||
#[error("the standalone Worker working directory is unavailable or changed")]
|
|
||||||
WorkingDirectoryUnavailable,
|
|
||||||
#[error(
|
|
||||||
"requested scope `{}` conflicts with worker allocation `{competitor}` rule `{}`",
|
|
||||||
requested_rule.target.display(),
|
|
||||||
competitor_rule.target.display()
|
|
||||||
)]
|
|
||||||
ScopeConflict {
|
|
||||||
competitor: String,
|
|
||||||
requested_rule: ScopeRule,
|
|
||||||
competitor_rule: ScopeRule,
|
|
||||||
},
|
|
||||||
#[error("the resolved Worker configuration or persisted history is invalid")]
|
|
||||||
WorkerConfiguration,
|
|
||||||
#[error("the configured model provider is unavailable")]
|
|
||||||
ModelProvider,
|
|
||||||
#[error("the fixed standalone feature composition could not be installed")]
|
|
||||||
FeatureComposition,
|
|
||||||
#[error("the in-process Worker controller could not start")]
|
|
||||||
Controller,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
|
||||||
pub enum StandaloneShutdownError {
|
|
||||||
#[error("the standalone Worker did not stop before the shutdown deadline")]
|
|
||||||
DeadlineExceeded,
|
|
||||||
#[error("the standalone Worker shutdown confirmation was lost")]
|
|
||||||
ConfirmationLost,
|
|
||||||
#[error("the standalone Worker final state could not be committed")]
|
|
||||||
StateStore,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StandaloneHost {
|
|
||||||
pub async fn start(launch: ResolvedStandaloneLaunch) -> Result<Self, StandaloneStartupError> {
|
|
||||||
Self::start_with_optional_model_client(launch, None).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn start_with_model_client<C>(
|
|
||||||
launch: ResolvedStandaloneLaunch,
|
|
||||||
model_client: C,
|
|
||||||
) -> Result<Self, StandaloneStartupError>
|
|
||||||
where
|
|
||||||
C: LlmClient + 'static,
|
|
||||||
{
|
|
||||||
Self::start_with_optional_model_client(launch, Some(Box::new(model_client))).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start_with_optional_model_client(
|
|
||||||
launch: ResolvedStandaloneLaunch,
|
|
||||||
model_client: Option<Box<dyn LlmClient>>,
|
|
||||||
) -> Result<Self, StandaloneStartupError> {
|
|
||||||
let store =
|
|
||||||
StandaloneWorkerStore::open(&launch.state_dir).map_err(classify_store_startup_error)?;
|
|
||||||
let allocation = store
|
|
||||||
.allocate(&launch.cwd, StaleLeasePolicy::Reject)
|
|
||||||
.map_err(classify_store_startup_error)?;
|
|
||||||
let worker_id = allocation.worker_id();
|
|
||||||
|
|
||||||
// WorkerId is the stable identity. The current Worker store remains
|
|
||||||
// name-keyed, so keep its derived storage key separate from the
|
|
||||||
// user-facing profile name.
|
|
||||||
let manifest = launch.profile.manifest.clone();
|
|
||||||
let storage_key = format!("standalone-{worker_id}");
|
|
||||||
let mut bootstrap_manifest = manifest.clone();
|
|
||||||
bootstrap_manifest.worker.name = storage_key.clone();
|
|
||||||
let (backing_store, worker_store) = match backing_store(&store, worker_id) {
|
|
||||||
Ok(stores) => stores,
|
|
||||||
Err(error) => {
|
|
||||||
let _ = store.abandon_allocation(allocation);
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let filesystem_authority =
|
|
||||||
WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone());
|
|
||||||
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
|
||||||
let runtime_base = store.runtime_dir(worker_id);
|
|
||||||
let bash_output_dir = bash_output_dir_for_worker_id(worker_id);
|
|
||||||
|
|
||||||
let mut bootstrap = WorkerBootstrap::new(
|
|
||||||
bootstrap_manifest,
|
|
||||||
backing_store,
|
|
||||||
launch.prompt_catalog,
|
|
||||||
workspace_context,
|
|
||||||
filesystem_authority,
|
|
||||||
WorkerBootstrapLayout::Direct {
|
|
||||||
runtime_base,
|
|
||||||
bash_output_dir,
|
|
||||||
},
|
|
||||||
WorkerControllerTransport::InProcess,
|
|
||||||
);
|
|
||||||
if let Some(model_client) = model_client {
|
|
||||||
bootstrap = bootstrap.with_model_client(model_client);
|
|
||||||
}
|
|
||||||
let started = match bootstrap.start().await {
|
|
||||||
Ok(started) => started,
|
|
||||||
Err(error) => {
|
|
||||||
let _ = store.abandon_allocation(allocation);
|
|
||||||
return Err(classify_startup_error(error));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let active = match active_pointer(&worker_store, &storage_key) {
|
|
||||||
Ok(active) => active,
|
|
||||||
Err(error) => {
|
|
||||||
stop_started_worker(started).await;
|
|
||||||
let _ = store.abandon_allocation(allocation);
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let record = match store.commit_created(
|
|
||||||
&allocation,
|
|
||||||
manifest,
|
|
||||||
storage_key,
|
|
||||||
active.session_id,
|
|
||||||
active.segment_id,
|
|
||||||
) {
|
|
||||||
Ok(record) => record,
|
|
||||||
Err(_) => {
|
|
||||||
stop_started_worker(started).await;
|
|
||||||
let _ = store.abandon_allocation(allocation);
|
|
||||||
return Err(StandaloneStartupError::StateStore);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(Self::from_started(
|
|
||||||
started,
|
|
||||||
store,
|
|
||||||
worker_store,
|
|
||||||
record,
|
|
||||||
allocation.into_lease(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn restore(
|
|
||||||
state_dir: PathBuf,
|
|
||||||
worker_id: WorkerId,
|
|
||||||
) -> Result<Self, StandaloneStartupError> {
|
|
||||||
Self::restore_with_optional_model_client(state_dir, worker_id, None).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn restore_with_model_client<C>(
|
|
||||||
state_dir: PathBuf,
|
|
||||||
worker_id: WorkerId,
|
|
||||||
model_client: C,
|
|
||||||
) -> Result<Self, StandaloneStartupError>
|
|
||||||
where
|
|
||||||
C: LlmClient + 'static,
|
|
||||||
{
|
|
||||||
Self::restore_with_optional_model_client(state_dir, worker_id, Some(Box::new(model_client)))
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn restore_with_optional_model_client(
|
|
||||||
state_dir: PathBuf,
|
|
||||||
worker_id: WorkerId,
|
|
||||||
model_client: Option<Box<dyn LlmClient>>,
|
|
||||||
) -> Result<Self, StandaloneStartupError> {
|
|
||||||
let store = StandaloneWorkerStore::open(state_dir).map_err(classify_store_startup_error)?;
|
|
||||||
let record = store
|
|
||||||
.load(worker_id)
|
|
||||||
.map_err(classify_store_startup_error)?;
|
|
||||||
record.cwd.verify().map_err(classify_store_startup_error)?;
|
|
||||||
let lease = store
|
|
||||||
.acquire_lease(worker_id, StaleLeasePolicy::Recover)
|
|
||||||
.map_err(classify_store_startup_error)?;
|
|
||||||
let (backing_store, worker_store) = backing_store(&store, worker_id)?;
|
|
||||||
let storage_key = record.storage_key.clone();
|
|
||||||
let mut manifest = record.manifest.clone();
|
|
||||||
manifest.worker.name = storage_key.clone();
|
|
||||||
let filesystem_authority = WorkerFilesystemAuthority::local(
|
|
||||||
record.cwd.canonical_path.clone(),
|
|
||||||
record.cwd.canonical_path.clone(),
|
|
||||||
);
|
|
||||||
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
|
||||||
let runtime_base = store.runtime_dir(worker_id);
|
|
||||||
let bash_output_dir = bash_output_dir_for_worker_id(worker_id);
|
|
||||||
|
|
||||||
let mut bootstrap = WorkerBootstrap::new(
|
|
||||||
manifest,
|
|
||||||
backing_store,
|
|
||||||
worker::PromptCatalogSource::builtins_only(),
|
|
||||||
workspace_context,
|
|
||||||
filesystem_authority,
|
|
||||||
WorkerBootstrapLayout::Direct {
|
|
||||||
runtime_base,
|
|
||||||
bash_output_dir,
|
|
||||||
},
|
|
||||||
WorkerControllerTransport::InProcess,
|
|
||||||
);
|
|
||||||
if let Some(model_client) = model_client {
|
|
||||||
bootstrap = bootstrap.with_model_client(model_client);
|
|
||||||
}
|
|
||||||
let prepared = bootstrap
|
|
||||||
.prepare_restored(&storage_key)
|
|
||||||
.await
|
|
||||||
.map_err(classify_startup_error)?;
|
|
||||||
let started = prepared.start().await.map_err(classify_startup_error)?;
|
|
||||||
let active = match active_pointer(&worker_store, &storage_key) {
|
|
||||||
Ok(active) => active,
|
|
||||||
Err(error) => {
|
|
||||||
stop_started_worker(started).await;
|
|
||||||
return Err(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let record =
|
|
||||||
match store.update_active_pointer(&record, active.session_id, active.segment_id) {
|
|
||||||
Ok(record) => record,
|
|
||||||
Err(_) => {
|
|
||||||
stop_started_worker(started).await;
|
|
||||||
lease.retain();
|
|
||||||
return Err(StandaloneStartupError::StateStore);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(Self::from_started(
|
|
||||||
started,
|
|
||||||
store,
|
|
||||||
worker_store,
|
|
||||||
record,
|
|
||||||
lease,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_started(
|
|
||||||
started: BootstrappedWorker,
|
|
||||||
store: StandaloneWorkerStore,
|
|
||||||
worker_store: FsWorkerStore,
|
|
||||||
record: StandaloneWorkerRecord,
|
|
||||||
lease: StandaloneWorkerLease,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
handle: started.handle,
|
|
||||||
shutdown: Some(started.shutdown),
|
|
||||||
shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
|
|
||||||
store,
|
|
||||||
worker_store,
|
|
||||||
record,
|
|
||||||
lease: Some(lease),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn worker_id(&self) -> WorkerId {
|
|
||||||
self.record.worker_id
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn record(&self) -> &StandaloneWorkerRecord {
|
|
||||||
&self.record
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Open one complete client-side Worker protocol session.
|
|
||||||
///
|
|
||||||
/// Working events, committed session entries, alert snapshots, and the
|
|
||||||
/// initial history snapshot are merged behind the client boundary.
|
|
||||||
pub fn connect(&self) -> Client<InProcessSocket> {
|
|
||||||
let streams = subscribe_worker_protocol_session(&self.handle);
|
|
||||||
let (socket, peer) = InProcessSocket::pair();
|
|
||||||
tokio::spawn(run_protocol_session(self.handle.clone(), streams, peer));
|
|
||||||
Client::new(socket)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_shutdown_timeout(mut self, shutdown_timeout: Duration) -> Self {
|
|
||||||
self.shutdown_timeout = shutdown_timeout;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> {
|
|
||||||
let command = protocol::WorkerCommandEnvelope::new(u64::MAX);
|
|
||||||
let _ = self.handle.send(Method::Shutdown { command }).await;
|
|
||||||
let Some(shutdown) = self.shutdown.take() else {
|
|
||||||
self.retain_lease();
|
|
||||||
return Err(StandaloneShutdownError::ConfirmationLost);
|
|
||||||
};
|
|
||||||
match tokio::time::timeout(self.shutdown_timeout, shutdown).await {
|
|
||||||
Ok(Ok(())) => {}
|
|
||||||
Ok(Err(_)) => {
|
|
||||||
self.retain_lease();
|
|
||||||
return Err(StandaloneShutdownError::ConfirmationLost);
|
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
self.retain_lease();
|
|
||||||
return Err(StandaloneShutdownError::DeadlineExceeded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let active = match active_pointer(&self.worker_store, &self.record.storage_key) {
|
|
||||||
Ok(active) => active,
|
|
||||||
Err(_) => {
|
|
||||||
self.retain_lease();
|
|
||||||
return Err(StandaloneShutdownError::StateStore);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if self
|
|
||||||
.store
|
|
||||||
.mark_stopped(
|
|
||||||
&self.record,
|
|
||||||
active.session_id,
|
|
||||||
active.segment_id,
|
|
||||||
StandaloneShutdownReason::UserExit,
|
|
||||||
)
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
self.retain_lease();
|
|
||||||
return Err(StandaloneShutdownError::StateStore);
|
|
||||||
}
|
|
||||||
if let Some(lease) = self.lease.take() {
|
|
||||||
lease
|
|
||||||
.release()
|
|
||||||
.map_err(|_| StandaloneShutdownError::StateStore)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn retain_lease(&mut self) {
|
|
||||||
if let Some(lease) = self.lease.take() {
|
|
||||||
lease.retain();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_protocol_session(
|
|
||||||
handle: worker::WorkerHandle,
|
|
||||||
streams: WorkerProtocolSessionStreams,
|
|
||||||
mut peer: InProcessPeer,
|
|
||||||
) {
|
|
||||||
let WorkerProtocolSessionStreams {
|
|
||||||
snapshot_event,
|
|
||||||
mut log_entries,
|
|
||||||
alert_snapshot,
|
|
||||||
mut events,
|
|
||||||
} = streams;
|
|
||||||
|
|
||||||
if !send_protocol_snapshot(&peer, alert_snapshot, snapshot_event).await {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
message = peer.next() => {
|
|
||||||
let Some(message) = message else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Ok(method) = decode_method(&message) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if let Some(event) = dispatch_worker_protocol_method(&handle, method).await
|
|
||||||
&& !send_protocol_event(&peer, event).await
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
event = events.recv() => {
|
|
||||||
match event {
|
|
||||||
Ok(event) => {
|
|
||||||
if !send_protocol_event(&peer, event).await {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
|
||||||
let replacement = subscribe_worker_protocol_session(&handle);
|
|
||||||
let WorkerProtocolSessionStreams {
|
|
||||||
snapshot_event,
|
|
||||||
log_entries: replacement_log_entries,
|
|
||||||
alert_snapshot,
|
|
||||||
events: replacement_events,
|
|
||||||
} = replacement;
|
|
||||||
log_entries = replacement_log_entries;
|
|
||||||
events = replacement_events;
|
|
||||||
if !send_protocol_snapshot(&peer, alert_snapshot, snapshot_event).await {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
entry = log_entries.recv() => {
|
|
||||||
match entry {
|
|
||||||
Ok(entry) => {
|
|
||||||
if let Some(event) = live_log_entry_event(entry)
|
|
||||||
&& !send_protocol_event(&peer, event).await
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
|
||||||
let replacement = subscribe_worker_protocol_session(&handle);
|
|
||||||
let WorkerProtocolSessionStreams {
|
|
||||||
snapshot_event,
|
|
||||||
log_entries: replacement_log_entries,
|
|
||||||
alert_snapshot,
|
|
||||||
events: replacement_events,
|
|
||||||
} = replacement;
|
|
||||||
log_entries = replacement_log_entries;
|
|
||||||
events = replacement_events;
|
|
||||||
if !send_protocol_snapshot(&peer, alert_snapshot, snapshot_event).await {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_protocol_snapshot(
|
|
||||||
peer: &InProcessPeer,
|
|
||||||
alert_snapshot: Vec<protocol::Alert>,
|
|
||||||
snapshot_event: Event,
|
|
||||||
) -> bool {
|
|
||||||
for alert in alert_snapshot {
|
|
||||||
if !send_protocol_event(peer, Event::Alert(alert)).await {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
send_protocol_event(peer, snapshot_event).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_protocol_event(peer: &InProcessPeer, event: Event) -> bool {
|
|
||||||
let Ok(message) = encode_event(&event) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
peer.send(message).await.is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn backing_store(
|
|
||||||
store: &StandaloneWorkerStore,
|
|
||||||
worker_id: WorkerId,
|
|
||||||
) -> Result<(StandaloneBackingStore, FsWorkerStore), StandaloneStartupError> {
|
|
||||||
let session_store = FsStore::new(store.sessions_dir(worker_id))
|
|
||||||
.map_err(|_| StandaloneStartupError::StateStore)?;
|
|
||||||
let worker_store = FsWorkerStore::new(store.worker_metadata_dir(worker_id))
|
|
||||||
.map_err(|_| StandaloneStartupError::StateStore)?;
|
|
||||||
Ok((
|
|
||||||
CombinedStore::new(session_store, worker_store.clone()),
|
|
||||||
worker_store,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn active_pointer(
|
|
||||||
worker_store: &FsWorkerStore,
|
|
||||||
storage_key: &str,
|
|
||||||
) -> Result<WorkerActiveSegmentRef, StandaloneStartupError> {
|
|
||||||
worker_store
|
|
||||||
.read_by_name(storage_key)
|
|
||||||
.map_err(|_| StandaloneStartupError::StateStore)?
|
|
||||||
.and_then(|metadata| metadata.active)
|
|
||||||
.ok_or(StandaloneStartupError::StateStore)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stop_started_worker(started: BootstrappedWorker) {
|
|
||||||
let command = protocol::WorkerCommandEnvelope::new(u64::MAX);
|
|
||||||
let _ = started.handle.send(Method::Shutdown { command }).await;
|
|
||||||
let _ = tokio::time::timeout(Duration::from_secs(2), started.shutdown).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classify_store_startup_error(error: StandaloneStoreError) -> StandaloneStartupError {
|
|
||||||
match error {
|
|
||||||
StandaloneStoreError::WorkerLeased(_) => StandaloneStartupError::WorkerActive,
|
|
||||||
StandaloneStoreError::LeaseLivenessUnknown(_) => {
|
|
||||||
StandaloneStartupError::LeaseLivenessUnknown
|
|
||||||
}
|
|
||||||
StandaloneStoreError::CwdUnavailable(_)
|
|
||||||
| StandaloneStoreError::CwdNotDirectory
|
|
||||||
| StandaloneStoreError::CwdIdentityMismatch => {
|
|
||||||
StandaloneStartupError::WorkingDirectoryUnavailable
|
|
||||||
}
|
|
||||||
_ => StandaloneStartupError::StateStore,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classify_startup_error(error: WorkerBootstrapError) -> StandaloneStartupError {
|
|
||||||
match error {
|
|
||||||
WorkerBootstrapError::Worker(WorkerError::ScopeLock(ScopeLockError::WriteConflict {
|
|
||||||
competitor,
|
|
||||||
rule,
|
|
||||||
competitor_rule,
|
|
||||||
})) => StandaloneStartupError::ScopeConflict {
|
|
||||||
competitor,
|
|
||||||
requested_rule: rule,
|
|
||||||
competitor_rule,
|
|
||||||
},
|
|
||||||
WorkerBootstrapError::Worker(WorkerError::Provider(_)) => {
|
|
||||||
StandaloneStartupError::ModelProvider
|
|
||||||
}
|
|
||||||
WorkerBootstrapError::Worker(_) => StandaloneStartupError::WorkerConfiguration,
|
|
||||||
WorkerBootstrapError::Controller { source, .. }
|
|
||||||
if source.kind() == std::io::ErrorKind::Other =>
|
|
||||||
{
|
|
||||||
StandaloneStartupError::FeatureComposition
|
|
||||||
}
|
|
||||||
WorkerBootstrapError::Controller { .. } => StandaloneStartupError::Controller,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use manifest::{
|
|
||||||
ProfileExecutionTarget, ProfileResolveOptions, ProfileResolver, ProfileSelector,
|
|
||||||
ResolvedProfile,
|
|
||||||
};
|
|
||||||
use thiserror::Error;
|
|
||||||
use worker::PromptCatalogSource;
|
|
||||||
|
|
||||||
/// Process launch input resolved before any Worker/session side effect occurs.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct StandaloneLaunchConfig {
|
|
||||||
pub cwd: PathBuf,
|
|
||||||
pub state_dir: PathBuf,
|
|
||||||
pub profile: ProfileSelector,
|
|
||||||
pub worker_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ResolvedStandaloneLaunch {
|
|
||||||
pub cwd: PathBuf,
|
|
||||||
pub state_dir: PathBuf,
|
|
||||||
pub profile: ResolvedProfile,
|
|
||||||
pub prompt_catalog: PromptCatalogSource,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
|
||||||
pub enum StandaloneLaunchError {
|
|
||||||
#[error("the standalone working directory is unavailable")]
|
|
||||||
WorkingDirectoryUnavailable,
|
|
||||||
#[error("path-based profiles are not standalone launch authority")]
|
|
||||||
PathProfileUnsupported,
|
|
||||||
#[error("the standalone profile could not be resolved")]
|
|
||||||
ProfileResolutionFailed,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StandaloneLaunchConfig {
|
|
||||||
pub fn new(
|
|
||||||
cwd: impl Into<PathBuf>,
|
|
||||||
state_dir: impl Into<PathBuf>,
|
|
||||||
profile: ProfileSelector,
|
|
||||||
worker_name: impl Into<String>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
cwd: cwd.into(),
|
|
||||||
state_dir: state_dir.into(),
|
|
||||||
profile,
|
|
||||||
worker_name: worker_name.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve only built-in/XDG profile authority and bind standalone scope
|
|
||||||
/// to the canonical process cwd. Repository-local profile discovery is
|
|
||||||
/// deliberately not part of this path.
|
|
||||||
pub fn resolve(self) -> Result<ResolvedStandaloneLaunch, StandaloneLaunchError> {
|
|
||||||
if matches!(self.profile, ProfileSelector::Path { .. }) {
|
|
||||||
return Err(StandaloneLaunchError::PathProfileUnsupported);
|
|
||||||
}
|
|
||||||
let cwd = canonical_directory(&self.cwd)?;
|
|
||||||
let profile = ProfileResolver::new()
|
|
||||||
.with_workspace_base(&cwd)
|
|
||||||
.resolve_for_target(
|
|
||||||
&self.profile,
|
|
||||||
ProfileResolveOptions {
|
|
||||||
worker_name: Some(self.worker_name),
|
|
||||||
},
|
|
||||||
ProfileExecutionTarget::Standalone,
|
|
||||||
)
|
|
||||||
.map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?;
|
|
||||||
|
|
||||||
Ok(ResolvedStandaloneLaunch {
|
|
||||||
cwd,
|
|
||||||
state_dir: self.state_dir,
|
|
||||||
profile,
|
|
||||||
prompt_catalog: PromptCatalogSource::builtins_only(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn canonical_directory(path: &Path) -> Result<PathBuf, StandaloneLaunchError> {
|
|
||||||
let path = std::fs::canonicalize(path)
|
|
||||||
.map_err(|_| StandaloneLaunchError::WorkingDirectoryUnavailable)?;
|
|
||||||
if !path.is_dir() {
|
|
||||||
return Err(StandaloneLaunchError::WorkingDirectoryUnavailable);
|
|
||||||
}
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
//! In-process standalone host for one top-level Yoi Worker.
|
|
||||||
//!
|
|
||||||
//! The crate composes existing `worker`, `manifest`, `session-store`, and
|
|
||||||
//! `workdir` contracts. It intentionally owns no TUI, Runtime, Workspace
|
|
||||||
//! Server, HTTP, WebSocket, subprocess Worker, or alternative execution path.
|
|
||||||
|
|
||||||
pub mod host;
|
|
||||||
pub mod launch;
|
|
||||||
pub mod store;
|
|
||||||
|
|
||||||
pub use host::{StandaloneHost, StandaloneShutdownError, StandaloneStartupError};
|
|
||||||
pub use launch::{ResolvedStandaloneLaunch, StandaloneLaunchConfig, StandaloneLaunchError};
|
|
||||||
pub use protocol::WorkerId;
|
|
||||||
pub use store::{
|
|
||||||
StaleLeasePolicy, StandaloneCwdIdentity, StandaloneListScope, StandaloneShutdownReason,
|
|
||||||
StandaloneStoreError, StandaloneWorkerRecord, StandaloneWorkerStatus, StandaloneWorkerStore,
|
|
||||||
};
|
|
||||||
@@ -1,846 +0,0 @@
|
|||||||
use std::fs::{self, File, OpenOptions};
|
|
||||||
use std::io::{self, Write};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use fs4::fs_std::FileExt;
|
|
||||||
use manifest::WorkerManifest;
|
|
||||||
use protocol::WorkerId;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use session_store::{SegmentId, SessionId};
|
|
||||||
use thiserror::Error;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
const RECORD_FILE: &str = "record.json";
|
|
||||||
const COMMIT_MARKER: &str = "commit.pending";
|
|
||||||
const LEASE_FILE: &str = "lease.json";
|
|
||||||
const LEASE_LOCK_FILE: &str = "lease.lock";
|
|
||||||
const SESSIONS_DIR: &str = "sessions";
|
|
||||||
const WORKER_DIR: &str = "worker";
|
|
||||||
const SCHEMA_VERSION: u32 = 1;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct StandaloneCwdIdentity {
|
|
||||||
pub canonical_path: PathBuf,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub device: Option<u64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub inode: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StandaloneCwdIdentity {
|
|
||||||
pub fn capture(path: impl AsRef<Path>) -> Result<Self, StandaloneStoreError> {
|
|
||||||
let canonical_path =
|
|
||||||
fs::canonicalize(path).map_err(StandaloneStoreError::CwdUnavailable)?;
|
|
||||||
let metadata =
|
|
||||||
fs::metadata(&canonical_path).map_err(StandaloneStoreError::CwdUnavailable)?;
|
|
||||||
if !metadata.is_dir() {
|
|
||||||
return Err(StandaloneStoreError::CwdNotDirectory);
|
|
||||||
}
|
|
||||||
#[cfg(unix)]
|
|
||||||
let (device, inode) = {
|
|
||||||
use std::os::unix::fs::MetadataExt;
|
|
||||||
(Some(metadata.dev()), Some(metadata.ino()))
|
|
||||||
};
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
let (device, inode) = (None, None);
|
|
||||||
Ok(Self {
|
|
||||||
canonical_path,
|
|
||||||
device,
|
|
||||||
inode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn verify(&self) -> Result<PathBuf, StandaloneStoreError> {
|
|
||||||
let current = Self::capture(&self.canonical_path)?;
|
|
||||||
if current != *self {
|
|
||||||
return Err(StandaloneStoreError::CwdIdentityMismatch);
|
|
||||||
}
|
|
||||||
Ok(current.canonical_path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum StandaloneWorkerStatus {
|
|
||||||
Active,
|
|
||||||
Stopped,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum StandaloneShutdownReason {
|
|
||||||
UserExit,
|
|
||||||
StartupFailed,
|
|
||||||
ControllerError,
|
|
||||||
ProcessInterrupted,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct StandaloneWorkerRecord {
|
|
||||||
pub schema_version: u32,
|
|
||||||
pub revision: u64,
|
|
||||||
pub worker_id: WorkerId,
|
|
||||||
/// User-facing Worker name resolved from the profile.
|
|
||||||
pub worker_name: String,
|
|
||||||
/// Internal key used by the current name-keyed Worker store.
|
|
||||||
pub storage_key: String,
|
|
||||||
pub cwd: StandaloneCwdIdentity,
|
|
||||||
pub manifest: WorkerManifest,
|
|
||||||
pub active_session_id: SessionId,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub active_segment_id: Option<SegmentId>,
|
|
||||||
pub status: StandaloneWorkerStatus,
|
|
||||||
pub created_at_unix_ms: u64,
|
|
||||||
pub updated_at_unix_ms: u64,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub shutdown_reason: Option<StandaloneShutdownReason>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum StandaloneListScope {
|
|
||||||
CurrentCwd,
|
|
||||||
All,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum StaleLeasePolicy {
|
|
||||||
Reject,
|
|
||||||
Recover,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct StandaloneWorkerStore {
|
|
||||||
root: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StandaloneWorkerStore {
|
|
||||||
pub fn open(root: impl Into<PathBuf>) -> Result<Self, StandaloneStoreError> {
|
|
||||||
let root = root.into();
|
|
||||||
fs::create_dir_all(&root).map_err(StandaloneStoreError::Io)?;
|
|
||||||
if !fs::metadata(&root)
|
|
||||||
.map_err(StandaloneStoreError::Io)?
|
|
||||||
.is_dir()
|
|
||||||
{
|
|
||||||
return Err(StandaloneStoreError::NotDirectory);
|
|
||||||
}
|
|
||||||
Ok(Self { root })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn root(&self) -> &Path {
|
|
||||||
&self.root
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn allocate(
|
|
||||||
&self,
|
|
||||||
cwd: impl AsRef<Path>,
|
|
||||||
policy: StaleLeasePolicy,
|
|
||||||
) -> Result<StandaloneWorkerAllocation, StandaloneStoreError> {
|
|
||||||
let worker_id = WorkerId::now_v7();
|
|
||||||
let cwd = StandaloneCwdIdentity::capture(cwd)?;
|
|
||||||
let dir = self.worker_dir(worker_id);
|
|
||||||
fs::create_dir(&dir).map_err(StandaloneStoreError::Io)?;
|
|
||||||
fs::create_dir(dir.join(SESSIONS_DIR)).map_err(StandaloneStoreError::Io)?;
|
|
||||||
fs::create_dir(dir.join(WORKER_DIR)).map_err(StandaloneStoreError::Io)?;
|
|
||||||
let lease = self.acquire_lease(worker_id, policy)?;
|
|
||||||
Ok(StandaloneWorkerAllocation {
|
|
||||||
worker_id,
|
|
||||||
cwd,
|
|
||||||
lease,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn commit_created(
|
|
||||||
&self,
|
|
||||||
allocation: &StandaloneWorkerAllocation,
|
|
||||||
manifest: WorkerManifest,
|
|
||||||
storage_key: String,
|
|
||||||
active_session_id: SessionId,
|
|
||||||
active_segment_id: Option<SegmentId>,
|
|
||||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
|
||||||
let now = now_unix_ms()?;
|
|
||||||
let record = StandaloneWorkerRecord {
|
|
||||||
schema_version: SCHEMA_VERSION,
|
|
||||||
revision: 1,
|
|
||||||
worker_id: allocation.worker_id,
|
|
||||||
worker_name: manifest.worker.name.clone(),
|
|
||||||
storage_key,
|
|
||||||
cwd: allocation.cwd.clone(),
|
|
||||||
manifest,
|
|
||||||
active_session_id,
|
|
||||||
active_segment_id,
|
|
||||||
status: StandaloneWorkerStatus::Active,
|
|
||||||
created_at_unix_ms: now,
|
|
||||||
updated_at_unix_ms: now,
|
|
||||||
shutdown_reason: None,
|
|
||||||
};
|
|
||||||
self.commit_record(None, &record)?;
|
|
||||||
Ok(record)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load(&self, id: WorkerId) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
|
||||||
let dir = self.worker_dir(id);
|
|
||||||
if dir.join(COMMIT_MARKER).exists() {
|
|
||||||
return Err(StandaloneStoreError::IncompleteCommit(id));
|
|
||||||
}
|
|
||||||
let bytes = fs::read(dir.join(RECORD_FILE)).map_err(|error| {
|
|
||||||
if error.kind() == io::ErrorKind::NotFound {
|
|
||||||
StandaloneStoreError::WorkerNotFound(id)
|
|
||||||
} else {
|
|
||||||
StandaloneStoreError::Io(error)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let record = decode_worker_record(id, &bytes)?;
|
|
||||||
if record.schema_version > SCHEMA_VERSION {
|
|
||||||
return Err(StandaloneStoreError::NewerSchema {
|
|
||||||
id,
|
|
||||||
found: record.schema_version,
|
|
||||||
supported: SCHEMA_VERSION,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if record.schema_version != SCHEMA_VERSION || record.worker_id != id {
|
|
||||||
return Err(StandaloneStoreError::InvalidRecord(id));
|
|
||||||
}
|
|
||||||
Ok(record)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list(
|
|
||||||
&self,
|
|
||||||
cwd: impl AsRef<Path>,
|
|
||||||
scope: StandaloneListScope,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<Vec<StandaloneWorkerRecord>, StandaloneStoreError> {
|
|
||||||
let current_cwd = (scope == StandaloneListScope::CurrentCwd)
|
|
||||||
.then(|| StandaloneCwdIdentity::capture(cwd))
|
|
||||||
.transpose()?;
|
|
||||||
let mut records = Vec::new();
|
|
||||||
for entry in fs::read_dir(&self.root).map_err(StandaloneStoreError::Io)? {
|
|
||||||
let entry = entry.map_err(StandaloneStoreError::Io)?;
|
|
||||||
if !entry
|
|
||||||
.file_type()
|
|
||||||
.map_err(StandaloneStoreError::Io)?
|
|
||||||
.is_dir()
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(id) = entry.file_name().to_string_lossy().parse() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let record = self.load(id)?;
|
|
||||||
if current_cwd.as_ref().is_none_or(|cwd| &record.cwd == cwd) {
|
|
||||||
records.push(record);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
records.sort_by(|left, right| {
|
|
||||||
right
|
|
||||||
.updated_at_unix_ms
|
|
||||||
.cmp(&left.updated_at_unix_ms)
|
|
||||||
.then_with(|| right.worker_id.to_string().cmp(&left.worker_id.to_string()))
|
|
||||||
});
|
|
||||||
records.truncate(limit);
|
|
||||||
Ok(records)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn acquire_lease(
|
|
||||||
&self,
|
|
||||||
id: WorkerId,
|
|
||||||
policy: StaleLeasePolicy,
|
|
||||||
) -> Result<StandaloneWorkerLease, StandaloneStoreError> {
|
|
||||||
let dir = self.worker_dir(id);
|
|
||||||
let path = dir.join(LEASE_FILE);
|
|
||||||
let _guard = LeaseMutationGuard::acquire(&dir)?;
|
|
||||||
let lease = LeaseRecord::current()?;
|
|
||||||
loop {
|
|
||||||
match OpenOptions::new().write(true).create_new(true).open(&path) {
|
|
||||||
Ok(mut file) => {
|
|
||||||
serde_json::to_writer(&mut file, &lease).map_err(StandaloneStoreError::Json)?;
|
|
||||||
file.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
|
|
||||||
file.sync_all().map_err(StandaloneStoreError::Io)?;
|
|
||||||
sync_directory(&dir)?;
|
|
||||||
return Ok(StandaloneWorkerLease {
|
|
||||||
path,
|
|
||||||
lease_id: lease.lease_id,
|
|
||||||
released: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
|
|
||||||
let existing = read_lease(&path, id)?;
|
|
||||||
match existing.liveness() {
|
|
||||||
LeaseLiveness::Live => {
|
|
||||||
return Err(StandaloneStoreError::WorkerLeased(id));
|
|
||||||
}
|
|
||||||
LeaseLiveness::Unknown => {
|
|
||||||
return Err(StandaloneStoreError::LeaseLivenessUnknown(id));
|
|
||||||
}
|
|
||||||
LeaseLiveness::Stale => {}
|
|
||||||
}
|
|
||||||
if policy == StaleLeasePolicy::Reject {
|
|
||||||
return Err(StandaloneStoreError::StaleLease(id));
|
|
||||||
}
|
|
||||||
fs::remove_file(&path).map_err(StandaloneStoreError::Io)?;
|
|
||||||
sync_directory(&dir)?;
|
|
||||||
}
|
|
||||||
Err(error) => return Err(StandaloneStoreError::Io(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_active_pointer(
|
|
||||||
&self,
|
|
||||||
record: &StandaloneWorkerRecord,
|
|
||||||
active_session_id: SessionId,
|
|
||||||
active_segment_id: Option<SegmentId>,
|
|
||||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
|
||||||
let mut next = record.clone();
|
|
||||||
next.revision = next.revision.saturating_add(1);
|
|
||||||
next.updated_at_unix_ms = now_unix_ms()?;
|
|
||||||
next.active_session_id = active_session_id;
|
|
||||||
next.active_segment_id = active_segment_id;
|
|
||||||
next.status = StandaloneWorkerStatus::Active;
|
|
||||||
next.shutdown_reason = None;
|
|
||||||
self.commit_record(Some(record.revision), &next)?;
|
|
||||||
Ok(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn mark_stopped(
|
|
||||||
&self,
|
|
||||||
record: &StandaloneWorkerRecord,
|
|
||||||
active_session_id: SessionId,
|
|
||||||
active_segment_id: Option<SegmentId>,
|
|
||||||
reason: StandaloneShutdownReason,
|
|
||||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
|
||||||
let mut next = record.clone();
|
|
||||||
next.revision = next.revision.saturating_add(1);
|
|
||||||
next.updated_at_unix_ms = now_unix_ms()?;
|
|
||||||
next.active_session_id = active_session_id;
|
|
||||||
next.active_segment_id = active_segment_id;
|
|
||||||
next.status = StandaloneWorkerStatus::Stopped;
|
|
||||||
next.shutdown_reason = Some(reason);
|
|
||||||
self.commit_record(Some(record.revision), &next)?;
|
|
||||||
Ok(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete(&self, id: WorkerId) -> Result<(), StandaloneStoreError> {
|
|
||||||
let record = self.load(id)?;
|
|
||||||
if record.status != StandaloneWorkerStatus::Stopped {
|
|
||||||
return Err(StandaloneStoreError::DeleteActive(id));
|
|
||||||
}
|
|
||||||
let worker_dir = self.worker_dir(id);
|
|
||||||
let _guard = LeaseMutationGuard::acquire(&worker_dir)?;
|
|
||||||
let lease_path = worker_dir.join(LEASE_FILE);
|
|
||||||
if lease_path.exists() {
|
|
||||||
let lease = read_lease(&lease_path, id)?;
|
|
||||||
return Err(match lease.liveness() {
|
|
||||||
LeaseLiveness::Live => StandaloneStoreError::WorkerLeased(id),
|
|
||||||
LeaseLiveness::Stale => StandaloneStoreError::StaleLease(id),
|
|
||||||
LeaseLiveness::Unknown => StandaloneStoreError::LeaseLivenessUnknown(id),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
fs::remove_dir_all(self.worker_dir(id)).map_err(StandaloneStoreError::Io)?;
|
|
||||||
sync_directory(&self.root)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn sessions_dir(&self, id: WorkerId) -> PathBuf {
|
|
||||||
self.worker_dir(id).join(SESSIONS_DIR)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn worker_metadata_dir(&self, id: WorkerId) -> PathBuf {
|
|
||||||
self.worker_dir(id).join(WORKER_DIR)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub(crate) fn runtime_dir(&self, id: WorkerId) -> PathBuf {
|
|
||||||
self.worker_dir(id).join("runtime")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn abandon_allocation(
|
|
||||||
&self,
|
|
||||||
allocation: StandaloneWorkerAllocation,
|
|
||||||
) -> Result<(), StandaloneStoreError> {
|
|
||||||
let worker_id = allocation.worker_id;
|
|
||||||
allocation.lease.release()?;
|
|
||||||
fs::remove_dir_all(self.worker_dir(worker_id)).map_err(StandaloneStoreError::Io)?;
|
|
||||||
sync_directory(&self.root)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn commit_record(
|
|
||||||
&self,
|
|
||||||
expected_revision: Option<u64>,
|
|
||||||
next: &StandaloneWorkerRecord,
|
|
||||||
) -> Result<(), StandaloneStoreError> {
|
|
||||||
let dir = self.worker_dir(next.worker_id);
|
|
||||||
let marker = dir.join(COMMIT_MARKER);
|
|
||||||
let mut marker_file = OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.open(&marker)
|
|
||||||
.map_err(|error| {
|
|
||||||
if error.kind() == io::ErrorKind::AlreadyExists {
|
|
||||||
StandaloneStoreError::IncompleteCommit(next.worker_id)
|
|
||||||
} else {
|
|
||||||
StandaloneStoreError::Io(error)
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
writeln!(marker_file, "{}", next.revision).map_err(StandaloneStoreError::Io)?;
|
|
||||||
marker_file.sync_all().map_err(StandaloneStoreError::Io)?;
|
|
||||||
sync_directory(&dir)?;
|
|
||||||
|
|
||||||
if let Some(expected) = expected_revision {
|
|
||||||
let current = self.load_record_while_committing(next.worker_id)?;
|
|
||||||
if current.revision != expected {
|
|
||||||
let _ = fs::remove_file(&marker);
|
|
||||||
return Err(StandaloneStoreError::RevisionConflict {
|
|
||||||
id: next.worker_id,
|
|
||||||
expected,
|
|
||||||
found: current.revision,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let temporary = dir.join(format!("record.{}.tmp", Uuid::now_v7()));
|
|
||||||
let result = (|| {
|
|
||||||
let mut file = OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.create_new(true)
|
|
||||||
.open(&temporary)
|
|
||||||
.map_err(StandaloneStoreError::Io)?;
|
|
||||||
write_worker_record(&mut file, next)?;
|
|
||||||
file.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
|
|
||||||
file.sync_all().map_err(StandaloneStoreError::Io)?;
|
|
||||||
fs::rename(&temporary, dir.join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
|
|
||||||
sync_directory(&dir)?;
|
|
||||||
fs::remove_file(&marker).map_err(StandaloneStoreError::Io)?;
|
|
||||||
sync_directory(&dir)
|
|
||||||
})();
|
|
||||||
if result.is_err() {
|
|
||||||
let _ = fs::remove_file(&temporary);
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_record_while_committing(
|
|
||||||
&self,
|
|
||||||
id: WorkerId,
|
|
||||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
|
||||||
let bytes =
|
|
||||||
fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
|
|
||||||
decode_worker_record(id, &bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn worker_dir(&self, id: WorkerId) -> PathBuf {
|
|
||||||
self.root.join(id.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct StandaloneWorkerAllocation {
|
|
||||||
worker_id: WorkerId,
|
|
||||||
cwd: StandaloneCwdIdentity,
|
|
||||||
lease: StandaloneWorkerLease,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StandaloneWorkerAllocation {
|
|
||||||
#[must_use]
|
|
||||||
pub fn worker_id(&self) -> WorkerId {
|
|
||||||
self.worker_id
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn cwd(&self) -> &StandaloneCwdIdentity {
|
|
||||||
&self.cwd
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn into_lease(self) -> StandaloneWorkerLease {
|
|
||||||
self.lease
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct StandaloneWorkerLease {
|
|
||||||
path: PathBuf,
|
|
||||||
lease_id: Uuid,
|
|
||||||
released: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl StandaloneWorkerLease {
|
|
||||||
pub fn release(mut self) -> Result<(), StandaloneStoreError> {
|
|
||||||
self.release_inner()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn retain(mut self) {
|
|
||||||
self.released = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn release_inner(&mut self) -> Result<(), StandaloneStoreError> {
|
|
||||||
if self.released {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if self.path.exists() {
|
|
||||||
let parent = self.path.parent().expect("lease parent");
|
|
||||||
let _guard = LeaseMutationGuard::acquire(parent)?;
|
|
||||||
let bytes = fs::read(&self.path).map_err(StandaloneStoreError::Io)?;
|
|
||||||
let current: LeaseRecord =
|
|
||||||
serde_json::from_slice(&bytes).map_err(StandaloneStoreError::Json)?;
|
|
||||||
if current.lease_id != self.lease_id {
|
|
||||||
return Err(StandaloneStoreError::LeaseOwnershipLost);
|
|
||||||
}
|
|
||||||
fs::remove_file(&self.path).map_err(StandaloneStoreError::Io)?;
|
|
||||||
sync_directory(self.path.parent().expect("lease parent"))?;
|
|
||||||
}
|
|
||||||
self.released = true;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for StandaloneWorkerLease {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = self.release_inner();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LeaseMutationGuard {
|
|
||||||
file: File,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LeaseMutationGuard {
|
|
||||||
fn acquire(dir: &Path) -> Result<Self, StandaloneStoreError> {
|
|
||||||
let file = OpenOptions::new()
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.create(true)
|
|
||||||
.truncate(false)
|
|
||||||
.open(dir.join(LEASE_LOCK_FILE))
|
|
||||||
.map_err(StandaloneStoreError::Io)?;
|
|
||||||
file.lock_exclusive().map_err(StandaloneStoreError::Io)?;
|
|
||||||
Ok(Self { file })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for LeaseMutationGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = FileExt::unlock(&self.file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
|
||||||
struct LeaseRecord {
|
|
||||||
lease_id: Uuid,
|
|
||||||
pid: u32,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
process_start_marker: Option<u64>,
|
|
||||||
acquired_at_unix_ms: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LeaseRecord {
|
|
||||||
fn current() -> Result<Self, StandaloneStoreError> {
|
|
||||||
Ok(Self {
|
|
||||||
lease_id: Uuid::now_v7(),
|
|
||||||
pid: std::process::id(),
|
|
||||||
process_start_marker: match observe_process(std::process::id()) {
|
|
||||||
ProcessObservation::Running { start_marker } => Some(start_marker),
|
|
||||||
ProcessObservation::Missing | ProcessObservation::Unobservable => None,
|
|
||||||
},
|
|
||||||
acquired_at_unix_ms: now_unix_ms()?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn liveness(&self) -> LeaseLiveness {
|
|
||||||
classify_lease_liveness(self.process_start_marker, observe_process(self.pid))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum LeaseLiveness {
|
|
||||||
Live,
|
|
||||||
Stale,
|
|
||||||
Unknown,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum ProcessObservation {
|
|
||||||
Running { start_marker: u64 },
|
|
||||||
Missing,
|
|
||||||
Unobservable,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classify_lease_liveness(
|
|
||||||
recorded_start_marker: Option<u64>,
|
|
||||||
observation: ProcessObservation,
|
|
||||||
) -> LeaseLiveness {
|
|
||||||
match (recorded_start_marker, observation) {
|
|
||||||
(Some(recorded), ProcessObservation::Running { start_marker })
|
|
||||||
if recorded == start_marker =>
|
|
||||||
{
|
|
||||||
LeaseLiveness::Live
|
|
||||||
}
|
|
||||||
(Some(_), ProcessObservation::Running { .. }) | (_, ProcessObservation::Missing) => {
|
|
||||||
LeaseLiveness::Stale
|
|
||||||
}
|
|
||||||
(None, ProcessObservation::Running { .. }) | (_, ProcessObservation::Unobservable) => {
|
|
||||||
LeaseLiveness::Unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_lease(path: &Path, id: WorkerId) -> Result<LeaseRecord, StandaloneStoreError> {
|
|
||||||
let bytes = fs::read(path).map_err(StandaloneStoreError::Io)?;
|
|
||||||
serde_json::from_slice(&bytes)
|
|
||||||
.map_err(|source| StandaloneStoreError::CorruptLease { id, source })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn observe_process(pid: u32) -> ProcessObservation {
|
|
||||||
let stat = match fs::read_to_string(format!("/proc/{pid}/stat")) {
|
|
||||||
Ok(stat) => stat,
|
|
||||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
|
||||||
return if pid != std::process::id() && linux_proc_is_observable() {
|
|
||||||
ProcessObservation::Missing
|
|
||||||
} else {
|
|
||||||
ProcessObservation::Unobservable
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Err(_) => return ProcessObservation::Unobservable,
|
|
||||||
};
|
|
||||||
parse_linux_process_start_marker(&stat)
|
|
||||||
.map(|start_marker| ProcessObservation::Running { start_marker })
|
|
||||||
.unwrap_or(ProcessObservation::Unobservable)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn linux_proc_is_observable() -> bool {
|
|
||||||
fs::read_to_string("/proc/self/stat")
|
|
||||||
.ok()
|
|
||||||
.and_then(|stat| parse_linux_process_start_marker(&stat))
|
|
||||||
.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
fn parse_linux_process_start_marker(stat: &str) -> Option<u64> {
|
|
||||||
let (_, tail) = stat.rsplit_once(") ")?;
|
|
||||||
tail.split_whitespace().nth(19)?.parse().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
fn observe_process(pid: u32) -> ProcessObservation {
|
|
||||||
if pid == std::process::id() {
|
|
||||||
ProcessObservation::Running { start_marker: 0 }
|
|
||||||
} else {
|
|
||||||
ProcessObservation::Unobservable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_worker_record(
|
|
||||||
id: WorkerId,
|
|
||||||
bytes: &[u8],
|
|
||||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
|
||||||
let decode = || -> Result<StandaloneWorkerRecord, serde_json::Error> {
|
|
||||||
let mut snapshot: serde_json::Value = serde_json::from_slice(bytes)?;
|
|
||||||
let object = snapshot.as_object_mut().ok_or_else(|| {
|
|
||||||
serde_json::Error::io(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
"standalone Worker record must be an object",
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let persisted_manifest = object.remove("manifest").ok_or_else(|| {
|
|
||||||
serde_json::Error::io(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
"standalone Worker record is missing manifest",
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let manifest = manifest::read_persisted_worker_manifest_snapshot(persisted_manifest)?;
|
|
||||||
object.insert("manifest".to_string(), serde_json::to_value(manifest)?);
|
|
||||||
serde_json::from_value(snapshot)
|
|
||||||
};
|
|
||||||
decode().map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_worker_record(
|
|
||||||
writer: &mut impl Write,
|
|
||||||
record: &StandaloneWorkerRecord,
|
|
||||||
) -> Result<(), StandaloneStoreError> {
|
|
||||||
let mut snapshot = serde_json::to_value(record).map_err(StandaloneStoreError::Json)?;
|
|
||||||
let object = snapshot.as_object_mut().ok_or_else(|| {
|
|
||||||
StandaloneStoreError::Json(serde_json::Error::io(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidData,
|
|
||||||
"standalone Worker record must be an object",
|
|
||||||
)))
|
|
||||||
})?;
|
|
||||||
object.insert(
|
|
||||||
"manifest".to_string(),
|
|
||||||
manifest::write_persisted_worker_manifest_snapshot(&record.manifest)
|
|
||||||
.map_err(StandaloneStoreError::Json)?,
|
|
||||||
);
|
|
||||||
serde_json::to_writer_pretty(writer, &snapshot).map_err(StandaloneStoreError::Json)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_unix_ms() -> Result<u64, StandaloneStoreError> {
|
|
||||||
let duration = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map_err(|_| StandaloneStoreError::Clock)?;
|
|
||||||
u64::try_from(duration.as_millis()).map_err(|_| StandaloneStoreError::Clock)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_directory(path: &Path) -> Result<(), StandaloneStoreError> {
|
|
||||||
File::open(path)
|
|
||||||
.and_then(|file| file.sync_all())
|
|
||||||
.map_err(StandaloneStoreError::Io)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum StandaloneStoreError {
|
|
||||||
#[error("standalone state path is not a directory")]
|
|
||||||
NotDirectory,
|
|
||||||
#[error("standalone cwd is unavailable")]
|
|
||||||
CwdUnavailable(#[source] io::Error),
|
|
||||||
#[error("standalone cwd is not a directory")]
|
|
||||||
CwdNotDirectory,
|
|
||||||
#[error("standalone cwd identity no longer matches the persisted Worker")]
|
|
||||||
CwdIdentityMismatch,
|
|
||||||
#[error("standalone Worker {0} was not found")]
|
|
||||||
WorkerNotFound(WorkerId),
|
|
||||||
#[error("standalone Worker {0} has an incomplete metadata commit")]
|
|
||||||
IncompleteCommit(WorkerId),
|
|
||||||
#[error("standalone Worker {0} has invalid metadata")]
|
|
||||||
InvalidRecord(WorkerId),
|
|
||||||
#[error("standalone Worker {id} metadata is corrupt")]
|
|
||||||
CorruptRecord {
|
|
||||||
id: WorkerId,
|
|
||||||
#[source]
|
|
||||||
source: serde_json::Error,
|
|
||||||
},
|
|
||||||
#[error("standalone Worker {id} lease is corrupt")]
|
|
||||||
CorruptLease {
|
|
||||||
id: WorkerId,
|
|
||||||
#[source]
|
|
||||||
source: serde_json::Error,
|
|
||||||
},
|
|
||||||
#[error("standalone Worker {id} uses schema {found}, newer than supported schema {supported}")]
|
|
||||||
NewerSchema {
|
|
||||||
id: WorkerId,
|
|
||||||
found: u32,
|
|
||||||
supported: u32,
|
|
||||||
},
|
|
||||||
#[error("standalone Worker {0} is already active")]
|
|
||||||
WorkerLeased(WorkerId),
|
|
||||||
#[error("standalone Worker {0} lease liveness cannot be proven; recovery is rejected")]
|
|
||||||
LeaseLivenessUnknown(WorkerId),
|
|
||||||
#[error("standalone Worker {0} has a stale lease; explicit recovery is required")]
|
|
||||||
StaleLease(WorkerId),
|
|
||||||
#[error("standalone Worker lease ownership changed")]
|
|
||||||
LeaseOwnershipLost,
|
|
||||||
#[error("standalone Worker {0} must be stopped before deletion")]
|
|
||||||
DeleteActive(WorkerId),
|
|
||||||
#[error(
|
|
||||||
"standalone Worker {id} metadata revision changed (expected {expected}, found {found})"
|
|
||||||
)]
|
|
||||||
RevisionConflict {
|
|
||||||
id: WorkerId,
|
|
||||||
expected: u64,
|
|
||||||
found: u64,
|
|
||||||
},
|
|
||||||
#[error("system clock is before the Unix epoch or out of range")]
|
|
||||||
Clock,
|
|
||||||
#[error("standalone metadata serialization failed")]
|
|
||||||
Json(#[source] serde_json::Error),
|
|
||||||
#[error("standalone state I/O failed")]
|
|
||||||
Io(#[source] io::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn test_manifest() -> WorkerManifest {
|
|
||||||
WorkerManifest::from_toml(
|
|
||||||
r#"
|
|
||||||
[worker]
|
|
||||||
name = "standalone-test"
|
|
||||||
|
|
||||||
[model]
|
|
||||||
scheme = "anthropic"
|
|
||||||
model_id = "claude-sonnet-4-20250514"
|
|
||||||
|
|
||||||
[engine]
|
|
||||||
|
|
||||||
[[scope.allow]]
|
|
||||||
target = "/tmp"
|
|
||||||
permission = "write"
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn standalone_record_uses_versioned_manifest_adapter_for_legacy_memory() {
|
|
||||||
let worker_id = "01a05782-d5dd-78f1-b9cd-ce37535bdb9d".parse().unwrap();
|
|
||||||
let manifest = test_manifest();
|
|
||||||
let record = StandaloneWorkerRecord {
|
|
||||||
schema_version: SCHEMA_VERSION,
|
|
||||||
revision: 6,
|
|
||||||
worker_id,
|
|
||||||
worker_name: manifest.worker.name.clone(),
|
|
||||||
storage_key: "standalone-test".to_string(),
|
|
||||||
cwd: StandaloneCwdIdentity {
|
|
||||||
canonical_path: PathBuf::from("/tmp"),
|
|
||||||
device: None,
|
|
||||||
inode: None,
|
|
||||||
},
|
|
||||||
manifest,
|
|
||||||
active_session_id: "01a05782-d5dd-78f1-b9cd-ce37535bdb9e".parse().unwrap(),
|
|
||||||
active_segment_id: None,
|
|
||||||
status: StandaloneWorkerStatus::Stopped,
|
|
||||||
created_at_unix_ms: 1,
|
|
||||||
updated_at_unix_ms: 2,
|
|
||||||
shutdown_reason: None,
|
|
||||||
};
|
|
||||||
let mut legacy = serde_json::to_value(&record).unwrap();
|
|
||||||
legacy["manifest"]["feature"]["memory"] = serde_json::json!({
|
|
||||||
"enabled": false,
|
|
||||||
"staging": false,
|
|
||||||
});
|
|
||||||
|
|
||||||
let decoded =
|
|
||||||
decode_worker_record(worker_id, &serde_json::to_vec(&legacy).unwrap()).unwrap();
|
|
||||||
assert!(!decoded.manifest.feature.memory.profile.enabled);
|
|
||||||
|
|
||||||
let mut persisted = Vec::new();
|
|
||||||
write_worker_record(&mut persisted, &decoded).unwrap();
|
|
||||||
let persisted: serde_json::Value = serde_json::from_slice(&persisted).unwrap();
|
|
||||||
assert_eq!(persisted["manifest"]["schema_version"], 2);
|
|
||||||
assert_eq!(
|
|
||||||
persisted["manifest"]["manifest"]["feature"]["memory"]["profile"]["enabled"],
|
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lease_liveness_requires_positive_live_or_stale_evidence() {
|
|
||||||
assert_eq!(
|
|
||||||
classify_lease_liveness(Some(41), ProcessObservation::Running { start_marker: 41 }),
|
|
||||||
LeaseLiveness::Live
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
classify_lease_liveness(Some(41), ProcessObservation::Running { start_marker: 42 }),
|
|
||||||
LeaseLiveness::Stale
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
classify_lease_liveness(Some(41), ProcessObservation::Missing),
|
|
||||||
LeaseLiveness::Stale
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
classify_lease_liveness(None, ProcessObservation::Running { start_marker: 41 }),
|
|
||||||
LeaseLiveness::Unknown
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
classify_lease_liveness(Some(41), ProcessObservation::Unobservable),
|
|
||||||
LeaseLiveness::Unknown
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
classify_lease_liveness(None, ProcessObservation::Unobservable),
|
|
||||||
LeaseLiveness::Unknown
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,622 +0,0 @@
|
|||||||
use std::collections::VecDeque;
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use agen::llm_client::client::LlmClient;
|
|
||||||
use agen::llm_client::error::ClientError;
|
|
||||||
use agen::llm_client::event::{Event as LlmEvent, StopReason};
|
|
||||||
use agen::llm_client::types::Request;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use client::Client;
|
|
||||||
use client::transport::in_process::Socket as InProcessSocket;
|
|
||||||
use futures::{Stream, stream};
|
|
||||||
use protocol::{Event, Method};
|
|
||||||
use standalone::{
|
|
||||||
StaleLeasePolicy, StandaloneHost, StandaloneLaunchConfig, StandaloneListScope,
|
|
||||||
StandaloneStartupError, StandaloneStoreError, StandaloneWorkerStatus, StandaloneWorkerStore,
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct ScriptedClient {
|
|
||||||
responses: Arc<Mutex<VecDeque<Vec<LlmEvent>>>>,
|
|
||||||
requests: Arc<Mutex<Vec<Request>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ScriptedClient {
|
|
||||||
fn new(responses: Vec<Vec<LlmEvent>>) -> Self {
|
|
||||||
Self {
|
|
||||||
responses: Arc::new(Mutex::new(responses.into())),
|
|
||||||
requests: Arc::new(Mutex::new(Vec::new())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn requests(&self) -> Vec<Request> {
|
|
||||||
self.requests.lock().expect("requests lock").clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl LlmClient for ScriptedClient {
|
|
||||||
async fn stream(
|
|
||||||
&self,
|
|
||||||
request: Request,
|
|
||||||
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
|
|
||||||
{
|
|
||||||
self.requests.lock().expect("requests lock").push(request);
|
|
||||||
let response = self
|
|
||||||
.responses
|
|
||||||
.lock()
|
|
||||||
.expect("responses lock")
|
|
||||||
.pop_front()
|
|
||||||
.expect("scripted response");
|
|
||||||
Ok(Box::pin(stream::iter(response.into_iter().map(Ok))))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
|
||||||
Box::new(self.clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
|
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
|
||||||
std::fs::write(temp.path().join("probe.txt"), "standalone tool evidence\n")
|
|
||||||
.expect("write probe");
|
|
||||||
let worker_name = format!("standalone-{}", Uuid::now_v7());
|
|
||||||
let launch = StandaloneLaunchConfig::new(
|
|
||||||
temp.path(),
|
|
||||||
temp.path().join("state"),
|
|
||||||
manifest::ProfileSelector::Default,
|
|
||||||
&worker_name,
|
|
||||||
)
|
|
||||||
.resolve()
|
|
||||||
.expect("resolve standalone profile");
|
|
||||||
|
|
||||||
let client = ScriptedClient::new(vec![
|
|
||||||
vec![
|
|
||||||
LlmEvent::tool_use_start(0, "read-1", "Read"),
|
|
||||||
LlmEvent::tool_input_delta(0, r#"{"file_path":"probe.txt"}"#),
|
|
||||||
LlmEvent::tool_use_stop(0),
|
|
||||||
],
|
|
||||||
vec![
|
|
||||||
LlmEvent::text_block_start(0),
|
|
||||||
LlmEvent::text_delta(0, "standalone response"),
|
|
||||||
LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
let inspection = client.clone();
|
|
||||||
let host = StandaloneHost::start_with_model_client(launch, client)
|
|
||||||
.await
|
|
||||||
.expect("start in-process host");
|
|
||||||
assert_eq!(host.record().worker_name, worker_name);
|
|
||||||
assert_eq!(host.record().manifest.worker.name, worker_name);
|
|
||||||
assert_eq!(
|
|
||||||
host.record().storage_key,
|
|
||||||
format!("standalone-{}", host.worker_id())
|
|
||||||
);
|
|
||||||
let mut protocol_client = host.connect();
|
|
||||||
|
|
||||||
protocol_client
|
|
||||||
.send(&Method::submit_text(
|
|
||||||
protocol::new_submission_request_id(),
|
|
||||||
"read the probe",
|
|
||||||
))
|
|
||||||
.await
|
|
||||||
.expect("submit input");
|
|
||||||
|
|
||||||
tokio::time::timeout(Duration::from_secs(30), async {
|
|
||||||
let mut saw_user_message = false;
|
|
||||||
let mut saw_text = false;
|
|
||||||
let mut saw_tool_result = false;
|
|
||||||
loop {
|
|
||||||
match protocol_client
|
|
||||||
.next_event()
|
|
||||||
.await
|
|
||||||
.expect("protocol event")
|
|
||||||
.expect("worker event")
|
|
||||||
{
|
|
||||||
Event::UserMessage { segments }
|
|
||||||
if format!("{segments:?}").contains("read the probe") =>
|
|
||||||
{
|
|
||||||
saw_user_message = true;
|
|
||||||
}
|
|
||||||
Event::TextDelta { text } if text.contains("standalone response") => {
|
|
||||||
saw_text = true;
|
|
||||||
}
|
|
||||||
Event::ToolResult { .. } => {
|
|
||||||
saw_tool_result = true;
|
|
||||||
}
|
|
||||||
Event::RunEnd { .. } => {
|
|
||||||
assert!(
|
|
||||||
saw_user_message,
|
|
||||||
"stream must expose the committed user message"
|
|
||||||
);
|
|
||||||
assert!(saw_text, "stream must expose the model text delta");
|
|
||||||
assert!(saw_tool_result, "stream must expose the tool result");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("run completed");
|
|
||||||
|
|
||||||
let requests = inspection.requests();
|
|
||||||
assert_eq!(requests.len(), 2);
|
|
||||||
let tool_names = requests[0]
|
|
||||||
.tools
|
|
||||||
.iter()
|
|
||||||
.map(|tool| tool.name.as_str())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
assert!(tool_names.contains(&"Read"));
|
|
||||||
assert!(tool_names.contains(&"TaskCreate"));
|
|
||||||
assert!(tool_names.contains(&"SubWorkerSpawn"));
|
|
||||||
assert!(format!("{:?}", requests[1].items).contains("standalone tool evidence"));
|
|
||||||
assert!(
|
|
||||||
!temp
|
|
||||||
.path()
|
|
||||||
.join("state/runtime")
|
|
||||||
.join(&worker_name)
|
|
||||||
.join("worker.sock")
|
|
||||||
.exists()
|
|
||||||
);
|
|
||||||
|
|
||||||
host.shutdown().await.expect("graceful shutdown");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn startup_preserves_occupied_scope_conflict_details() {
|
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
|
||||||
let cwd = temp.path().join("project");
|
|
||||||
std::fs::create_dir(&cwd).expect("create project");
|
|
||||||
|
|
||||||
let first_launch = StandaloneLaunchConfig::new(
|
|
||||||
&cwd,
|
|
||||||
temp.path().join("first-state"),
|
|
||||||
manifest::ProfileSelector::Default,
|
|
||||||
"first-worker",
|
|
||||||
)
|
|
||||||
.resolve()
|
|
||||||
.expect("resolve first launch");
|
|
||||||
let first_host =
|
|
||||||
StandaloneHost::start_with_model_client(first_launch, ScriptedClient::new(Vec::new()))
|
|
||||||
.await
|
|
||||||
.expect("start first host");
|
|
||||||
let competitor = first_host.record().storage_key.clone();
|
|
||||||
|
|
||||||
let second_launch = StandaloneLaunchConfig::new(
|
|
||||||
&cwd,
|
|
||||||
temp.path().join("second-state"),
|
|
||||||
manifest::ProfileSelector::Default,
|
|
||||||
"second-worker",
|
|
||||||
)
|
|
||||||
.resolve()
|
|
||||||
.expect("resolve second launch");
|
|
||||||
let error =
|
|
||||||
StandaloneHost::start_with_model_client(second_launch, ScriptedClient::new(Vec::new()))
|
|
||||||
.await
|
|
||||||
.err()
|
|
||||||
.expect("occupied scope rejected");
|
|
||||||
|
|
||||||
first_host.shutdown().await.expect("shutdown first host");
|
|
||||||
|
|
||||||
let canonical_cwd = cwd.canonicalize().expect("canonical cwd");
|
|
||||||
match &error {
|
|
||||||
StandaloneStartupError::ScopeConflict {
|
|
||||||
competitor: actual_competitor,
|
|
||||||
requested_rule,
|
|
||||||
competitor_rule,
|
|
||||||
} => {
|
|
||||||
assert_eq!(actual_competitor, &competitor);
|
|
||||||
assert_eq!(requested_rule.target, canonical_cwd);
|
|
||||||
assert_eq!(competitor_rule.target, canonical_cwd);
|
|
||||||
}
|
|
||||||
other => panic!("expected scope conflict, got {other:?}"),
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
error.to_string(),
|
|
||||||
format!(
|
|
||||||
"requested scope `{}` conflicts with worker allocation `{competitor}` rule `{}`",
|
|
||||||
canonical_cwd.display(),
|
|
||||||
canonical_cwd.display()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn state_store_failure_is_redacted_and_starts_no_controller() {
|
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
|
||||||
let state_path = temp.path().join("state-file-with-secret-name");
|
|
||||||
std::fs::write(&state_path, "not a directory").expect("write blocking file");
|
|
||||||
let launch = StandaloneLaunchConfig::new(
|
|
||||||
temp.path(),
|
|
||||||
&state_path,
|
|
||||||
manifest::ProfileSelector::Default,
|
|
||||||
format!("standalone-failure-{}", Uuid::now_v7()),
|
|
||||||
)
|
|
||||||
.resolve()
|
|
||||||
.expect("resolve launch");
|
|
||||||
let client = ScriptedClient::new(Vec::new());
|
|
||||||
|
|
||||||
let error = StandaloneHost::start_with_model_client(launch, client)
|
|
||||||
.await
|
|
||||||
.err()
|
|
||||||
.expect("state store startup rejected");
|
|
||||||
assert_eq!(error, standalone::StandaloneStartupError::StateStore);
|
|
||||||
assert_eq!(
|
|
||||||
error.to_string(),
|
|
||||||
"the standalone state store could not be opened or validated"
|
|
||||||
);
|
|
||||||
assert!(!error.to_string().contains("secret-name"));
|
|
||||||
assert!(
|
|
||||||
!temp
|
|
||||||
.path()
|
|
||||||
.join("state-file-with-secret-name/runtime")
|
|
||||||
.exists()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn standalone_crate_has_no_tui_runtime_or_workspace_server_dependency() {
|
|
||||||
let manifest = include_str!("../Cargo.toml");
|
|
||||||
let dependencies = manifest
|
|
||||||
.split("[dependencies]")
|
|
||||||
.nth(1)
|
|
||||||
.expect("dependencies section")
|
|
||||||
.split("[dev-dependencies]")
|
|
||||||
.next()
|
|
||||||
.expect("dependency body");
|
|
||||||
for forbidden in ["tui", "worker-runtime", "yoi-workspace-server"] {
|
|
||||||
assert!(
|
|
||||||
!dependencies.lines().any(|line| {
|
|
||||||
line.split_once('=')
|
|
||||||
.is_some_and(|(name, _)| name.trim() == forbidden)
|
|
||||||
}),
|
|
||||||
"standalone must not depend on {forbidden}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn launch_rejects_path_profile_before_worker_startup() {
|
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
|
||||||
let error = StandaloneLaunchConfig::new(
|
|
||||||
temp.path(),
|
|
||||||
temp.path().join("state"),
|
|
||||||
manifest::ProfileSelector::Path {
|
|
||||||
path: temp.path().join("profile.dcdl"),
|
|
||||||
},
|
|
||||||
"standalone-path-profile",
|
|
||||||
)
|
|
||||||
.resolve()
|
|
||||||
.err()
|
|
||||||
.expect("path profile rejected");
|
|
||||||
assert_eq!(
|
|
||||||
error,
|
|
||||||
standalone::StandaloneLaunchError::PathProfileUnsupported
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type TestResult = Result<(), Box<dyn std::error::Error>>;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope() -> TestResult {
|
|
||||||
let temp = tempfile::tempdir()?;
|
|
||||||
let cwd = temp.path().join("project");
|
|
||||||
let state_dir = temp.path().join("client").join("standalone-workers");
|
|
||||||
std::fs::create_dir_all(&cwd)?;
|
|
||||||
let launch = StandaloneLaunchConfig::new(
|
|
||||||
&cwd,
|
|
||||||
&state_dir,
|
|
||||||
manifest::ProfileSelector::Default,
|
|
||||||
"display-name-is-not-session-identity",
|
|
||||||
)
|
|
||||||
.resolve()?;
|
|
||||||
let first_client = ScriptedClient::new(vec![
|
|
||||||
vec![
|
|
||||||
LlmEvent::tool_use_start(0, "task-1", "TaskCreate"),
|
|
||||||
LlmEvent::tool_input_delta(
|
|
||||||
0,
|
|
||||||
r#"{"subject":"persisted task","description":"survives restore"}"#,
|
|
||||||
),
|
|
||||||
LlmEvent::tool_use_stop(0),
|
|
||||||
],
|
|
||||||
vec![
|
|
||||||
LlmEvent::text_block_start(0),
|
|
||||||
LlmEvent::text_delta(0, "first answer"),
|
|
||||||
LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)),
|
|
||||||
],
|
|
||||||
vec![
|
|
||||||
LlmEvent::text_block_start(0),
|
|
||||||
LlmEvent::text_delta(0, "notification acknowledged"),
|
|
||||||
LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
let host = StandaloneHost::start_with_model_client(launch, first_client).await?;
|
|
||||||
let worker_id = host.worker_id();
|
|
||||||
let mut protocol_client = host.connect();
|
|
||||||
protocol_client
|
|
||||||
.send(&Method::submit_text(
|
|
||||||
protocol::new_submission_request_id(),
|
|
||||||
"first request",
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
wait_for_run_end(&mut protocol_client).await?;
|
|
||||||
protocol_client
|
|
||||||
.send(&Method::Notify {
|
|
||||||
notification_request_id: protocol::new_submission_request_id(),
|
|
||||||
message: "persisted notification".to_string(),
|
|
||||||
auto_run: true,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
wait_for_run_end(&mut protocol_client).await?;
|
|
||||||
host.shutdown().await?;
|
|
||||||
|
|
||||||
let store = StandaloneWorkerStore::open(&state_dir)?;
|
|
||||||
let current = store.list(&cwd, StandaloneListScope::CurrentCwd, 100)?;
|
|
||||||
assert_eq!(current.len(), 1);
|
|
||||||
assert_eq!(current[0].worker_id, worker_id);
|
|
||||||
assert_eq!(current[0].status, StandaloneWorkerStatus::Stopped);
|
|
||||||
let other_cwd = temp.path().join("other");
|
|
||||||
std::fs::create_dir(&other_cwd)?;
|
|
||||||
assert!(
|
|
||||||
store
|
|
||||||
.list(&other_cwd, StandaloneListScope::CurrentCwd, 100)?
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
store.list(&other_cwd, StandaloneListScope::All, 100)?.len(),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
let second_client = ScriptedClient::new(vec![vec![
|
|
||||||
LlmEvent::text_block_start(0),
|
|
||||||
LlmEvent::text_delta(0, "second answer"),
|
|
||||||
LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)),
|
|
||||||
]]);
|
|
||||||
let second_inspection = second_client.clone();
|
|
||||||
let host =
|
|
||||||
StandaloneHost::restore_with_model_client(state_dir.clone(), worker_id, second_client)
|
|
||||||
.await?;
|
|
||||||
assert_eq!(
|
|
||||||
host.record().worker_name,
|
|
||||||
"display-name-is-not-session-identity"
|
|
||||||
);
|
|
||||||
assert_eq!(host.record().storage_key, format!("standalone-{worker_id}"));
|
|
||||||
let mut protocol_client = host.connect();
|
|
||||||
let snapshot = format!(
|
|
||||||
"{:?}",
|
|
||||||
protocol_client
|
|
||||||
.next_event()
|
|
||||||
.await
|
|
||||||
.expect("restored protocol stream")
|
|
||||||
.expect("restored snapshot")
|
|
||||||
);
|
|
||||||
assert!(snapshot.contains("first request"), "{snapshot}");
|
|
||||||
assert!(snapshot.contains("first answer"), "{snapshot}");
|
|
||||||
assert!(snapshot.contains("persisted task"), "{snapshot}");
|
|
||||||
assert!(snapshot.contains("persisted notification"), "{snapshot}");
|
|
||||||
|
|
||||||
protocol_client
|
|
||||||
.send(&Method::submit_text(
|
|
||||||
protocol::new_submission_request_id(),
|
|
||||||
"continue after restore",
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
wait_for_run_end(&mut protocol_client).await?;
|
|
||||||
let request = second_inspection
|
|
||||||
.requests()
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.expect("restored run request");
|
|
||||||
let projected = format!("{:?}", request.items);
|
|
||||||
assert!(projected.contains("first answer"), "{projected}");
|
|
||||||
assert!(projected.contains("persisted notification"), "{projected}");
|
|
||||||
assert!(projected.contains("persisted task"), "{projected}");
|
|
||||||
host.shutdown().await?;
|
|
||||||
|
|
||||||
store.delete(worker_id)?;
|
|
||||||
assert!(cwd.exists(), "deleting session state must not mutate cwd");
|
|
||||||
assert!(matches!(
|
|
||||||
store.load(worker_id),
|
|
||||||
Err(StandaloneStoreError::WorkerNotFound(_))
|
|
||||||
));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn standalone_restore_rejects_concurrent_lease_and_missing_cwd() -> TestResult {
|
|
||||||
let temp = tempfile::tempdir()?;
|
|
||||||
let cwd = temp.path().join("project");
|
|
||||||
let moved = temp.path().join("moved-project");
|
|
||||||
let state_dir = temp.path().join("state");
|
|
||||||
std::fs::create_dir(&cwd)?;
|
|
||||||
let launch = StandaloneLaunchConfig::new(
|
|
||||||
&cwd,
|
|
||||||
&state_dir,
|
|
||||||
manifest::ProfileSelector::Default,
|
|
||||||
"standalone-lease-test",
|
|
||||||
)
|
|
||||||
.resolve()?;
|
|
||||||
let host =
|
|
||||||
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
|
||||||
let worker_id = host.worker_id();
|
|
||||||
let store = StandaloneWorkerStore::open(&state_dir)?;
|
|
||||||
assert!(matches!(
|
|
||||||
store.acquire_lease(worker_id, StaleLeasePolicy::Recover),
|
|
||||||
Err(StandaloneStoreError::WorkerLeased(id)) if id == worker_id
|
|
||||||
));
|
|
||||||
let restore = StandaloneHost::restore_with_model_client(
|
|
||||||
state_dir.clone(),
|
|
||||||
worker_id,
|
|
||||||
ScriptedClient::new(Vec::new()),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert!(matches!(restore, Err(StandaloneStartupError::WorkerActive)));
|
|
||||||
host.shutdown().await?;
|
|
||||||
|
|
||||||
std::fs::rename(&cwd, &moved)?;
|
|
||||||
let restore = StandaloneHost::restore_with_model_client(
|
|
||||||
state_dir,
|
|
||||||
worker_id,
|
|
||||||
ScriptedClient::new(Vec::new()),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert!(matches!(
|
|
||||||
restore,
|
|
||||||
Err(StandaloneStartupError::WorkingDirectoryUnavailable)
|
|
||||||
));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult {
|
|
||||||
let temp = tempfile::tempdir()?;
|
|
||||||
let state_dir = temp.path().join("state");
|
|
||||||
let mut launch = StandaloneLaunchConfig::new(
|
|
||||||
temp.path(),
|
|
||||||
&state_dir,
|
|
||||||
manifest::ProfileSelector::Default,
|
|
||||||
"standalone-stale-lease-test",
|
|
||||||
)
|
|
||||||
.resolve()?;
|
|
||||||
launch.profile.manifest.profile = Some(manifest::ProfileManifestSnapshot {
|
|
||||||
source: manifest::ProfileSource::Registry {
|
|
||||||
source: manifest::ProfileRegistrySource::User,
|
|
||||||
name: "user-standalone".to_string(),
|
|
||||||
path: None,
|
|
||||||
provenance: Some("user-config-revision-7".to_string()),
|
|
||||||
},
|
|
||||||
profile: Some(manifest::ProfileMetadata {
|
|
||||||
name: Some("User standalone".to_string()),
|
|
||||||
description: None,
|
|
||||||
format: None,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
let host =
|
|
||||||
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
|
||||||
let worker_id = host.worker_id();
|
|
||||||
host.shutdown().await?;
|
|
||||||
let store = StandaloneWorkerStore::open(&state_dir)?;
|
|
||||||
assert!(matches!(
|
|
||||||
store.load(worker_id)?.manifest.profile,
|
|
||||||
Some(manifest::ProfileManifestSnapshot {
|
|
||||||
source: manifest::ProfileSource::Registry {
|
|
||||||
source: manifest::ProfileRegistrySource::User,
|
|
||||||
..
|
|
||||||
},
|
|
||||||
..
|
|
||||||
})
|
|
||||||
));
|
|
||||||
let worker_dir = state_dir.join(worker_id.to_string());
|
|
||||||
std::fs::write(
|
|
||||||
worker_dir.join("lease.json"),
|
|
||||||
serde_json::to_vec(&serde_json::json!({
|
|
||||||
"lease_id": uuid::Uuid::now_v7(),
|
|
||||||
"pid": u32::MAX,
|
|
||||||
"process_start_marker": 1,
|
|
||||||
"acquired_at_unix_ms": 1
|
|
||||||
}))?,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let host = StandaloneHost::restore_with_model_client(
|
|
||||||
state_dir,
|
|
||||||
worker_id,
|
|
||||||
ScriptedClient::new(Vec::new()),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
host.shutdown().await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn standalone_restore_rejects_lease_with_missing_start_marker() -> TestResult {
|
|
||||||
let temp = tempfile::tempdir()?;
|
|
||||||
let state_dir = temp.path().join("state");
|
|
||||||
let launch = StandaloneLaunchConfig::new(
|
|
||||||
temp.path(),
|
|
||||||
&state_dir,
|
|
||||||
manifest::ProfileSelector::Default,
|
|
||||||
"standalone-unknown-lease-test",
|
|
||||||
)
|
|
||||||
.resolve()?;
|
|
||||||
let host =
|
|
||||||
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
|
||||||
let worker_id = host.worker_id();
|
|
||||||
host.shutdown().await?;
|
|
||||||
let worker_dir = state_dir.join(worker_id.to_string());
|
|
||||||
std::fs::write(
|
|
||||||
worker_dir.join("lease.json"),
|
|
||||||
serde_json::to_vec(&serde_json::json!({
|
|
||||||
"lease_id": uuid::Uuid::now_v7(),
|
|
||||||
"pid": std::process::id(),
|
|
||||||
"acquired_at_unix_ms": 1
|
|
||||||
}))?,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let store = StandaloneWorkerStore::open(&state_dir)?;
|
|
||||||
assert!(matches!(
|
|
||||||
store.acquire_lease(worker_id, StaleLeasePolicy::Recover),
|
|
||||||
Err(StandaloneStoreError::LeaseLivenessUnknown(id)) if id == worker_id
|
|
||||||
));
|
|
||||||
let restore = StandaloneHost::restore_with_model_client(
|
|
||||||
state_dir,
|
|
||||||
worker_id,
|
|
||||||
ScriptedClient::new(Vec::new()),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert!(matches!(
|
|
||||||
restore,
|
|
||||||
Err(StandaloneStartupError::LeaseLivenessUnknown)
|
|
||||||
));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn standalone_metadata_fails_closed_on_incomplete_or_newer_records() -> TestResult {
|
|
||||||
let temp = tempfile::tempdir()?;
|
|
||||||
let state_dir = temp.path().join("state");
|
|
||||||
let launch = StandaloneLaunchConfig::new(
|
|
||||||
temp.path(),
|
|
||||||
&state_dir,
|
|
||||||
manifest::ProfileSelector::Default,
|
|
||||||
"standalone-schema-test",
|
|
||||||
)
|
|
||||||
.resolve()?;
|
|
||||||
let host =
|
|
||||||
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
|
||||||
let worker_id = host.worker_id();
|
|
||||||
host.shutdown().await?;
|
|
||||||
let store = StandaloneWorkerStore::open(&state_dir)?;
|
|
||||||
let worker_dir = state_dir.join(worker_id.to_string());
|
|
||||||
std::fs::write(worker_dir.join("commit.pending"), b"interrupted\n")?;
|
|
||||||
assert!(matches!(
|
|
||||||
store.load(worker_id),
|
|
||||||
Err(StandaloneStoreError::IncompleteCommit(id)) if id == worker_id
|
|
||||||
));
|
|
||||||
std::fs::remove_file(worker_dir.join("commit.pending"))?;
|
|
||||||
let record_path = worker_dir.join("record.json");
|
|
||||||
let mut record: serde_json::Value = serde_json::from_slice(&std::fs::read(&record_path)?)?;
|
|
||||||
record["schema_version"] = serde_json::json!(u32::MAX);
|
|
||||||
std::fs::write(&record_path, serde_json::to_vec_pretty(&record)?)?;
|
|
||||||
assert!(matches!(
|
|
||||||
store.load(worker_id),
|
|
||||||
Err(StandaloneStoreError::NewerSchema { id, .. }) if id == worker_id
|
|
||||||
));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn wait_for_run_end(client: &mut Client<InProcessSocket>) -> TestResult {
|
|
||||||
tokio::time::timeout(Duration::from_secs(10), async {
|
|
||||||
loop {
|
|
||||||
if matches!(client.next_event().await, Ok(Some(Event::RunEnd { .. }))) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
-- Canonical standalone Ticket schema. Workspace Server composes stricter cross-domain authority.
|
|
||||||
CREATE TABLE typed_ticket_artifacts (
|
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, relative_path),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE typed_ticket_event_attributes (
|
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, event_index, key),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE typed_ticket_event_references (
|
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE typed_ticket_events (
|
|
||||||
workspace_id TEXT NOT NULL,
|
|
||||||
ticket_id TEXT NOT NULL,
|
|
||||||
event_index INTEGER NOT NULL,
|
|
||||||
kind TEXT NOT NULL,
|
|
||||||
author TEXT,
|
|
||||||
at TEXT,
|
|
||||||
status TEXT,
|
|
||||||
from_state TEXT,
|
|
||||||
to_state TEXT,
|
|
||||||
reason TEXT,
|
|
||||||
state_field TEXT,
|
|
||||||
heading TEXT,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, event_index),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE typed_ticket_labels (
|
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, ordinal),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE typed_ticket_orchestration_plans (
|
|
||||||
workspace_id TEXT NOT NULL,
|
|
||||||
ticket_id TEXT NOT NULL,
|
|
||||||
record_id TEXT NOT NULL,
|
|
||||||
kind TEXT NOT NULL,
|
|
||||||
related_ticket TEXT,
|
|
||||||
note TEXT,
|
|
||||||
accepted_summary TEXT,
|
|
||||||
accepted_branch TEXT,
|
|
||||||
accepted_worktree TEXT,
|
|
||||||
accepted_role_plan TEXT,
|
|
||||||
author TEXT NOT NULL,
|
|
||||||
at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, record_id),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE typed_ticket_raw_frontmatter (
|
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, key),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE typed_ticket_relations (
|
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, note TEXT, author TEXT NOT NULL, at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, kind, target),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE typed_ticket_risk_flags (
|
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id, ordinal),
|
|
||||||
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
CREATE TABLE typed_tickets (
|
|
||||||
workspace_id TEXT NOT NULL,
|
|
||||||
ticket_id TEXT NOT NULL,
|
|
||||||
slug TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL,
|
|
||||||
kind TEXT NOT NULL,
|
|
||||||
priority TEXT NOT NULL,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
created_at TEXT,
|
|
||||||
updated_at TEXT,
|
|
||||||
assignee TEXT,
|
|
||||||
readiness TEXT,
|
|
||||||
workflow_state TEXT NOT NULL,
|
|
||||||
workflow_state_explicit INTEGER NOT NULL,
|
|
||||||
queued_by TEXT,
|
|
||||||
queued_at TEXT,
|
|
||||||
resolution TEXT, repository_id TEXT, ref_selector TEXT,
|
|
||||||
PRIMARY KEY (workspace_id, ticket_id)
|
|
||||||
);
|
|
||||||
CREATE TABLE "workspace_resource_key_counters" (
|
|
||||||
workspace_id TEXT NOT NULL,
|
|
||||||
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
|
|
||||||
next_sequence INTEGER NOT NULL CHECK (next_sequence > 0),
|
|
||||||
PRIMARY KEY (workspace_id, resource_kind)
|
|
||||||
);
|
|
||||||
CREATE TABLE "workspace_resource_keys" (
|
|
||||||
workspace_id TEXT NOT NULL,
|
|
||||||
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
|
|
||||||
resource_id TEXT NOT NULL,
|
|
||||||
sequence INTEGER NOT NULL CHECK (sequence > 0),
|
|
||||||
resource_key TEXT NOT NULL,
|
|
||||||
allocated_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (workspace_id, resource_kind, resource_id),
|
|
||||||
UNIQUE (workspace_id, resource_kind, sequence),
|
|
||||||
UNIQUE (workspace_id, resource_key)
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_workspace_resource_keys_reverse
|
|
||||||
ON workspace_resource_keys(workspace_id, resource_kind, resource_key);
|
|
||||||
CREATE INDEX typed_ticket_events_workspace_kind_ticket
|
|
||||||
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
|
|
||||||
CREATE INDEX typed_ticket_relations_workspace_source_kind
|
|
||||||
ON typed_ticket_relations(workspace_id, ticket_id, kind, target);
|
|
||||||
CREATE INDEX typed_ticket_relations_workspace_target_kind
|
|
||||||
ON typed_ticket_relations(workspace_id, target, kind, ticket_id);
|
|
||||||
CREATE INDEX typed_tickets_workspace_created
|
|
||||||
ON typed_tickets(workspace_id, created_at DESC, ticket_id);
|
|
||||||
CREATE INDEX typed_tickets_workspace_state_updated
|
|
||||||
ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id);
|
|
||||||
CREATE INDEX typed_tickets_workspace_title
|
|
||||||
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
|
|
||||||
CREATE INDEX typed_tickets_workspace_updated
|
|
||||||
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user