Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
532c833875 | ||
|
|
23289f6656 | ||
|
|
c71d5f05f0 | ||
|
|
d8aac9748d | ||
|
|
ce084714dc | ||
|
|
ca6d0746b1 | ||
|
|
9147ac303b |
@@ -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 / `<system-reminder>` 系はこの原則で扱う。
|
||||||
|
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Generated
+695
-676
File diff suppressed because it is too large
Load Diff
+6
-21
@@ -2,10 +2,9 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/client",
|
"crates/client",
|
||||||
"crates/agen",
|
"crates/llm-engine",
|
||||||
"crates/agen-macros",
|
"crates/llm-engine-macros",
|
||||||
"crates/session-store",
|
"crates/session-store",
|
||||||
"crates/standalone",
|
|
||||||
"crates/secrets",
|
"crates/secrets",
|
||||||
"crates/manifest",
|
"crates/manifest",
|
||||||
"crates/mcp",
|
"crates/mcp",
|
||||||
@@ -28,16 +27,14 @@ members = [
|
|||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
"crates/merge-request",
|
"crates/merge-request",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
"crates/workspace-api",
|
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
"tests/e2e",
|
"tests/e2e",
|
||||||
]
|
]
|
||||||
default-members = [
|
default-members = [
|
||||||
"crates/client",
|
"crates/client",
|
||||||
"crates/agen",
|
"crates/llm-engine",
|
||||||
"crates/agen-macros",
|
"crates/llm-engine-macros",
|
||||||
"crates/session-store",
|
"crates/session-store",
|
||||||
"crates/standalone",
|
|
||||||
"crates/secrets",
|
"crates/secrets",
|
||||||
"crates/manifest",
|
"crates/manifest",
|
||||||
"crates/mcp",
|
"crates/mcp",
|
||||||
@@ -60,7 +57,6 @@ default-members = [
|
|||||||
"crates/ticket",
|
"crates/ticket",
|
||||||
"crates/merge-request",
|
"crates/merge-request",
|
||||||
"crates/project-record",
|
"crates/project-record",
|
||||||
"crates/workspace-api",
|
|
||||||
"crates/workspace-server",
|
"crates/workspace-server",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -68,17 +64,11 @@ 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" }
|
||||||
agen = { path = "crates/agen", version = "0.2" }
|
llm-engine = { path = "crates/llm-engine", version = "0.2" }
|
||||||
agen-macros = { path = "crates/agen-macros", version = "0.2" }
|
llm-engine-macros = { path = "crates/llm-engine-macros", version = "0.2" }
|
||||||
manifest = { path = "crates/manifest" }
|
manifest = { path = "crates/manifest" }
|
||||||
mcp = { path = "crates/mcp" }
|
mcp = { path = "crates/mcp" }
|
||||||
lint-common = { path = "crates/lint-common" }
|
lint-common = { path = "crates/lint-common" }
|
||||||
@@ -88,14 +78,12 @@ ticket = { path = "crates/ticket" }
|
|||||||
project-record = { path = "crates/project-record" }
|
project-record = { path = "crates/project-record" }
|
||||||
worker = { path = "crates/worker" }
|
worker = { path = "crates/worker" }
|
||||||
worker-runtime = { path = "crates/worker-runtime" }
|
worker-runtime = { path = "crates/worker-runtime" }
|
||||||
workspace-api = { path = "crates/workspace-api" }
|
|
||||||
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
||||||
yoi = { path = "crates/yoi" }
|
yoi = { path = "crates/yoi" }
|
||||||
protocol = { path = "crates/protocol" }
|
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" }
|
||||||
@@ -124,7 +112,6 @@ tar = "0.4"
|
|||||||
rusqlite = { version = "0.37", features = ["backup", "bundled"] }
|
rusqlite = { version = "0.37", features = ["backup", "bundled"] }
|
||||||
ring = "0.17.14"
|
ring = "0.17.14"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] }
|
|
||||||
tempfile = "3.27"
|
tempfile = "3.27"
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
tokio = "1.52"
|
tokio = "1.52"
|
||||||
@@ -132,8 +119,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
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "agen-macros"
|
|
||||||
description = "Procedural macros for declaring agen tools"
|
|
||||||
version = "0.2.0"
|
|
||||||
edition.workspace = true
|
|
||||||
rust-version = "1.85"
|
|
||||||
license.workspace = true
|
|
||||||
readme = "README.md"
|
|
||||||
repository = "https://gitea.hareworks.net/Hare/yoi"
|
|
||||||
homepage = "https://gitea.hareworks.net/Hare/yoi"
|
|
||||||
documentation = "https://docs.rs/agen-macros"
|
|
||||||
keywords = ["llm", "agent", "tools", "macros"]
|
|
||||||
categories = ["development-tools::procedural-macro-helpers"]
|
|
||||||
include = ["src/**", "README.md", "LICENSE"]
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
proc-macro = true
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
proc-macro2 = "1"
|
|
||||||
quote = "1"
|
|
||||||
syn = { version = "2", features = ["full"] }
|
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
|
||||||
all-features = true
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
Copyright 2026 Hare
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# agen-macros
|
|
||||||
|
|
||||||
Procedural macros used by [`agen`](https://crates.io/crates/agen) to declare LLM tools from Rust methods.
|
|
||||||
|
|
||||||
Applications should normally depend only on `agen` and import its re-exports:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use agen::tool_registry;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct Tools;
|
|
||||||
|
|
||||||
#[tool_registry]
|
|
||||||
impl Tools {
|
|
||||||
/// Returns the supplied text.
|
|
||||||
#[tool]
|
|
||||||
async fn echo(
|
|
||||||
&self,
|
|
||||||
#[description = "Text to return"] text: String,
|
|
||||||
) -> Result<String, std::io::Error> {
|
|
||||||
Ok(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`#[tool_registry]` generates the argument schema, a `Tool` implementation, and an `<method>_definition` constructor. It rejects arguments of its own, duplicate `#[tool]` markers, malformed or duplicate `#[description = "..."]` attributes, and non-identifier argument patterns.
|
|
||||||
|
|
||||||
Generated code targets the canonical `::agen` path and uses implementation dependencies re-exported by `agen`; consumers do not need direct `serde`, `schemars`, `serde_json`, or `async-trait` dependencies. Renaming the `agen` dependency in `Cargo.toml` is not currently supported.
|
|
||||||
|
|
||||||
This companion package is published before the matching `agen` release. Its public contract is the generated API consumed by `agen`, and its minor version compatibility follows the `agen` 0.2 series.
|
|
||||||
|
|
||||||
Licensed under the [MIT License](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/LICENSE).
|
|
||||||
@@ -1,482 +0,0 @@
|
|||||||
//! Procedural macros for declaring [`agen`](https://docs.rs/agen) tools.
|
|
||||||
//!
|
|
||||||
//! [`tool_registry`] expands methods marked with `#[tool]` into `agen::tool::Tool`
|
|
||||||
//! implementations and tool definitions. Applications normally use the re-exports from
|
|
||||||
//! `agen`; this companion crate exists so those macros can be published and versioned
|
|
||||||
//! independently.
|
|
||||||
|
|
||||||
use proc_macro::TokenStream;
|
|
||||||
use quote::{format_ident, quote};
|
|
||||||
use syn::{
|
|
||||||
Attribute, FnArg, ImplItem, ItemImpl, Lit, Meta, Pat, ReturnType, Type, parse_macro_input,
|
|
||||||
spanned::Spanned,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Generates tools for methods marked with `#[tool]` in an `impl` block.
|
|
||||||
///
|
|
||||||
/// Method doc comments become the tool description. An argument can use
|
|
||||||
/// `#[description = "..."]` to supply its JSON Schema description.
|
|
||||||
///
|
|
||||||
/// ```ignore
|
|
||||||
/// #[derive(Clone)]
|
|
||||||
/// struct MyApp;
|
|
||||||
///
|
|
||||||
/// #[agen::tool_registry]
|
|
||||||
/// impl MyApp {
|
|
||||||
/// /// Retrieves a user by ID.
|
|
||||||
/// #[tool]
|
|
||||||
/// async fn get_user(
|
|
||||||
/// &self,
|
|
||||||
/// #[description = "The user ID"] user_id: String,
|
|
||||||
/// ) -> Result<String, std::io::Error> {
|
|
||||||
/// todo!()
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// This generates a `ToolGetUser` wrapper, a `GetUserArgs` schema type, and
|
|
||||||
/// `MyApp::get_user_definition()`.
|
|
||||||
#[proc_macro_attribute]
|
|
||||||
pub fn tool_registry(attr: TokenStream, item: TokenStream) -> TokenStream {
|
|
||||||
let attr = proc_macro2::TokenStream::from(attr);
|
|
||||||
let impl_block = parse_macro_input!(item as ItemImpl);
|
|
||||||
|
|
||||||
expand_tool_registry(attr, impl_block)
|
|
||||||
.unwrap_or_else(syn::Error::into_compile_error)
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn expand_tool_registry(
|
|
||||||
attr: proc_macro2::TokenStream,
|
|
||||||
mut impl_block: ItemImpl,
|
|
||||||
) -> syn::Result<proc_macro2::TokenStream> {
|
|
||||||
if !attr.is_empty() {
|
|
||||||
return Err(syn::Error::new(
|
|
||||||
attr.span(),
|
|
||||||
"tool_registry does not accept arguments",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let self_ty = impl_block.self_ty.as_ref().clone();
|
|
||||||
let mut generated_items = Vec::new();
|
|
||||||
|
|
||||||
for item in &mut impl_block.items {
|
|
||||||
let ImplItem::Fn(method) = item else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let tool_attrs: Vec<_> = method
|
|
||||||
.attrs
|
|
||||||
.iter()
|
|
||||||
.filter(|attr| attr.path().is_ident("tool"))
|
|
||||||
.collect();
|
|
||||||
if tool_attrs.len() > 1 {
|
|
||||||
return Err(syn::Error::new_spanned(
|
|
||||||
tool_attrs[1],
|
|
||||||
"duplicate #[tool] attribute",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let Some(tool_attr) = tool_attrs.first() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if !matches!(tool_attr.meta, Meta::Path(_)) {
|
|
||||||
return Err(syn::Error::new_spanned(
|
|
||||||
tool_attr,
|
|
||||||
"#[tool] does not accept arguments",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
method.attrs.retain(|attr| !attr.path().is_ident("tool"));
|
|
||||||
generated_items.push(generate_tool_impl(&self_ty, method)?);
|
|
||||||
|
|
||||||
for input in &mut method.sig.inputs {
|
|
||||||
if let FnArg::Typed(pat_type) = input {
|
|
||||||
pat_type
|
|
||||||
.attrs
|
|
||||||
.retain(|attr| !attr.path().is_ident("description"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(quote! {
|
|
||||||
#impl_block
|
|
||||||
|
|
||||||
#(#generated_items)*
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_doc_comment(attrs: &[Attribute]) -> String {
|
|
||||||
let mut lines = Vec::new();
|
|
||||||
|
|
||||||
for attr in attrs {
|
|
||||||
if attr.path().is_ident("doc")
|
|
||||||
&& let Meta::NameValue(meta) = &attr.meta
|
|
||||||
&& let syn::Expr::Lit(expr_lit) = &meta.value
|
|
||||||
&& let Lit::Str(lit_str) = &expr_lit.lit
|
|
||||||
{
|
|
||||||
let line = lit_str.value();
|
|
||||||
let trimmed = line.strip_prefix(' ').unwrap_or(&line);
|
|
||||||
lines.push(trimmed.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_description_attr(attrs: &[Attribute]) -> syn::Result<Option<String>> {
|
|
||||||
let mut description = None;
|
|
||||||
|
|
||||||
for attr in attrs
|
|
||||||
.iter()
|
|
||||||
.filter(|attr| attr.path().is_ident("description"))
|
|
||||||
{
|
|
||||||
let value = match &attr.meta {
|
|
||||||
Meta::NameValue(meta) => match &meta.value {
|
|
||||||
syn::Expr::Lit(expr_lit) => match &expr_lit.lit {
|
|
||||||
Lit::Str(value) => value.value(),
|
|
||||||
_ => {
|
|
||||||
return Err(syn::Error::new_spanned(
|
|
||||||
attr,
|
|
||||||
"description must be a string literal",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => {
|
|
||||||
return Err(syn::Error::new_spanned(
|
|
||||||
attr,
|
|
||||||
"description must be a string literal",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
_ => {
|
|
||||||
return Err(syn::Error::new_spanned(
|
|
||||||
attr,
|
|
||||||
"expected #[description = \"...\"]",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if description.replace(value).is_some() {
|
|
||||||
return Err(syn::Error::new_spanned(
|
|
||||||
attr,
|
|
||||||
"duplicate #[description] attribute",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(description)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn argument_ident(pat: &Pat) -> syn::Result<&syn::Ident> {
|
|
||||||
match pat {
|
|
||||||
Pat::Ident(pat_ident) => Ok(&pat_ident.ident),
|
|
||||||
_ => Err(syn::Error::new_spanned(
|
|
||||||
pat,
|
|
||||||
"tool arguments must use simple identifier patterns",
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_tool_execution_context_type(ty: &Type) -> bool {
|
|
||||||
let Type::Path(path) = ty else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
path.path
|
|
||||||
.segments
|
|
||||||
.last()
|
|
||||||
.is_some_and(|segment| segment.ident == "ToolExecutionContext")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_tool_impl(
|
|
||||||
self_ty: &Type,
|
|
||||||
method: &syn::ImplItemFn,
|
|
||||||
) -> syn::Result<proc_macro2::TokenStream> {
|
|
||||||
let sig = &method.sig;
|
|
||||||
let method_name = &sig.ident;
|
|
||||||
let tool_name = method_name.to_string();
|
|
||||||
|
|
||||||
let pascal_name = to_pascal_case(&method_name.to_string());
|
|
||||||
let tool_struct_name = format_ident!("Tool{}", pascal_name);
|
|
||||||
let args_struct_name = format_ident!("{}Args", pascal_name);
|
|
||||||
let definition_name = format_ident!("{}_definition", method_name);
|
|
||||||
|
|
||||||
let description = extract_doc_comment(&method.attrs);
|
|
||||||
let description = if description.is_empty() {
|
|
||||||
format!("Tool: {}", tool_name)
|
|
||||||
} else {
|
|
||||||
description
|
|
||||||
};
|
|
||||||
|
|
||||||
let method_args: Vec<_> = sig
|
|
||||||
.inputs
|
|
||||||
.iter()
|
|
||||||
.filter_map(|arg| match arg {
|
|
||||||
FnArg::Typed(pat_type) => Some(pat_type),
|
|
||||||
FnArg::Receiver(_) => None,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let json_args: Vec<_> = method_args
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|pat_type| !is_tool_execution_context_type(pat_type.ty.as_ref()))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let arg_fields: Vec<_> = json_args
|
|
||||||
.iter()
|
|
||||||
.map(|pat_type| {
|
|
||||||
let field_name = argument_ident(pat_type.pat.as_ref())?;
|
|
||||||
let ty = &pat_type.ty;
|
|
||||||
let description = extract_description_attr(&pat_type.attrs)?;
|
|
||||||
|
|
||||||
Ok(if let Some(description) = description {
|
|
||||||
quote! {
|
|
||||||
#[schemars(description = #description)]
|
|
||||||
pub #field_name: #ty
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
quote! {
|
|
||||||
pub #field_name: #ty
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<syn::Result<_>>()?;
|
|
||||||
|
|
||||||
let call_args: Vec<_> = method_args
|
|
||||||
.iter()
|
|
||||||
.map(|pat_type| {
|
|
||||||
if is_tool_execution_context_type(pat_type.ty.as_ref()) {
|
|
||||||
Ok(quote! { ctx.clone() })
|
|
||||||
} else {
|
|
||||||
let ident = argument_ident(pat_type.pat.as_ref())?;
|
|
||||||
Ok(quote! { args.#ident })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<syn::Result<_>>()?;
|
|
||||||
let method_call = if call_args.is_empty() {
|
|
||||||
quote! { self.ctx.#method_name() }
|
|
||||||
} else {
|
|
||||||
quote! { self.ctx.#method_name(#(#call_args),*) }
|
|
||||||
};
|
|
||||||
|
|
||||||
let awaiter = if sig.asyncness.is_some() {
|
|
||||||
quote! { .await }
|
|
||||||
} else {
|
|
||||||
quote! {}
|
|
||||||
};
|
|
||||||
|
|
||||||
let result_handling = if is_result_type(&sig.output) {
|
|
||||||
quote! {
|
|
||||||
match result {
|
|
||||||
Ok(val) => Ok(format!("{:?}", val).into()),
|
|
||||||
Err(error) => Err(::agen::tool::ToolError::ExecutionFailed(format!("{}", error))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
quote! {
|
|
||||||
Ok(format!("{:?}", result).into())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let args_struct_def = quote! {
|
|
||||||
#[derive(
|
|
||||||
::agen::__private::serde::Deserialize,
|
|
||||||
::agen::__private::schemars::JsonSchema,
|
|
||||||
)]
|
|
||||||
#[serde(crate = "::agen::__private::serde")]
|
|
||||||
#[schemars(crate = "::agen::__private::schemars")]
|
|
||||||
struct #args_struct_name {
|
|
||||||
#(#arg_fields),*
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let execute_body = if json_args.is_empty() {
|
|
||||||
quote! {
|
|
||||||
let _: #args_struct_name = ::agen::__private::serde_json::from_str(input_json)
|
|
||||||
.unwrap_or(#args_struct_name {});
|
|
||||||
|
|
||||||
let result = #method_call #awaiter;
|
|
||||||
#result_handling
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
quote! {
|
|
||||||
let args: #args_struct_name = ::agen::__private::serde_json::from_str(input_json)
|
|
||||||
.map_err(|error| ::agen::tool::ToolError::InvalidArgument(error.to_string()))?;
|
|
||||||
|
|
||||||
let result = #method_call #awaiter;
|
|
||||||
#result_handling
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(quote! {
|
|
||||||
#args_struct_def
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct #tool_struct_name {
|
|
||||||
ctx: #self_ty,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[::agen::__private::async_trait::async_trait]
|
|
||||||
impl ::agen::tool::Tool for #tool_struct_name {
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
input_json: &str,
|
|
||||||
ctx: ::agen::tool::ToolExecutionContext,
|
|
||||||
) -> Result<::agen::tool::ToolOutput, ::agen::tool::ToolError> {
|
|
||||||
let _ = &ctx;
|
|
||||||
#execute_body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl #self_ty {
|
|
||||||
/// Returns a tool definition for registration with an `agen::Engine`.
|
|
||||||
pub fn #definition_name(&self) -> ::agen::tool::ToolDefinition {
|
|
||||||
let ctx = self.clone();
|
|
||||||
::std::sync::Arc::new(move || {
|
|
||||||
let schema = ::agen::__private::schemars::schema_for!(#args_struct_name);
|
|
||||||
let meta = ::agen::tool::ToolMeta::new(#tool_name)
|
|
||||||
.description(#description)
|
|
||||||
.input_schema(
|
|
||||||
::agen::__private::serde_json::to_value(schema)
|
|
||||||
.unwrap_or_else(|_| ::agen::__private::serde_json::json!({})),
|
|
||||||
);
|
|
||||||
let tool: ::std::sync::Arc<dyn ::agen::tool::Tool> =
|
|
||||||
::std::sync::Arc::new(#tool_struct_name { ctx: ctx.clone() });
|
|
||||||
(meta, tool)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_result_type(return_type: &ReturnType) -> bool {
|
|
||||||
match return_type {
|
|
||||||
ReturnType::Default => false,
|
|
||||||
ReturnType::Type(_, ty) => {
|
|
||||||
if let Type::Path(type_path) = ty.as_ref()
|
|
||||||
&& let Some(segment) = type_path.path.segments.last()
|
|
||||||
{
|
|
||||||
return segment.ident == "Result";
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn to_pascal_case(s: &str) -> String {
|
|
||||||
s.split('_')
|
|
||||||
.map(|part| {
|
|
||||||
let mut chars = part.chars();
|
|
||||||
match chars.next() {
|
|
||||||
None => String::new(),
|
|
||||||
Some(first) => first.to_uppercase().chain(chars).collect(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marker attribute interpreted by [`tool_registry`].
|
|
||||||
#[proc_macro_attribute]
|
|
||||||
pub fn tool(attr: TokenStream, item: TokenStream) -> TokenStream {
|
|
||||||
marker_attribute("tool", attr, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Argument description marker interpreted by [`tool_registry`].
|
|
||||||
///
|
|
||||||
/// Use it as `#[description = "The argument description"]` on a tool method argument.
|
|
||||||
#[proc_macro_attribute]
|
|
||||||
pub fn description(attr: TokenStream, item: TokenStream) -> TokenStream {
|
|
||||||
marker_attribute("description", attr, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn marker_attribute(name: &str, attr: TokenStream, item: TokenStream) -> TokenStream {
|
|
||||||
if attr.is_empty() {
|
|
||||||
item
|
|
||||||
} else {
|
|
||||||
syn::Error::new(
|
|
||||||
proc_macro2::Span::call_site(),
|
|
||||||
format!("{name} is a marker interpreted by #[tool_registry]"),
|
|
||||||
)
|
|
||||||
.into_compile_error()
|
|
||||||
.into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use quote::quote;
|
|
||||||
use syn::parse_quote;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_tool_registry_arguments() {
|
|
||||||
let implementation: ItemImpl = parse_quote!(impl Registry {});
|
|
||||||
let error = expand_tool_registry(quote!(unexpected), implementation).unwrap_err();
|
|
||||||
|
|
||||||
assert!(error.to_string().contains("does not accept arguments"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_duplicate_tool_markers() {
|
|
||||||
let implementation: ItemImpl = parse_quote! {
|
|
||||||
impl Registry {
|
|
||||||
#[tool]
|
|
||||||
#[tool]
|
|
||||||
fn inspect(&self) {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let error = expand_tool_registry(quote!(), implementation).unwrap_err();
|
|
||||||
|
|
||||||
assert!(error.to_string().contains("duplicate #[tool]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_invalid_description_attributes() {
|
|
||||||
let implementation: ItemImpl = parse_quote! {
|
|
||||||
impl Registry {
|
|
||||||
#[tool]
|
|
||||||
fn inspect(&self, #[description] input: String) {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let error = expand_tool_registry(quote!(), implementation).unwrap_err();
|
|
||||||
|
|
||||||
assert!(error.to_string().contains("expected #[description"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_duplicate_description_attributes() {
|
|
||||||
let implementation: ItemImpl = parse_quote! {
|
|
||||||
impl Registry {
|
|
||||||
#[tool]
|
|
||||||
fn inspect(
|
|
||||||
&self,
|
|
||||||
#[description = "first"]
|
|
||||||
#[description = "second"]
|
|
||||||
input: String,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let error = expand_tool_registry(quote!(), implementation).unwrap_err();
|
|
||||||
|
|
||||||
assert!(error.to_string().contains("duplicate #[description]"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generated_code_uses_only_agen_runtime_paths() {
|
|
||||||
let implementation: ItemImpl = parse_quote! {
|
|
||||||
impl Registry {
|
|
||||||
#[tool]
|
|
||||||
fn inspect(&self, input: String) -> Result<String, Error> {
|
|
||||||
unreachable!()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let expanded = expand_tool_registry(quote!(), implementation)
|
|
||||||
.unwrap()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
assert!(expanded.contains(":: agen :: tool :: Tool"));
|
|
||||||
assert!(expanded.contains(":: agen :: __private :: serde_json"));
|
|
||||||
assert!(expanded.contains(":: agen :: __private :: serde"));
|
|
||||||
assert!(expanded.contains(":: agen :: __private :: schemars"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
Copyright 2026 Hare
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# agen
|
|
||||||
|
|
||||||
`agen` is a provider-neutral Rust engine for streaming LLM applications that use tools. It owns the turn loop, typed conversation history, provider wire-format adapters, tool execution, interceptors, usage accounting, and cache-aware state transitions.
|
|
||||||
|
|
||||||
> `agen` is pre-1.0. Public APIs may change between minor releases.
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[dependencies]
|
|
||||||
agen = "0.2.1"
|
|
||||||
```
|
|
||||||
|
|
||||||
The default feature set is intentionally empty. Enable the experimental Codex/ChatGPT authentication adapter when needed:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
agen = { version = "0.2.1", features = ["codex"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
`agen` requires Rust 1.86 or newer. The companion `agen-macros` package requires Rust 1.85 or newer.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
```no_run
|
|
||||||
use agen::{Engine, EngineError, History};
|
|
||||||
use agen::llm_client::LlmClient;
|
|
||||||
|
|
||||||
async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> {
|
|
||||||
let mut history = History::new();
|
|
||||||
let output = Engine::new(client)
|
|
||||||
.system_prompt("You are a concise assistant.")
|
|
||||||
.run(&mut history, "Explain typed state in one sentence.")
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let mut engine = output.engine;
|
|
||||||
let _result = engine.run(&mut history, "Give a Rust example.").await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Declaring tools
|
|
||||||
|
|
||||||
The tool macros are re-exported by `agen`; applications do not need direct dependencies on `serde`, `schemars`, `serde_json`, or `async-trait` for generated code.
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use agen::tool_registry;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct Tools;
|
|
||||||
|
|
||||||
#[tool_registry]
|
|
||||||
impl Tools {
|
|
||||||
/// Returns the supplied text.
|
|
||||||
#[tool]
|
|
||||||
async fn echo(
|
|
||||||
&self,
|
|
||||||
#[description = "Text to return"] text: String,
|
|
||||||
) -> Result<String, std::io::Error> {
|
|
||||||
Ok(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let definition = Tools.echo_definition();
|
|
||||||
assert_eq!(definition().0.name, "echo");
|
|
||||||
```
|
|
||||||
|
|
||||||
The generated API uses the canonical crate name `agen`. Renaming the `agen` dependency in `Cargo.toml` is not currently supported by these macros.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
| Feature | Default | Adds |
|
|
||||||
|---|---:|---|
|
|
||||||
| `codex` | No | Experimental Codex/ChatGPT auth-file loading and token refresh support |
|
|
||||||
|
|
||||||
The base crate includes provider-neutral transport and Anthropic, OpenAI-compatible, Gemini, and Ollama wire-format schemes. See [`llm_client`](https://docs.rs/agen/latest/agen/llm_client/) for the client boundary.
|
|
||||||
|
|
||||||
## Architecture and API scope
|
|
||||||
|
|
||||||
The current public modules cover the engine, typed history, client transport/schemes, timeline events, tools, interceptors, pruning, token estimation, and usage records. Their relationships are described in [Architecture](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/crates/agen/docs/architecture.md); behavioral requirements are summarized in [Requirements](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/crates/agen/docs/requirements.md).
|
|
||||||
|
|
||||||
Low-level modules remain public in the 0.2 series because downstream Yoi components implement custom clients, event handlers, pruning policies, and tool registries against them. This surface is versioned as pre-1.0 API rather than declared stable.
|
|
||||||
|
|
||||||
## Packaging and security
|
|
||||||
|
|
||||||
The published package contains source, public documentation, curated examples, and deterministic tests/fixtures. Credentialed fixture-recording utilities are intentionally excluded. Examples that contact a provider read credentials from environment variables and never embed production credentials.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Licensed under the [MIT License](https://gitea.hareworks.net/Hare/yoi/src/branch/develop/LICENSE).
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# agen architecture
|
|
||||||
|
|
||||||
`agen` separates orchestration, event projection, and provider transport so applications can replace an LLM client without changing the turn loop or tool model.
|
|
||||||
|
|
||||||
```text
|
|
||||||
┌────────────────────────────────────────────┐
|
|
||||||
│ Engine │
|
|
||||||
│ turn loop · interceptors · tool execution │
|
|
||||||
│ typed state: Mutable → Locked → Mutable │
|
|
||||||
└─────────────────────┬──────────────────────┘
|
|
||||||
│
|
|
||||||
┌─────────────────────▼──────────────────────┐
|
|
||||||
│ Timeline │
|
|
||||||
│ event dispatch · block collectors │
|
|
||||||
└─────────────────────┬──────────────────────┘
|
|
||||||
│
|
|
||||||
┌─────────────────────▼──────────────────────┐
|
|
||||||
│ LlmClient │
|
|
||||||
│ transport · provider wire-format schemes │
|
|
||||||
└────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Main modules
|
|
||||||
|
|
||||||
| Module | Responsibility |
|
|
||||||
|---|---|
|
|
||||||
| `engine` | Turn execution, pause/resume, retries, tool integration, and callbacks |
|
|
||||||
| `state` | Sealed `Mutable` and `Locked` type-state markers |
|
|
||||||
| `interceptor` | Application-owned control decisions at orchestration boundaries |
|
|
||||||
| `tool` / `tool_server` | Tool metadata, registration, execution, and bounded output |
|
|
||||||
| `timeline` | Streaming event dispatch, handlers, and block assembly |
|
|
||||||
| `llm_client` | Provider-neutral request, response, auth, transport, and scheme contracts |
|
|
||||||
| `providers` | Optional higher-level provider adapters such as the `codex` feature |
|
|
||||||
| `prune` / `token_counter` | Cache-aware history reduction and token estimation |
|
|
||||||
| `usage_record` | Request and token usage accounting |
|
|
||||||
|
|
||||||
## Request flow
|
|
||||||
|
|
||||||
```text
|
|
||||||
Engine history
|
|
||||||
→ provider-neutral Request
|
|
||||||
→ Scheme::build_request
|
|
||||||
→ Provider transport
|
|
||||||
```
|
|
||||||
|
|
||||||
## Response flow
|
|
||||||
|
|
||||||
```text
|
|
||||||
streaming response bytes
|
|
||||||
→ Scheme event parsing
|
|
||||||
→ unified Event values
|
|
||||||
→ Timeline handlers and collectors
|
|
||||||
→ Engine history/tool decisions
|
|
||||||
```
|
|
||||||
|
|
||||||
## Type state and cache protection
|
|
||||||
|
|
||||||
`Engine<C, Mutable>` permits configuration and history editing. `Engine::run` or `Engine::lock` commits the current prefix and produces `Engine<C, Locked>`. The locked engine may append turns without mutating the committed prefix. `Engine::unlock` explicitly returns to mutable state when an application accepts losing that cache guarantee.
|
|
||||||
|
|
||||||
## Public surface
|
|
||||||
|
|
||||||
The 0.2 series exposes the low-level client, timeline, tool, pruning, and usage modules because custom clients and orchestration hosts build directly on them. These APIs are intentionally provider-neutral but remain pre-1.0 and may change in later minor releases.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# agen requirements
|
|
||||||
|
|
||||||
## R1: Turn execution and continuation
|
|
||||||
|
|
||||||
- `Engine::run` starts a turn and loops through provider output and tool calls.
|
|
||||||
- An `Interceptor` may continue, cancel, or pause work at defined orchestration boundaries.
|
|
||||||
- `Engine::resume` continues paused generation without fabricating another user message.
|
|
||||||
- Cancellation and provider errors are represented as typed `EngineError` values.
|
|
||||||
|
|
||||||
## R2: Explicit cache-preserving state
|
|
||||||
|
|
||||||
- `Engine<C, Mutable>` permits configuration and history edits.
|
|
||||||
- `Engine::run` or `Engine::lock` transitions to `Engine<C, Locked>` and records the committed prefix.
|
|
||||||
- A locked engine appends turns but cannot mutate that prefix through mutable-only APIs.
|
|
||||||
- `Engine::unlock` explicitly abandons the lock before configuration or history changes.
|
|
||||||
|
|
||||||
## R3: Tool declarations and execution
|
|
||||||
|
|
||||||
- `#[tool_registry]` generates a schema and `Tool` implementation for methods marked `#[tool]`.
|
|
||||||
- `#[description = "..."]` supplies argument descriptions in generated JSON Schema.
|
|
||||||
- Generated code resolves its runtime and helper dependencies through `::agen`.
|
|
||||||
- Invalid and duplicate marker attributes produce compile errors rather than panics.
|
|
||||||
- Tools execute through `ToolServer` with typed context, errors, and output limits.
|
|
||||||
|
|
||||||
## R4: Provider-neutral orchestration
|
|
||||||
|
|
||||||
- `LlmClient` is the boundary between the engine and provider-specific transport.
|
|
||||||
- Request/response schemes translate provider wire formats into shared request and event types.
|
|
||||||
- Interceptors, tool execution, timeline collection, and pruning stay above the provider transport.
|
|
||||||
- Provider-specific capabilities are optional features when they require additional policy or dependencies.
|
|
||||||
|
|
||||||
## R5: Publication quality
|
|
||||||
|
|
||||||
- crates.io metadata includes license, repository, documentation, README, categories, keywords, and MSRV.
|
|
||||||
- The default feature set and each optional feature compile and test independently.
|
|
||||||
- Macro expansion compiles in a downstream-style integration test without direct helper dependencies.
|
|
||||||
- rustdoc builds without dependency documentation.
|
|
||||||
- Package contents are explicitly bounded and exclude credentialed fixture-recording utilities.
|
|
||||||
- `cargo package` and `cargo publish --dry-run` are run for `agen-macros` before `agen` because the main package depends on its companion package.
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
//! Interceptor - control flow delegation for the Engine execution loop
|
|
||||||
//!
|
|
||||||
//! Defines the [`Interceptor`] trait that callers implement to inject
|
|
||||||
//! orchestration decisions (approval, skip, pause, abort) into the Engine's
|
|
||||||
//! turn loop without the Engine knowing about host-application concepts.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
|
|
||||||
use crate::Item;
|
|
||||||
use crate::engine::EngineRunExit;
|
|
||||||
use crate::history::HistoryEntry;
|
|
||||||
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 after prompt submission.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub enum PromptAction {
|
|
||||||
/// Proceed normally.
|
|
||||||
Continue,
|
|
||||||
/// Cancel with a reason.
|
|
||||||
Cancel(String),
|
|
||||||
/// Proceed, and append these items to history right after the user
|
|
||||||
/// message. Mirrors [`TurnEndAction::ContinueWithMessages`] for the
|
|
||||||
/// submit edge: lets the upper layer attach resolver-produced
|
|
||||||
/// system messages (e.g. `@<path>` file content) so they sit
|
|
||||||
/// adjacent to the user message that referenced them.
|
|
||||||
ContinueWith(Vec<Item>),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Action before an LLM request.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub enum PreRequestAction {
|
|
||||||
/// Proceed normally.
|
|
||||||
Continue,
|
|
||||||
/// Proceed after appending these items to durable engine history.
|
|
||||||
///
|
|
||||||
/// This is for upper-layer budget/status nudges that the model may react
|
|
||||||
/// to: the items are committed before the request so later turns can see
|
|
||||||
/// why the engine changed course.
|
|
||||||
ContinueWith(Vec<Item>),
|
|
||||||
/// Yield after appending these items to durable engine history.
|
|
||||||
///
|
|
||||||
/// This is for host-mediated pre-request appends that must be visible to
|
|
||||||
/// usage accounting and compaction checks before the current LLM request is
|
|
||||||
/// allowed to proceed.
|
|
||||||
YieldWith(Vec<Item>),
|
|
||||||
/// Cancel with a reason (treated as an error).
|
|
||||||
Cancel(String),
|
|
||||||
/// Yield control to the caller for external processing.
|
|
||||||
///
|
|
||||||
/// The Engine exits the turn loop cleanly with `EngineResult::Yielded`.
|
|
||||||
/// The caller is expected to resume execution later.
|
|
||||||
Yield,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Action before a tool call.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum PreToolAction {
|
|
||||||
/// Proceed with execution.
|
|
||||||
Continue,
|
|
||||||
/// Skip this tool call (do not execute).
|
|
||||||
Skip,
|
|
||||||
/// Do not execute the tool call; commit this synthetic result instead.
|
|
||||||
///
|
|
||||||
/// This preserves provider-visible `tool_use` / `tool_result` pairing
|
|
||||||
/// without aborting the whole turn.
|
|
||||||
SyntheticResult(ToolResult),
|
|
||||||
/// Abort the entire run.
|
|
||||||
Abort(String),
|
|
||||||
/// Pause execution (can be resumed later).
|
|
||||||
Pause,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Action after a tool call.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum PostToolAction {
|
|
||||||
/// Proceed normally.
|
|
||||||
Continue,
|
|
||||||
/// Abort the entire run.
|
|
||||||
Abort(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Action at the end of a turn (when LLM produces no tool calls).
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum TurnEndAction {
|
|
||||||
/// Accept the Engine's natural next phase: execute tools, or finish when none exist.
|
|
||||||
Finish,
|
|
||||||
/// Commit additional messages, then continue through the natural next phase.
|
|
||||||
ContinueWithMessages(Vec<Item>),
|
|
||||||
/// Pause execution (can be resumed later).
|
|
||||||
Pause,
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Context Types
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/// Context for pre-tool-call decisions.
|
|
||||||
pub struct ToolCallInfo<'a, A = ()> {
|
|
||||||
pub invocation: InterceptorInvocation,
|
|
||||||
pub history: &'a [HistoryEntry<A>],
|
|
||||||
pub call: ToolCall,
|
|
||||||
/// Tool meta information.
|
|
||||||
pub meta: ToolMeta,
|
|
||||||
/// Tool instance (for state access).
|
|
||||||
pub tool: Arc<dyn Tool>,
|
|
||||||
/// Response-local execution context for this call.
|
|
||||||
pub context: ToolExecutionContext,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Context for post-tool-call decisions.
|
|
||||||
pub struct ToolResultInfo<'a, A = ()> {
|
|
||||||
pub invocation: InterceptorInvocation,
|
|
||||||
pub history: &'a [HistoryEntry<A>],
|
|
||||||
pub call: ToolCall,
|
|
||||||
/// Committed terminal tool execution result.
|
|
||||||
pub result: ToolResult,
|
|
||||||
/// Tool meta information.
|
|
||||||
pub meta: ToolMeta,
|
|
||||||
/// Tool instance (for state access).
|
|
||||||
pub tool: Arc<dyn Tool>,
|
|
||||||
/// Response-local execution context for this call.
|
|
||||||
pub context: ToolExecutionContext,
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Interceptor Trait
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
/// Intercepts the Engine execution loop at key decision points.
|
|
||||||
///
|
|
||||||
/// Every lifecycle method is asynchronous and returns [`InterceptorResult`],
|
|
||||||
/// keeping implementation failure separate from the method's control-flow
|
|
||||||
/// action. The Engine reports a failure as a typed run interruption annotated
|
|
||||||
/// 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]
|
|
||||||
pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
|
|
||||||
/// Called after receiving user input, before adding it to Engine history.
|
|
||||||
async fn on_prompt_submit(
|
|
||||||
&self,
|
|
||||||
_context: PromptSubmitContext<'_, A>,
|
|
||||||
) -> InterceptorResult<PromptAction> {
|
|
||||||
Ok(PromptAction::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Items that should be **committed to `engine.history`** just
|
|
||||||
/// before the next LLM request. Returned items are `extend`ed into
|
|
||||||
/// the persistent history (and therefore picked up by the per-turn
|
|
||||||
/// clone that backs the LLM request, plus the usual
|
|
||||||
/// history-persistence path).
|
|
||||||
///
|
|
||||||
/// Use this for inputs that arrive from outside the LLM and need
|
|
||||||
/// to be reflected in the on-disk history — notifications,
|
|
||||||
/// external events, system reminders. Do **not** use
|
|
||||||
/// [`Self::pre_llm_request`] for that purpose: it mutates a
|
|
||||||
/// per-request clone, so any committed assistant response that
|
|
||||||
/// reacts to the injection would have no visible trigger on the
|
|
||||||
/// next turn (or after resume / compaction).
|
|
||||||
///
|
|
||||||
/// `pre_llm_request` remains the right place for purely
|
|
||||||
/// reproducible per-request transformations (pruning, content
|
|
||||||
/// trimming, cache anchors) that depend only on the existing
|
|
||||||
/// history.
|
|
||||||
async fn pending_history_appends(
|
|
||||||
&self,
|
|
||||||
_context: PendingHistoryAppendsContext<'_, A>,
|
|
||||||
) -> InterceptorResult<Vec<Item>> {
|
|
||||||
Ok(Vec::new())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called before each LLM request. The context starts as a clone
|
|
||||||
/// of `engine.history` (after `pending_history_appends` and the
|
|
||||||
/// Engine's own prune projection have been applied).
|
|
||||||
///
|
|
||||||
/// Direct mutations to `context` remain request-local and are not persisted.
|
|
||||||
/// If an interceptor derives a human/model-visible nudge from the current
|
|
||||||
/// request context, return [`PreRequestAction::ContinueWith`] so the Engine
|
|
||||||
/// commits it to history before the request is sent.
|
|
||||||
async fn pre_llm_request(
|
|
||||||
&self,
|
|
||||||
_context: PreLlmRequestContext<'_, A>,
|
|
||||||
) -> InterceptorResult<PreRequestAction> {
|
|
||||||
Ok(PreRequestAction::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called before each tool is executed.
|
|
||||||
async fn pre_tool_call(
|
|
||||||
&self,
|
|
||||||
_info: &mut ToolCallInfo<'_, A>,
|
|
||||||
) -> InterceptorResult<PreToolAction> {
|
|
||||||
Ok(PreToolAction::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called after each tool reaches one terminal result and that result is committed.
|
|
||||||
async fn post_tool_call(
|
|
||||||
&self,
|
|
||||||
_info: &ToolResultInfo<'_, A>,
|
|
||||||
) -> InterceptorResult<PostToolAction> {
|
|
||||||
Ok(PostToolAction::Continue)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called after every terminal assistant response is committed and before
|
|
||||||
/// the Engine decides whether to execute tools, continue, or finish.
|
|
||||||
async fn on_assistant_turn_end(
|
|
||||||
&self,
|
|
||||||
_context: AssistantTurnEndContext<'_, A>,
|
|
||||||
) -> InterceptorResult<TurnEndAction> {
|
|
||||||
Ok(TurnEndAction::Finish)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called once for the terminal outcome of each public run or resume call.
|
|
||||||
async fn on_run_exit(&self, _context: RunExitContext<'_, A>) -> InterceptorResult<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default interceptor: no intervention. Engine proceeds through the loop
|
|
||||||
/// without any external control flow decisions.
|
|
||||||
pub(crate) struct DefaultInterceptor;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl<A: Send + Sync> Interceptor<A> for DefaultInterceptor {}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
#![doc = include_str!("../README.md")]
|
|
||||||
|
|
||||||
mod engine;
|
|
||||||
mod handler;
|
|
||||||
mod history;
|
|
||||||
mod message;
|
|
||||||
|
|
||||||
pub(crate) mod callback;
|
|
||||||
pub mod event;
|
|
||||||
pub mod interceptor;
|
|
||||||
pub mod llm_client;
|
|
||||||
pub mod providers;
|
|
||||||
pub mod prune;
|
|
||||||
pub mod state;
|
|
||||||
pub mod timeline;
|
|
||||||
pub mod token_counter;
|
|
||||||
pub mod tool;
|
|
||||||
pub mod tool_server;
|
|
||||||
pub mod usage_record;
|
|
||||||
|
|
||||||
pub use agen_macros::{description, tool, tool_registry};
|
|
||||||
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
|
|
||||||
pub use engine::{
|
|
||||||
Engine, EngineConfig, EngineError, EngineResult, EngineRunExit, EngineRunOutput,
|
|
||||||
LlmRetryNotice, RunInterruptionReason, ToolRegistryError,
|
|
||||||
};
|
|
||||||
pub use handler::ToolUseBlockStart;
|
|
||||||
pub use history::{History, HistoryEntry};
|
|
||||||
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 tool::{
|
|
||||||
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
|
|
||||||
ToolExecutionTerminal, ToolExecutionTerminalFuture, ToolOutputLimits, ToolResult,
|
|
||||||
ToolResultDisposition,
|
|
||||||
};
|
|
||||||
pub use usage_record::UsageRecord;
|
|
||||||
|
|
||||||
/// Implementation dependencies used by code generated from `agen` macros.
|
|
||||||
///
|
|
||||||
/// This module is not a stable user-facing API. It is public only because macro expansion
|
|
||||||
/// happens in the downstream crate.
|
|
||||||
#[doc(hidden)]
|
|
||||||
pub mod __private {
|
|
||||||
pub use async_trait;
|
|
||||||
pub use schemars;
|
|
||||||
pub use serde;
|
|
||||||
pub use serde_json;
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5,19 +5,18 @@ 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 = ["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
|
workdir = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Does not own:
|
|||||||
- product command names (`yoi`)
|
- product command names (`yoi`)
|
||||||
- Worker state authority (`worker`, `session-store` worker metadata)
|
- Worker state authority (`worker`, `session-store` worker metadata)
|
||||||
- UI rendering (`tui`)
|
- UI rendering (`tui`)
|
||||||
- Engine turn semantics (`agen`)
|
- Engine turn semantics (`llm-engine`)
|
||||||
|
|
||||||
## Design notes
|
## Design notes
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,189 +0,0 @@
|
|||||||
use crate::{BackendApiClient, BackendApiClientError};
|
|
||||||
use reqwest::Method;
|
|
||||||
use std::fmt;
|
|
||||||
use workspace_api::{
|
|
||||||
InitialRepositoryIntent, RepositoryListResponse, RepositorySummary,
|
|
||||||
WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse,
|
|
||||||
WorkspaceSummary,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
|
||||||
|
|
||||||
pub type BackendWorkspace = WorkspaceSummary;
|
|
||||||
pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
|
|
||||||
pub type CreateBackendWorkspaceRequest = WorkspaceCreateRequest;
|
|
||||||
pub type CreateBackendWorkspaceRepository = InitialRepositoryIntent;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct BackendWorkspaceCatalogTarget {
|
|
||||||
pub base_url: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BackendWorkspaceCatalogTarget {
|
|
||||||
pub fn new(base_url: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
base_url: base_url.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum BackendWorkspaceClientError {
|
|
||||||
InvalidTarget(String),
|
|
||||||
Api(BackendApiClientError),
|
|
||||||
Http(reqwest::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for BackendWorkspaceClientError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::InvalidTarget(message) => f.write_str(message),
|
|
||||||
Self::Api(error) => write!(f, "{error}"),
|
|
||||||
Self::Http(error) => write!(f, "{error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
fn from(error: reqwest::Error) -> Self {
|
|
||||||
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(
|
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
|
||||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
|
||||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
|
||||||
list_backend_workspaces_with_client(&client).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_backend_workspaces_with_client(
|
|
||||||
client: &BackendApiClient,
|
|
||||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
|
||||||
let response = client
|
|
||||||
.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(
|
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
|
||||||
request: &CreateBackendWorkspaceRequest,
|
|
||||||
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
|
|
||||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
|
||||||
let response = client
|
|
||||||
.request(Method::POST, "/api/workspaces")?
|
|
||||||
.json(request)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
client.check_status(response.status())?;
|
|
||||||
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
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]
|
|
||||||
fn create_request_keeps_operation_key_for_exact_retry() {
|
|
||||||
let request = CreateBackendWorkspaceRequest {
|
|
||||||
operation_key: "workspace-create-1".to_string(),
|
|
||||||
display_name: "Alpha".to_string(),
|
|
||||||
repository: CreateBackendWorkspaceRepository {
|
|
||||||
repository_key: "main".to_string(),
|
|
||||||
uri: "/srv/repos/alpha".to_string(),
|
|
||||||
default_ref: Some("develop".to_string()),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let retry = request.clone();
|
|
||||||
assert_eq!(retry.operation_key, "workspace-create-1");
|
|
||||||
assert_eq!(retry, request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 { .. }))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+33
-39
@@ -1,54 +1,48 @@
|
|||||||
//! 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 runtime_command;
|
||||||
mod client;
|
pub mod spawn;
|
||||||
pub mod target;
|
pub mod target;
|
||||||
pub mod transport;
|
pub mod ticket_role;
|
||||||
mod workspace_product;
|
mod worker_client;
|
||||||
|
|
||||||
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, BackendRuntimeClient, BackendRuntimeClientError, BackendRuntimeListResponse,
|
||||||
BackendDiagnosticSeverity, BackendRuntimeClientError, BackendRuntimeListResponse,
|
|
||||||
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
||||||
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
|
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
||||||
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
|
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,
|
||||||
BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
|
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers,
|
||||||
BackendWorkerRestoreResult, BackendWorkerSummary, BackendWorkerWorkspaceSummary,
|
list_backend_workers, restore_backend_worker,
|
||||||
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 runtime_command::WorkerRuntimeCommand;
|
||||||
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
|
||||||
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
|
||||||
CreateBackendWorkspaceResponse, create_backend_workspace,
|
|
||||||
list_backend_workspace_repositories_blocking, list_backend_workspaces,
|
|
||||||
list_backend_workspaces_blocking,
|
|
||||||
};
|
|
||||||
pub use client::{Client, ClientError};
|
|
||||||
pub use target::{
|
pub use target::{
|
||||||
BackendTarget, BackendWorkerLaunch, Dashboard, ResolvedTarget, StandaloneTarget,
|
BackendTarget, Dashboard, LocalTarget, Target, TargetError, TargetKind, WorkerByName,
|
||||||
StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind,
|
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerResume,
|
||||||
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
|
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 workspace_product::BackendWorkspaceProductClient;
|
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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
-263
@@ -1,44 +1,41 @@
|
|||||||
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)]
|
|
||||||
pub enum ResolvedTarget {
|
|
||||||
Standalone,
|
|
||||||
Backend {
|
|
||||||
base_url: String,
|
|
||||||
workspace_id: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ResolvedTarget {
|
|
||||||
pub fn kind(&self) -> TargetKind {
|
|
||||||
match self {
|
|
||||||
Self::Standalone => TargetKind::Standalone,
|
|
||||||
Self::Backend { .. } => TargetKind::Backend,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 +44,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 +90,28 @@ 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 struct Dashboard {
|
||||||
pub base_url: String,
|
pub runtime_command: WorkerRuntimeCommand,
|
||||||
pub workspace_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct BackendWorkerLaunch {
|
|
||||||
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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,9 +132,9 @@ impl TargetError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn invalid(target: TargetKind, message: impl Into<String>) -> Self {
|
fn local_runtime_command(error: std::io::Error) -> Self {
|
||||||
Self {
|
Self {
|
||||||
message: format!("invalid {target} target: {}", message.into()),
|
message: format!("failed to resolve local Worker runtime command: {error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,50 +150,63 @@ impl std::error::Error for TargetError {}
|
|||||||
pub trait Target: fmt::Debug + Send + Sync {
|
pub trait Target: fmt::Debug + Send + Sync {
|
||||||
fn kind(&self) -> TargetKind;
|
fn kind(&self) -> TargetKind;
|
||||||
|
|
||||||
/// Resolve the target once for Workspace product-state operations.
|
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError>;
|
||||||
///
|
|
||||||
/// Backend targets must carry an explicit Workspace identity. Callers use
|
fn worker_by_name(&self) -> Result<WorkerByName, TargetError>;
|
||||||
/// this value instead of rediscovering authority from cwd or process
|
|
||||||
/// configuration after command dispatch.
|
fn resume_worker(&self) -> Result<WorkerResume, TargetError>;
|
||||||
fn resolve(&self) -> Result<ResolvedTarget, 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 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 {
|
||||||
|
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,107 +220,38 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve(&self) -> Result<ResolvedTarget, TargetError> {
|
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
|
||||||
let workspace_id = self.workspace_id.clone().ok_or_else(|| {
|
Err(TargetError::unsupported("Worker spawn", self.kind()))
|
||||||
TargetError::invalid(
|
}
|
||||||
self.kind(),
|
|
||||||
"workspace selection is required for Backend product-state operations",
|
fn worker_by_name(&self) -> Result<WorkerByName, TargetError> {
|
||||||
)
|
Err(TargetError::unsupported(
|
||||||
})?;
|
"Worker name attachment",
|
||||||
Ok(ResolvedTarget::Backend {
|
self.kind(),
|
||||||
base_url: self.base_url.clone(),
|
))
|
||||||
workspace_id,
|
}
|
||||||
})
|
|
||||||
|
fn resume_worker(&self) -> Result<WorkerResume, TargetError> {
|
||||||
|
Err(TargetError::unsupported("Worker resume", self.kind()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
||||||
let ResolvedTarget::Backend {
|
Err(TargetError::unsupported("Dashboard", 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> {
|
|
||||||
Ok(BackendWorkerLaunch {
|
|
||||||
target: BackendWorkerLaunchTarget::new(
|
|
||||||
self.base_url.clone(),
|
|
||||||
self.workspace_id.clone(),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -335,16 +260,9 @@ impl Target for BackendTarget {
|
|||||||
&self,
|
&self,
|
||||||
selector: WorkerConnectionSelector,
|
selector: WorkerConnectionSelector,
|
||||||
) -> Result<WorkerConnection, TargetError> {
|
) -> Result<WorkerConnection, TargetError> {
|
||||||
let workspace_id = self.workspace_id.clone().ok_or_else(|| {
|
|
||||||
TargetError::invalid(
|
|
||||||
self.kind(),
|
|
||||||
"workspace selection is required before connecting to a Backend Worker",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(WorkerConnection {
|
Ok(WorkerConnection {
|
||||||
target: BackendRuntimeTarget::new(
|
target: BackendRuntimeTarget::new(
|
||||||
self.base_url.clone(),
|
self.base_url.clone(),
|
||||||
workspace_id,
|
|
||||||
selector.runtime_id,
|
selector.runtime_id,
|
||||||
selector.worker_id,
|
selector.worker_id,
|
||||||
),
|
),
|
||||||
@@ -356,76 +274,6 @@ impl Target for BackendTarget {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_target_resolves_workspace_scoped_product_state_authority() {
|
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
target.resolve().unwrap(),
|
|
||||||
ResolvedTarget::Backend {
|
|
||||||
base_url: "http://127.0.0.1:8787".to_string(),
|
|
||||||
workspace_id: "workspace-a".to_string(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_target_rejects_product_state_resolution_without_workspace() {
|
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
target
|
|
||||||
.resolve()
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string()
|
|
||||||
.contains("workspace selection is required")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn standalone_target_carries_in_process_state_without_runtime_command() {
|
|
||||||
let target = StandaloneTarget::new("/tmp/yoi-standalone-state");
|
|
||||||
|
|
||||||
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]
|
|
||||||
fn backend_target_builds_workspace_scoped_dashboard() {
|
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
target.dashboard().unwrap(),
|
|
||||||
Dashboard {
|
|
||||||
base_url: "http://127.0.0.1:8787".to_string(),
|
|
||||||
workspace_id: "workspace-a".to_string(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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 +281,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")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -452,23 +313,30 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(connection.target.base_url, "http://127.0.0.1:8787");
|
assert_eq!(connection.target.base_url, "http://127.0.0.1:8787");
|
||||||
assert_eq!(connection.target.workspace_id, "workspace-a");
|
|
||||||
assert_eq!(connection.target.runtime_id, "runtime-a");
|
assert_eq!(connection.target.runtime_id, "runtime-a");
|
||||||
assert_eq!(connection.target.worker_id, "worker-b");
|
assert_eq!(connection.target.worker_id, "worker-b");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn standalone_target_builds_explicit_worker_intents() {
|
fn backend_target_rejects_local_worker_operations() {
|
||||||
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 err = target.spawn_worker().unwrap_err();
|
||||||
assert_eq!(list.state_dir, PathBuf::from("/tmp/yoi-client-workers"));
|
|
||||||
assert!(list.include_all);
|
|
||||||
assert!(list.cwd.is_absolute());
|
|
||||||
|
|
||||||
let resume = target
|
assert_eq!(
|
||||||
.standalone_worker_resume("019d1234-0000-7000-8000-000000000000".to_string())
|
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,984 +0,0 @@
|
|||||||
use reqwest::Method;
|
|
||||||
use serde::Serialize;
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use ticket::{
|
|
||||||
MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation,
|
|
||||||
OrchestrationPlanKind, OrchestrationPlanRecord, Ticket, TicketBackend, TicketDependencyCheck,
|
|
||||||
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketItemEdit,
|
|
||||||
TicketListQuery, TicketListState, TicketMarkReady, TicketRef, TicketRelation,
|
|
||||||
TicketRelationKind, TicketRelationView, TicketStateChange, TicketStateSelector, TicketSummary,
|
|
||||||
};
|
|
||||||
use workspace_api::{
|
|
||||||
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
|
||||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
|
||||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
|
||||||
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
|
|
||||||
RuntimeTrustKeyRevealResponse, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
|
||||||
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
|
|
||||||
WorkspaceRuntimeResource,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
|
||||||
|
|
||||||
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
|
||||||
|
|
||||||
/// Workspace-scoped Backend client for Ticket and Objective product state.
|
|
||||||
///
|
|
||||||
/// Construction requires both the selected Backend URL and Workspace identity.
|
|
||||||
/// Callers should derive these once from `Target::resolve()` and must not retry
|
|
||||||
/// failed requests against repository-local state.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct BackendWorkspaceProductClient {
|
|
||||||
api: BackendApiClient,
|
|
||||||
workspace_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BackendWorkspaceProductClient {
|
|
||||||
pub fn new(
|
|
||||||
base_url: impl Into<String>,
|
|
||||||
workspace_id: impl Into<String>,
|
|
||||||
) -> Result<Self, BackendWorkspaceClientError> {
|
|
||||||
let base_url = base_url.into();
|
|
||||||
let api = BackendApiClient::from_stored_token(&base_url)?;
|
|
||||||
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 })
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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 {
|
|
||||||
&self.workspace_id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_tickets(
|
|
||||||
&self,
|
|
||||||
query: &TicketListQuery,
|
|
||||||
) -> Result<Vec<TicketSummary>, BackendWorkspaceClientError> {
|
|
||||||
let state = ticket_list_state_query(query);
|
|
||||||
self.get_json(&format!("/tickets/search?state={state}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn show_ticket(&self, id: &TicketIdOrSlug) -> Result<Ticket, BackendWorkspaceClientError> {
|
|
||||||
self.get_json(&format!(
|
|
||||||
"/tickets/{}/record",
|
|
||||||
encode_path_segment(&ticket_reference(id))
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_ticket(
|
|
||||||
&self,
|
|
||||||
input: &NewTicket,
|
|
||||||
) -> Result<TicketRef, BackendWorkspaceClientError> {
|
|
||||||
self.send_json(Method::POST, "/tickets", Some(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_ticket_event(
|
|
||||||
&self,
|
|
||||||
id: &TicketIdOrSlug,
|
|
||||||
event: &NewTicketEvent,
|
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
|
||||||
self.send_unit(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/thread-events",
|
|
||||||
encode_path_segment(&ticket_reference(id))
|
|
||||||
),
|
|
||||||
Some(event),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_ticket_workflow_state(
|
|
||||||
&self,
|
|
||||||
id: &TicketIdOrSlug,
|
|
||||||
change: &TicketStateChange,
|
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
|
||||||
self.send_unit(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/workflow-state",
|
|
||||||
encode_path_segment(&ticket_reference(id))
|
|
||||||
),
|
|
||||||
Some(change),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn close_ticket(
|
|
||||||
&self,
|
|
||||||
id: &TicketIdOrSlug,
|
|
||||||
resolution: &MarkdownText,
|
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
|
||||||
self.send_unit(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/workflow/close",
|
|
||||||
encode_path_segment(&ticket_reference(id))
|
|
||||||
),
|
|
||||||
Some(resolution),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_ticket_relation(
|
|
||||||
&self,
|
|
||||||
id: &TicketIdOrSlug,
|
|
||||||
relation: &NewTicketRelation,
|
|
||||||
) -> Result<TicketRelation, BackendWorkspaceClientError> {
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/relations",
|
|
||||||
encode_path_segment(&ticket_reference(id))
|
|
||||||
),
|
|
||||||
Some(relation),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn query_ticket_relations(
|
|
||||||
&self,
|
|
||||||
ticket: Option<&TicketIdOrSlug>,
|
|
||||||
kind: Option<TicketRelationKind>,
|
|
||||||
) -> Result<Vec<TicketRelation>, BackendWorkspaceClientError> {
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct Query<'a> {
|
|
||||||
ticket: Option<&'a TicketIdOrSlug>,
|
|
||||||
kind: Option<TicketRelationKind>,
|
|
||||||
}
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
TICKET_RELATIONS_QUERY_PATH,
|
|
||||||
Some(&Query { ticket, kind }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ticket_doctor(&self) -> Result<TicketDoctorReport, BackendWorkspaceClientError> {
|
|
||||||
self.get_json("/tickets/doctor")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn list_objectives(
|
|
||||||
&self,
|
|
||||||
limit: usize,
|
|
||||||
) -> Result<ListResponse<ObjectiveSummary>, BackendWorkspaceClientError> {
|
|
||||||
self.get_json(&format!("/objectives?limit={limit}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn show_objective(&self, id: &str) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
|
||||||
self.get_json(&format!("/objectives/{}", encode_path_segment(id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn create_objective(
|
|
||||||
&self,
|
|
||||||
input: &ObjectiveCreateRequest,
|
|
||||||
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
|
||||||
self.send_json(Method::POST, "/objectives", Some(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn edit_objective(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
input: &ObjectiveEditRequest,
|
|
||||||
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
|
||||||
self.send_json(
|
|
||||||
Method::PATCH,
|
|
||||||
&format!("/objectives/{}", encode_path_segment(id)),
|
|
||||||
Some(input),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_objective_state(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
input: &ObjectiveStateRequest,
|
|
||||||
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
&format!("/objectives/{}/state", encode_path_segment(id)),
|
|
||||||
Some(input),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn link_objective_ticket(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
input: &ObjectiveLinkTicketRequest,
|
|
||||||
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
&format!("/objectives/{}/ticket-links", encode_path_segment(id)),
|
|
||||||
Some(input),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn unlink_objective_ticket(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
ticket_id: &str,
|
|
||||||
) -> Result<ObjectiveDetail, BackendWorkspaceClientError> {
|
|
||||||
self.send_json::<(), _>(
|
|
||||||
Method::DELETE,
|
|
||||||
&format!(
|
|
||||||
"/objectives/{}/ticket-links/{}",
|
|
||||||
encode_path_segment(id),
|
|
||||||
encode_path_segment(ticket_id)
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
|
||||||
&self,
|
|
||||||
ticket_id: &str,
|
|
||||||
) -> Result<String, BackendWorkspaceClientError> {
|
|
||||||
let options: WorkerLaunchOptionsResponse = self.get_json("/workers/launch-options")?;
|
|
||||||
let runtime = options
|
|
||||||
.runtimes
|
|
||||||
.iter()
|
|
||||||
.find(|runtime| runtime.worker_creation_available && !runtime.working_directory_required)
|
|
||||||
.ok_or_else(|| {
|
|
||||||
BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Backend has no spawn-capable Runtime that supports a Workdir-less Intake Worker"
|
|
||||||
.to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let request = CreateWorkspaceWorkerRequest {
|
|
||||||
runtime_id: runtime.runtime_id.clone(),
|
|
||||||
display_name: format!("intake-{ticket_id}"),
|
|
||||||
profile: Some("builtin:intake".to_string()),
|
|
||||||
ticket_assignment: None,
|
|
||||||
initial_submit: vec![protocol::Segment::Text {
|
|
||||||
content: format!("Please handle intake for Ticket {ticket_id}."),
|
|
||||||
}],
|
|
||||||
working_directory: None,
|
|
||||||
control_operation_id: None,
|
|
||||||
};
|
|
||||||
let response: BrowserCreateWorkerResponse =
|
|
||||||
self.send_json(Method::POST, "/workers", Some(&request))?;
|
|
||||||
Ok(format!(
|
|
||||||
"Started Intake Worker {}/{} for Ticket {ticket_id}",
|
|
||||||
response.runtime_id, response.worker_id
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_workspace_orchestrator(&self) -> Result<String, BackendWorkspaceClientError> {
|
|
||||||
let response: BrowserWorkspaceOrchestratorResponse =
|
|
||||||
self.send_json::<(), _>(Method::POST, "/orchestrator", None)?;
|
|
||||||
let worker = response.worker.ok_or_else(|| {
|
|
||||||
BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Backend accepted the Orchestrator request without returning a Worker".to_string(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(format!(
|
|
||||||
"Workspace Orchestrator {} at {}/{}",
|
|
||||||
response.disposition, worker.runtime_id, worker.worker_id
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn default_product_list_limit() -> usize {
|
|
||||||
DEFAULT_PRODUCT_LIST_LIMIT
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_json<R: DeserializeOwned>(&self, path: &str) -> Result<R, BackendWorkspaceClientError> {
|
|
||||||
self.send_json::<(), R>(Method::GET, path, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_json<B: Serialize + ?Sized, R: DeserializeOwned>(
|
|
||||||
&self,
|
|
||||||
method: Method,
|
|
||||||
path: &str,
|
|
||||||
body: Option<&B>,
|
|
||||||
) -> Result<R, BackendWorkspaceClientError> {
|
|
||||||
let response = self.request(method, path, body)?.send()?;
|
|
||||||
self.api.check_status(response.status())?;
|
|
||||||
response.json().map_err(BackendWorkspaceClientError::Http)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_unit<B: Serialize + ?Sized>(
|
|
||||||
&self,
|
|
||||||
method: Method,
|
|
||||||
path: &str,
|
|
||||||
body: Option<&B>,
|
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
|
||||||
let response = self.request(method, path, body)?.send()?;
|
|
||||||
self.api.check_status(response.status())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request<B: Serialize + ?Sized>(
|
|
||||||
&self,
|
|
||||||
method: Method,
|
|
||||||
path: &str,
|
|
||||||
body: Option<&B>,
|
|
||||||
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
|
|
||||||
let path = format!(
|
|
||||||
"/api/w/{}/{}",
|
|
||||||
encode_path_segment(&self.workspace_id),
|
|
||||||
path.trim_start_matches('/')
|
|
||||||
);
|
|
||||||
let request = self.api.blocking_request(method, &path)?;
|
|
||||||
Ok(match body {
|
|
||||||
Some(body) => request.json(body),
|
|
||||||
None => request,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TicketBackend for BackendWorkspaceProductClient {
|
|
||||||
fn default_intake_ready_state_change_body(&self, from: &str) -> String {
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct Request<'a> {
|
|
||||||
from: &'a str,
|
|
||||||
}
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
"/tickets/default-intake-ready-body",
|
|
||||||
Some(&Request { from }),
|
|
||||||
)
|
|
||||||
.unwrap_or_else(|error| error.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list(&self, filter: TicketListQuery) -> ticket::Result<Vec<TicketSummary>> {
|
|
||||||
self.list_tickets(&filter).map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show(&self, id: TicketIdOrSlug) -> ticket::Result<Ticket> {
|
|
||||||
self.show_ticket(&id).map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create(&self, input: NewTicket) -> ticket::Result<TicketRef> {
|
|
||||||
self.create_ticket(&input).map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn edit_item(&self, id: TicketIdOrSlug, edit: TicketItemEdit) -> ticket::Result<Ticket> {
|
|
||||||
self.send_json(
|
|
||||||
Method::PATCH,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/item",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
),
|
|
||||||
Some(&edit),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dependency_check(&self, id: TicketIdOrSlug) -> ticket::Result<TicketDependencyCheck> {
|
|
||||||
self.get_json(&format!(
|
|
||||||
"/tickets/{}/dependency-check",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
))
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> ticket::Result<()> {
|
|
||||||
self.add_ticket_event(&id, &event)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_state_changed(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
change: TicketStateChange,
|
|
||||||
) -> ticket::Result<()> {
|
|
||||||
self.send_unit(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/state-changes",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
),
|
|
||||||
Some(&change),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_intake_summary(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
summary: TicketIntakeSummary,
|
|
||||||
) -> ticket::Result<()> {
|
|
||||||
self.send_unit(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/intake-summaries",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
),
|
|
||||||
Some(&summary),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_state_field(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
field: &str,
|
|
||||||
change: TicketStateChange,
|
|
||||||
) -> ticket::Result<()> {
|
|
||||||
self.send_unit(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/state-fields/{}",
|
|
||||||
encode_path_segment(&ticket_reference(&id)),
|
|
||||||
encode_path_segment(field)
|
|
||||||
),
|
|
||||||
Some(&change),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_workflow_state(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
change: TicketStateChange,
|
|
||||||
) -> ticket::Result<()> {
|
|
||||||
self.set_ticket_workflow_state(&id, &change)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> ticket::Result<Ticket> {
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/workflow/mark-ready",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
),
|
|
||||||
Some(&request),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn queue_ready(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
_queued_by: &str,
|
|
||||||
) -> ticket::Result<ticket::TicketQueueOutcome> {
|
|
||||||
self.send_json::<(), _>(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/workflow/queue",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> ticket::Result<()> {
|
|
||||||
self.close_ticket(&id, &resolution)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_ticket_relation(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
relation: NewTicketRelation,
|
|
||||||
) -> ticket::Result<TicketRelation> {
|
|
||||||
BackendWorkspaceProductClient::add_ticket_relation(self, &id, &relation)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_ticket_relation(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
kind: TicketRelationKind,
|
|
||||||
target: TicketIdOrSlug,
|
|
||||||
) -> ticket::Result<TicketRelation> {
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct Request {
|
|
||||||
kind: TicketRelationKind,
|
|
||||||
target: String,
|
|
||||||
}
|
|
||||||
self.send_json(
|
|
||||||
Method::DELETE,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/relations",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
),
|
|
||||||
Some(&Request {
|
|
||||||
kind,
|
|
||||||
target: ticket_reference(&target),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query_ticket_relations(
|
|
||||||
&self,
|
|
||||||
ticket: Option<TicketIdOrSlug>,
|
|
||||||
kind: Option<TicketRelationKind>,
|
|
||||||
) -> ticket::Result<Vec<TicketRelation>> {
|
|
||||||
BackendWorkspaceProductClient::query_ticket_relations(self, ticket.as_ref(), kind)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn relation_view(&self, id: TicketIdOrSlug) -> ticket::Result<TicketRelationView> {
|
|
||||||
self.get_json(&format!(
|
|
||||||
"/tickets/{}/relation-view",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
))
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn add_orchestration_plan_record(
|
|
||||||
&self,
|
|
||||||
id: TicketIdOrSlug,
|
|
||||||
record: NewOrchestrationPlanRecord,
|
|
||||||
) -> ticket::Result<OrchestrationPlanRecord> {
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
&format!(
|
|
||||||
"/tickets/{}/orchestration-plans",
|
|
||||||
encode_path_segment(&ticket_reference(&id))
|
|
||||||
),
|
|
||||||
Some(&record),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query_orchestration_plan_records(
|
|
||||||
&self,
|
|
||||||
ticket: Option<TicketIdOrSlug>,
|
|
||||||
kind: Option<OrchestrationPlanKind>,
|
|
||||||
) -> ticket::Result<Vec<OrchestrationPlanRecord>> {
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct Query {
|
|
||||||
ticket: Option<TicketIdOrSlug>,
|
|
||||||
kind: Option<OrchestrationPlanKind>,
|
|
||||||
}
|
|
||||||
self.send_json(
|
|
||||||
Method::POST,
|
|
||||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
|
||||||
Some(&Query { ticket, kind }),
|
|
||||||
)
|
|
||||||
.map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn doctor(&self) -> ticket::Result<TicketDoctorReport> {
|
|
||||||
self.ticket_doctor().map_err(ticket_client_error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ticket_client_error(error: BackendWorkspaceClientError) -> TicketError {
|
|
||||||
TicketError::Sqlite(format!("Backend request failed: {error}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ticket_reference(id: &TicketIdOrSlug) -> String {
|
|
||||||
match id {
|
|
||||||
TicketIdOrSlug::Id(id) => id.to_string(),
|
|
||||||
TicketIdOrSlug::Slug(slug) | TicketIdOrSlug::Query(slug) => slug.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ticket_list_state_query(query: &TicketListQuery) -> String {
|
|
||||||
match &query.state {
|
|
||||||
TicketStateSelector::Active => "active".to_string(),
|
|
||||||
TicketStateSelector::All => "all".to_string(),
|
|
||||||
TicketStateSelector::States(states) => states
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.map(TicketListState::as_str)
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(","),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_path_segment(value: &str) -> String {
|
|
||||||
let mut encoded = String::with_capacity(value.len());
|
|
||||||
for byte in value.bytes() {
|
|
||||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
|
|
||||||
encoded.push(char::from(byte));
|
|
||||||
} else {
|
|
||||||
use std::fmt::Write as _;
|
|
||||||
write!(&mut encoded, "%{byte:02X}").expect("writing to String cannot fail");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
encoded
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::io::{Read, Write};
|
|
||||||
use std::net::TcpListener;
|
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::thread;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn one_response_server(
|
|
||||||
status: &str,
|
|
||||||
body: &str,
|
|
||||||
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
||||||
let address = listener.local_addr().unwrap();
|
|
||||||
let status = status.to_string();
|
|
||||||
let body = body.to_string();
|
|
||||||
let (sender, receiver) = mpsc::channel();
|
|
||||||
let handle = thread::spawn(move || {
|
|
||||||
let (mut stream, _) = listener.accept().unwrap();
|
|
||||||
let mut request = vec![0_u8; 8_192];
|
|
||||||
let bytes = stream.read(&mut request).unwrap();
|
|
||||||
sender
|
|
||||||
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
|
|
||||||
.unwrap();
|
|
||||||
write!(
|
|
||||||
stream,
|
|
||||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
||||||
body.len()
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
});
|
|
||||||
(format!("http://{address}"), receiver, handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn response_sequence_server(
|
|
||||||
responses: Vec<(&'static str, &'static str)>,
|
|
||||||
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
||||||
let address = listener.local_addr().unwrap();
|
|
||||||
let (sender, receiver) = mpsc::channel();
|
|
||||||
let handle = thread::spawn(move || {
|
|
||||||
for (status, body) in responses {
|
|
||||||
let (mut stream, _) = listener.accept().unwrap();
|
|
||||||
let mut request = vec![0_u8; 16_384];
|
|
||||||
let bytes = stream.read(&mut request).unwrap();
|
|
||||||
sender
|
|
||||||
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
|
|
||||||
.unwrap();
|
|
||||||
write!(
|
|
||||||
stream,
|
|
||||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
||||||
body.len()
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
(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]
|
|
||||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
|
||||||
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 client = BackendWorkspaceProductClient::new_with_access_token(
|
|
||||||
base_url,
|
|
||||||
"workspace-a",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let response = client.list_objectives(1_000).unwrap();
|
|
||||||
|
|
||||||
assert!(response.items.is_empty());
|
|
||||||
let request = request.recv().unwrap();
|
|
||||||
assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 "));
|
|
||||||
assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn backend_mutation_failure_is_returned_without_local_fallback() {
|
|
||||||
let (base_url, request, handle) =
|
|
||||||
one_response_server("403 Forbidden", "test-backend-token");
|
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
|
||||||
base_url,
|
|
||||||
"workspace-a",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let error = client
|
|
||||||
.create_objective(&ObjectiveCreateRequest {
|
|
||||||
title: "Objective".to_string(),
|
|
||||||
body_md: "body".to_string(),
|
|
||||||
state: "active".to_string(),
|
|
||||||
linked_tickets: Vec::new(),
|
|
||||||
})
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert!(error.to_string().contains("403"));
|
|
||||||
assert!(!error.to_string().contains("test-backend-token"));
|
|
||||||
assert!(
|
|
||||||
request
|
|
||||||
.recv()
|
|
||||||
.unwrap()
|
|
||||||
.starts_with("POST /api/w/workspace-a/objectives ")
|
|
||||||
);
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ticket_relation_query_uses_workspace_scoped_backend_route() {
|
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
|
||||||
base_url,
|
|
||||||
"workspace-a",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let relations = client
|
|
||||||
.query_ticket_relations(
|
|
||||||
Some(&TicketIdOrSlug::Query("T-1".to_string())),
|
|
||||||
Some(TicketRelationKind::Related),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(relations.is_empty());
|
|
||||||
let request = request.recv().unwrap();
|
|
||||||
assert!(request.starts_with("POST /api/w/workspace-a/tickets/relations/search "));
|
|
||||||
assert!(request.contains("\"ticket\":{\"Query\":\"T-1\"}"));
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
|
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
|
||||||
base_url,
|
|
||||||
"workspace-a",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap();
|
|
||||||
|
|
||||||
assert!(records.is_empty());
|
|
||||||
assert!(
|
|
||||||
request
|
|
||||||
.recv()
|
|
||||||
.unwrap()
|
|
||||||
.starts_with("POST /api/w/workspace-a/tickets/orchestration-plans/search ")
|
|
||||||
);
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ticket_intake_launch_uses_backend_options_and_workspace_worker_route() {
|
|
||||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
|
||||||
(
|
|
||||||
"200 OK",
|
|
||||||
r#"{"workspace_id":"workspace-a","runtimes":[{"runtime_id":"embedded","display_name":"Embedded","built_in":true,"worker_creation_available":true,"working_directory_required":false,"status":"connected","diagnostics":[]}],"default_profile":null,"profiles":[],"repositories":[],"working_directories":[],"diagnostics":[]}"#,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"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":[]}"#,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
|
||||||
base_url,
|
|
||||||
"workspace-a",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let status = client.launch_ticket_intake("T-1").unwrap();
|
|
||||||
|
|
||||||
assert!(status.contains("embedded/worker-1"));
|
|
||||||
assert!(
|
|
||||||
requests
|
|
||||||
.recv()
|
|
||||||
.unwrap()
|
|
||||||
.starts_with("GET /api/w/workspace-a/workers/launch-options ")
|
|
||||||
);
|
|
||||||
let create_request = requests.recv().unwrap();
|
|
||||||
assert!(create_request.starts_with("POST /api/w/workspace-a/workers "));
|
|
||||||
assert!(create_request.contains("\"profile\":\"builtin:intake\""));
|
|
||||||
assert!(create_request.contains("Ticket T-1"));
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
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 (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 status = client.start_workspace_orchestrator().unwrap();
|
|
||||||
|
|
||||||
assert!(status.contains("created at embedded/worker-2"));
|
|
||||||
assert!(
|
|
||||||
request
|
|
||||||
.recv()
|
|
||||||
.unwrap()
|
|
||||||
.starts_with("POST /api/w/workspace-a/orchestrator ")
|
|
||||||
);
|
|
||||||
handle.join().unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn product_client_requires_workspace_identity() {
|
|
||||||
let error = BackendWorkspaceProductClient::new_with_access_token(
|
|
||||||
"http://127.0.0.1:8787",
|
|
||||||
"",
|
|
||||||
"test-backend-token",
|
|
||||||
)
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(error.to_string().contains("Workspace identity"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ticket_state_query_preserves_local_filter_semantics() {
|
|
||||||
assert_eq!(
|
|
||||||
ticket_list_state_query(&TicketListQuery::active()),
|
|
||||||
"active"
|
|
||||||
);
|
|
||||||
assert_eq!(ticket_list_state_query(&TicketListQuery::all()), "all");
|
|
||||||
assert_eq!(
|
|
||||||
ticket_list_state_query(&TicketListQuery {
|
|
||||||
state: TicketStateSelector::States(
|
|
||||||
[TicketListState::Ready, TicketListState::InProgress]
|
|
||||||
.into_iter()
|
|
||||||
.collect(),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
"ready,inprogress"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ticket_and_objective_references_are_path_encoded() {
|
|
||||||
assert_eq!(encode_path_segment("T-1/a"), "T-1%2Fa");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
use client::{
|
|
||||||
BackendTarget, CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest, Target,
|
|
||||||
WorkerConnectionSelector,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn workspace_creation_request_preserves_operation_key_for_retry() {
|
|
||||||
let request = CreateBackendWorkspaceRequest {
|
|
||||||
operation_key: "workspace-create-1".to_string(),
|
|
||||||
display_name: "Alpha".to_string(),
|
|
||||||
repository: CreateBackendWorkspaceRepository {
|
|
||||||
repository_key: "main".to_string(),
|
|
||||||
uri: "/srv/repos/alpha".to_string(),
|
|
||||||
default_ref: Some("develop".to_string()),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
assert_eq!(request.clone(), request);
|
|
||||||
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]
|
|
||||||
fn backend_worker_connection_requires_explicit_workspace_scope() {
|
|
||||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
|
||||||
let error = match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-a"))
|
|
||||||
{
|
|
||||||
Ok(_) => panic!("unscoped Backend worker connection must fail"),
|
|
||||||
Err(error) => error,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
error
|
|
||||||
.to_string()
|
|
||||||
.contains("workspace selection is required")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub const MAX_TOTAL_BYTES: usize = 4 * 1024 * 1024;
|
|||||||
pub const MAX_PATH_BYTES: usize = 512;
|
pub const MAX_PATH_BYTES: usize = 512;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)]
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, ts_rs::TS)]
|
||||||
|
#[serde(transparent)]
|
||||||
pub struct VirtualPath(String);
|
pub struct VirtualPath(String);
|
||||||
|
|
||||||
impl VirtualPath {
|
impl VirtualPath {
|
||||||
@@ -1203,9 +1204,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,
|
||||||
@@ -1793,19 +1791,6 @@ mod tests {
|
|||||||
assert_eq!(path("profiles/main.dcdl").as_str(), "profiles/main.dcdl");
|
assert_eq!(path("profiles/main.dcdl").as_str(), "profiles/main.dcdl");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn virtual_path_serde_shape_is_a_string() {
|
|
||||||
let path = path("profiles/main.dcdl");
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::to_value(&path).unwrap(),
|
|
||||||
serde_json::json!(path.as_str())
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
serde_json::from_value::<VirtualPath>(serde_json::json!(path.as_str())).unwrap(),
|
|
||||||
path
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn candidate_changes_are_atomic_ordered_and_conflict_checked() {
|
fn candidate_changes_are_atomic_ordered_and_conflict_checked() {
|
||||||
let base = ConfigTreeSnapshot::from_entries(
|
let base = ConfigTreeSnapshot::from_entries(
|
||||||
@@ -1964,31 +1949,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,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "llm-engine-macros"
|
||||||
|
description = "llm-engine's proc macros"
|
||||||
|
version = "0.2.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
proc-macro = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
proc-macro2 = "1"
|
||||||
|
quote = "1"
|
||||||
|
syn = { version = "2", features = ["full"] }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# llm-engine-macros
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
`llm-engine-macros` provides procedural macros for declaring Rust methods as LLM-callable tools.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- compile-time generation of tool argument structures and definitions
|
||||||
|
- small macro conveniences around tool descriptions and schemas
|
||||||
|
|
||||||
|
Does not own:
|
||||||
|
|
||||||
|
- runtime permission decisions
|
||||||
|
- filesystem scope checks
|
||||||
|
- tool execution policy
|
||||||
|
- model/tool-loop orchestration
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
Macros reduce boilerplate, but they must not imply capability. A generated tool definition is still subject to host permissions, application scope, and runtime policy.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [`../../docs/design/tool-permissions-scope.md`](../../docs/design/tool-permissions-scope.md)
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
//! llm-engine-macros - Procedural macros for Tool generation
|
||||||
|
//!
|
||||||
|
//! Provides `#[tool_registry]` and `#[tool]` macros to
|
||||||
|
//! automatically generate `Tool` trait implementations from user-defined methods.
|
||||||
|
|
||||||
|
use proc_macro::TokenStream;
|
||||||
|
use quote::{format_ident, quote};
|
||||||
|
use syn::{
|
||||||
|
Attribute, FnArg, ImplItem, ItemImpl, Lit, Meta, Pat, ReturnType, Type, parse_macro_input,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Macro applied to an `impl` block that generates tools from methods marked with `#[tool]`.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```ignore
|
||||||
|
/// #[tool_registry]
|
||||||
|
/// impl MyApp {
|
||||||
|
/// /// Get user information
|
||||||
|
/// /// Retrieves a user from the database by their ID.
|
||||||
|
/// #[tool]
|
||||||
|
/// async fn get_user(&self, user_id: String) -> Result<User, Error> { ... }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// This generates:
|
||||||
|
/// - `GetUserArgs` struct (for arguments)
|
||||||
|
/// - `Tool_get_user` struct (Tool wrapper)
|
||||||
|
/// - `impl Tool for Tool_get_user`
|
||||||
|
/// - `impl MyApp { fn get_user_tool(&self) -> Tool_get_user }`
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn tool_registry(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
let mut impl_block = parse_macro_input!(item as ItemImpl);
|
||||||
|
let self_ty = &impl_block.self_ty;
|
||||||
|
|
||||||
|
let mut generated_items = Vec::new();
|
||||||
|
|
||||||
|
for item in &mut impl_block.items {
|
||||||
|
if let ImplItem::Fn(method) = item {
|
||||||
|
// Look for #[tool] attribute
|
||||||
|
let mut is_tool = false;
|
||||||
|
|
||||||
|
// Iterate through attributes to check for tool and remove it
|
||||||
|
method.attrs.retain(|attr| {
|
||||||
|
if attr.path().is_ident("tool") {
|
||||||
|
is_tool = true;
|
||||||
|
false // Remove the attribute
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if is_tool {
|
||||||
|
let tool_impl = generate_tool_impl(self_ty, method);
|
||||||
|
generated_items.push(tool_impl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let expanded = quote! {
|
||||||
|
#impl_block
|
||||||
|
|
||||||
|
#(#generated_items)*
|
||||||
|
};
|
||||||
|
|
||||||
|
TokenStream::from(expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract description from doc comments
|
||||||
|
fn extract_doc_comment(attrs: &[Attribute]) -> String {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
for attr in attrs {
|
||||||
|
if attr.path().is_ident("doc") {
|
||||||
|
if let Meta::NameValue(meta) = &attr.meta {
|
||||||
|
if let syn::Expr::Lit(expr_lit) = &meta.value {
|
||||||
|
if let Lit::Str(lit_str) = &expr_lit.lit {
|
||||||
|
let line = lit_str.value();
|
||||||
|
// Remove only the leading space (after ///)
|
||||||
|
let trimmed = line.strip_prefix(' ').unwrap_or(&line);
|
||||||
|
lines.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract description from #[description = "..."] attribute
|
||||||
|
fn extract_description_attr(attrs: &[syn::Attribute]) -> Option<String> {
|
||||||
|
for attr in attrs {
|
||||||
|
if attr.path().is_ident("description")
|
||||||
|
&& let Meta::NameValue(meta) = &attr.meta
|
||||||
|
&& let syn::Expr::Lit(expr_lit) = &meta.value
|
||||||
|
&& let Lit::Str(lit_str) = &expr_lit.lit
|
||||||
|
{
|
||||||
|
return Some(lit_str.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_tool_execution_context_type(ty: &Type) -> bool {
|
||||||
|
let Type::Path(path) = ty else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
path.path
|
||||||
|
.segments
|
||||||
|
.last()
|
||||||
|
.is_some_and(|segment| segment.ident == "ToolExecutionContext")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate Tool implementation from a method
|
||||||
|
fn generate_tool_impl(self_ty: &Type, method: &syn::ImplItemFn) -> proc_macro2::TokenStream {
|
||||||
|
let sig = &method.sig;
|
||||||
|
let method_name = &sig.ident;
|
||||||
|
let tool_name = method_name.to_string();
|
||||||
|
|
||||||
|
// Generate struct names (convert to PascalCase)
|
||||||
|
let pascal_name = to_pascal_case(&method_name.to_string());
|
||||||
|
let tool_struct_name = format_ident!("Tool{}", pascal_name);
|
||||||
|
let args_struct_name = format_ident!("{}Args", pascal_name);
|
||||||
|
let definition_name = format_ident!("{}_definition", method_name);
|
||||||
|
|
||||||
|
// Get description from doc comments
|
||||||
|
let description = extract_doc_comment(&method.attrs);
|
||||||
|
let description = if description.is_empty() {
|
||||||
|
format!("Tool: {}", tool_name)
|
||||||
|
} else {
|
||||||
|
description
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse method arguments (excluding self). A parameter typed as
|
||||||
|
// ToolExecutionContext is supplied from the execution context and is not
|
||||||
|
// exposed in the JSON input schema.
|
||||||
|
let method_args: Vec<_> = sig
|
||||||
|
.inputs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|arg| {
|
||||||
|
if let FnArg::Typed(pat_type) = arg {
|
||||||
|
Some(pat_type)
|
||||||
|
} else {
|
||||||
|
None // Exclude self
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let json_args: Vec<_> = method_args
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|pat_type| !is_tool_execution_context_type(pat_type.ty.as_ref()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Generate argument struct fields
|
||||||
|
let arg_fields: Vec<_> = json_args
|
||||||
|
.iter()
|
||||||
|
.map(|pat_type| {
|
||||||
|
let pat = &pat_type.pat;
|
||||||
|
let ty = &pat_type.ty;
|
||||||
|
let desc = extract_description_attr(&pat_type.attrs);
|
||||||
|
|
||||||
|
// Extract identifier from pattern
|
||||||
|
let field_name = if let Pat::Ident(pat_ident) = pat.as_ref() {
|
||||||
|
&pat_ident.ident
|
||||||
|
} else {
|
||||||
|
panic!("Only simple identifiers are supported for tool arguments");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert #[description] to schemars doc if present
|
||||||
|
if let Some(desc_str) = desc {
|
||||||
|
quote! {
|
||||||
|
#[schemars(description = #desc_str)]
|
||||||
|
pub #field_name: #ty
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote! {
|
||||||
|
pub #field_name: #ty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Code to expand method arguments in execute
|
||||||
|
let call_args: Vec<_> = method_args
|
||||||
|
.iter()
|
||||||
|
.map(|pat_type| {
|
||||||
|
if is_tool_execution_context_type(pat_type.ty.as_ref()) {
|
||||||
|
quote! { ctx.clone() }
|
||||||
|
} else if let Pat::Ident(pat_ident) = pat_type.pat.as_ref() {
|
||||||
|
let ident = &pat_ident.ident;
|
||||||
|
quote! { args.#ident }
|
||||||
|
} else {
|
||||||
|
panic!("Only simple identifiers are supported");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let method_call = if call_args.is_empty() {
|
||||||
|
quote! { self.ctx.#method_name() }
|
||||||
|
} else {
|
||||||
|
quote! { self.ctx.#method_name(#(#call_args),*) }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if method is async
|
||||||
|
let is_async = sig.asyncness.is_some();
|
||||||
|
|
||||||
|
// Parse return type and determine if Result
|
||||||
|
let awaiter = if is_async {
|
||||||
|
quote! { .await }
|
||||||
|
} else {
|
||||||
|
quote! {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine if return type is Result
|
||||||
|
let result_handling = if is_result_type(&sig.output) {
|
||||||
|
quote! {
|
||||||
|
match result {
|
||||||
|
Ok(val) => Ok(format!("{:?}", val).into()),
|
||||||
|
Err(e) => Err(::llm_engine::tool::ToolError::ExecutionFailed(format!("{}", e))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote! {
|
||||||
|
Ok(format!("{:?}", result).into())
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create empty Args struct if no arguments
|
||||||
|
let args_struct_def = if arg_fields.is_empty() {
|
||||||
|
quote! {
|
||||||
|
#[derive(serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
struct #args_struct_name {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote! {
|
||||||
|
#[derive(serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
struct #args_struct_name {
|
||||||
|
#(#arg_fields),*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute body handling for no arguments case
|
||||||
|
let execute_body = if json_args.is_empty() {
|
||||||
|
quote! {
|
||||||
|
// Allow empty JSON object even with no JSON arguments
|
||||||
|
let _: #args_struct_name = serde_json::from_str(input_json)
|
||||||
|
.unwrap_or(#args_struct_name {});
|
||||||
|
|
||||||
|
let result = #method_call #awaiter;
|
||||||
|
#result_handling
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
quote! {
|
||||||
|
let args: #args_struct_name = serde_json::from_str(input_json)
|
||||||
|
.map_err(|e| ::llm_engine::tool::ToolError::InvalidArgument(e.to_string()))?;
|
||||||
|
|
||||||
|
let result = #method_call #awaiter;
|
||||||
|
#result_handling
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
#args_struct_def
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct #tool_struct_name {
|
||||||
|
ctx: #self_ty,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ::llm_engine::tool::Tool for #tool_struct_name {
|
||||||
|
async fn execute(&self, input_json: &str, ctx: ::llm_engine::tool::ToolExecutionContext) -> Result<::llm_engine::tool::ToolOutput, ::llm_engine::tool::ToolError> {
|
||||||
|
let _ = &ctx;
|
||||||
|
#execute_body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl #self_ty {
|
||||||
|
/// Get ToolDefinition (for registering with Engine)
|
||||||
|
pub fn #definition_name(&self) -> ::llm_engine::tool::ToolDefinition {
|
||||||
|
let ctx = self.clone();
|
||||||
|
::std::sync::Arc::new(move || {
|
||||||
|
let schema = schemars::schema_for!(#args_struct_name);
|
||||||
|
let meta = ::llm_engine::tool::ToolMeta::new(#tool_name)
|
||||||
|
.description(#description)
|
||||||
|
.input_schema(serde_json::to_value(schema).unwrap_or(serde_json::json!({})));
|
||||||
|
let tool: ::std::sync::Arc<dyn ::llm_engine::tool::Tool> =
|
||||||
|
::std::sync::Arc::new(#tool_struct_name { ctx: ctx.clone() });
|
||||||
|
(meta, tool)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine if return type is Result
|
||||||
|
fn is_result_type(return_type: &ReturnType) -> bool {
|
||||||
|
match return_type {
|
||||||
|
ReturnType::Default => false,
|
||||||
|
ReturnType::Type(_, ty) => {
|
||||||
|
// For Type::Path, check if last segment is "Result"
|
||||||
|
if let Type::Path(type_path) = ty.as_ref() {
|
||||||
|
if let Some(segment) = type_path.path.segments.last() {
|
||||||
|
return segment.ident == "Result";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert snake_case to PascalCase
|
||||||
|
fn to_pascal_case(s: &str) -> String {
|
||||||
|
s.split('_')
|
||||||
|
.map(|part| {
|
||||||
|
let mut chars = part.chars();
|
||||||
|
match chars.next() {
|
||||||
|
None => String::new(),
|
||||||
|
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker attribute. Does nothing here as it's processed by `tool_registry`.
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn tool(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
item
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker for argument attributes. Interpreted by `tool_registry` during parsing.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```ignore
|
||||||
|
/// #[tool]
|
||||||
|
/// async fn get_user(
|
||||||
|
/// &self,
|
||||||
|
/// #[description = "The ID of the user to retrieve"] user_id: String
|
||||||
|
/// ) -> Result<User, Error> { ... }
|
||||||
|
/// ```
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn description(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||||
|
item
|
||||||
|
}
|
||||||
@@ -1,18 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agen"
|
name = "llm-engine"
|
||||||
description = "Provider-neutral orchestration for tool-using LLM applications"
|
description = "A library for building autonomous LLM-powered systems"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version = "1.86"
|
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
readme = "README.md"
|
|
||||||
repository = "https://gitea.hareworks.net/Hare/yoi"
|
|
||||||
homepage = "https://gitea.hareworks.net/Hare/yoi"
|
|
||||||
documentation = "https://docs.rs/agen"
|
|
||||||
keywords = ["llm", "agent", "tools", "streaming", "orchestration"]
|
|
||||||
categories = ["api-bindings", "asynchronous"]
|
|
||||||
include = ["src/**", "tests/**", "examples/*.rs", "docs/**", "README.md", "LICENSE"]
|
|
||||||
autoexamples = false
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
@@ -21,7 +12,6 @@ codex = ["dep:chrono"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
schemars = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
@@ -33,23 +23,13 @@ eventsource-stream = "0.2"
|
|||||||
zstd = "0.13"
|
zstd = "0.13"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
|
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"], optional = true }
|
||||||
agen-macros = { workspace = true }
|
llm-engine-macros = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
|
schemars = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
trybuild = "1.0.116"
|
trybuild = "1.0.116"
|
||||||
wiremock = "0.6.5"
|
wiremock = "0.6.5"
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "engine_cancel_demo"
|
|
||||||
path = "examples/engine_cancel_demo.rs"
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "engine_cli"
|
|
||||||
path = "examples/engine_cli.rs"
|
|
||||||
|
|
||||||
[package.metadata.docs.rs]
|
|
||||||
all-features = true
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# llm-engine
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
`llm-engine` owns provider-independent model turn orchestration over committed history, tools, callbacks, retries, continuation, pruning, and compaction boundaries.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
|
||||||
|
- Engine history mutation and append contracts
|
||||||
|
- tool-call loop semantics
|
||||||
|
- pre-stream retry and stream-started continuation policy
|
||||||
|
- pruning/compaction coordination from the Engine perspective
|
||||||
|
- provider-neutral events/callbacks/interceptors
|
||||||
|
|
||||||
|
Does not own:
|
||||||
|
|
||||||
|
- Host application names, sockets, process lifecycle, or scope delegation
|
||||||
|
- Product CLI shape
|
||||||
|
- Provider catalog and secret resolution
|
||||||
|
- Durable application state outside engine history
|
||||||
|
|
||||||
|
## Design notes
|
||||||
|
|
||||||
|
The Engine is where turn lifecycle belongs because it sees history, in-flight usage, partial output, and tool-call state. It should not receive context-only volatile facts; model-affecting inputs must first be appended to history.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [`../../docs/design/context-history.md`](../../docs/design/context-history.md)
|
||||||
|
- [`../../docs/design/compaction.md`](../../docs/design/compaction.md)
|
||||||
|
- [`../../docs/design/provider-model-boundary.md`](../../docs/design/provider-model-boundary.md)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# llm-engine アーキテクチャ
|
||||||
|
|
||||||
|
## 概要
|
||||||
|
|
||||||
|
llm-engineは3層構成でLLMとのインタラクションを管理する。
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Engine (オーケストレーション) │
|
||||||
|
│ ターンループ / フック / ツール実行 │
|
||||||
|
│ Type-state: Mutable ↔ CacheLocked │
|
||||||
|
└───────────┬─────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────▼─────────────────────────────┐
|
||||||
|
│ Timeline (イベント処理) │
|
||||||
|
│ Handler dispatch / Block collectors │
|
||||||
|
└───────────┬─────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────▼─────────────────────────────┐
|
||||||
|
│ LLM Client (プロトコル) │
|
||||||
|
│ Provider (HTTP) / Scheme (変換) │
|
||||||
|
│ Anthropic / OpenAI / Gemini / Ollama │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## モジュール構成
|
||||||
|
|
||||||
|
| モジュール | 責務 | 要件 |
|
||||||
|
|---|---|---|
|
||||||
|
| `engine` | ターンループ、フック統合、ツール実行、Pause/Resume | R1, R4 |
|
||||||
|
| `state` | Type-state (Mutable/CacheLocked) | R2 |
|
||||||
|
| `hook` | Hook trait、10フックポイント | R3, R4 |
|
||||||
|
| `tool` / `tool_server` | ツール定義・登録・実行 | R3 |
|
||||||
|
| `timeline` | イベントストリーム処理、Handler dispatch | — |
|
||||||
|
| `handler` | Handler/Kind trait、ブロック別ハンドラ | — |
|
||||||
|
| `callback` | クロージャベースイベント購読(`on_text_block`, `on_usage` 等) | — |
|
||||||
|
| `llm_client` | LLMプロバイダへのHTTPリクエスト/ストリーミング | — |
|
||||||
|
| `llm_client/scheme` | プロバイダ固有ワイヤーフォーマット変換 | — |
|
||||||
|
| `llm_client/providers` | Anthropic, OpenAI, Gemini, Ollama実装 | — |
|
||||||
|
|
||||||
|
## データフロー
|
||||||
|
|
||||||
|
### リクエスト(送信)
|
||||||
|
```
|
||||||
|
Engine.history (Vec<Item>)
|
||||||
|
→ build_request() → Request { items, tools, config }
|
||||||
|
→ Scheme.build_request() → プロバイダ固有JSON
|
||||||
|
→ Provider.stream() → HTTP POST
|
||||||
|
```
|
||||||
|
|
||||||
|
### レスポンス(受信)
|
||||||
|
```
|
||||||
|
HTTP SSE bytes
|
||||||
|
→ Provider → SSE events
|
||||||
|
→ Scheme.parse_event() → Event (統一型)
|
||||||
|
→ Timeline.dispatch() → Handler.on_event()
|
||||||
|
→ TextBlockCollector / ToolCallCollector
|
||||||
|
→ Engine: 履歴に追加、ツール実行判定
|
||||||
|
```
|
||||||
|
|
||||||
|
## 内部型
|
||||||
|
|
||||||
|
### Item (会話履歴の単位)
|
||||||
|
- `Item::Message` — テキストメッセージ (user/assistant)
|
||||||
|
- `Item::ToolCall` — ツール呼び出し
|
||||||
|
- `Item::ToolResult` — ツール実行結果
|
||||||
|
- `Item::Reasoning` — 思考 (Extended Thinking)
|
||||||
|
|
||||||
|
### Event (ストリーミングイベント)
|
||||||
|
- Meta: `Ping`, `Usage`, `Status`, `Error`
|
||||||
|
- Block: `BlockStart` → `BlockDelta`* → `BlockStop` / `BlockAbort`
|
||||||
|
|
||||||
|
単一の `Event` 型が全層で共有される(`llm_client::event` で定義、他層はre-export)。
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# llm-engine 要件
|
||||||
|
|
||||||
|
## 前提
|
||||||
|
|
||||||
|
a. userメッセージを追加しなくてもagentの途中ママ投げれば、AIはそれを自身の生成途中と認識して普通に継続する
|
||||||
|
b. KVキャッシュは速度・効率の面で有利で、コンテキストの事後改変はキャッシュヒット率を大幅に下げる
|
||||||
|
c. ツール・フックの基本的なスキーマ自動化を提供する
|
||||||
|
|
||||||
|
## 要件
|
||||||
|
|
||||||
|
### R1: Resume/Pause
|
||||||
|
|
||||||
|
メッセージの送信と生成のResume、一時停止/再開。
|
||||||
|
|
||||||
|
- `Engine::run()` でターンを開始
|
||||||
|
- フックから `Pause` を返してターンを一時停止
|
||||||
|
- `Engine::resume()` でユーザーメッセージを追加せず継続
|
||||||
|
- AIは中断を認識せず、継続として処理する
|
||||||
|
|
||||||
|
**実装**: `engine.rs` — `resume()`, `get_pending_tool_calls()`, `EngineResult::Paused`
|
||||||
|
|
||||||
|
### R2: 暗黙的KVキャッシュ保証
|
||||||
|
|
||||||
|
キャッシュを破壊しうる操作を明示的にブロックせずとも、いつの間にかキャッシュ破壊してた状態にはしたくない。
|
||||||
|
|
||||||
|
- Type-stateパターン(`Mutable` / `CacheLocked`)でコンパイル時に保証
|
||||||
|
- `Engine::lock()` でCacheLocked状態に遷移
|
||||||
|
- CacheLocked状態ではシステムプロンプトや履歴の変更APIが型レベルで利用不可
|
||||||
|
- `locked_prefix_len` でプレフィックスの不変性を追跡
|
||||||
|
|
||||||
|
**実装**: `state.rs` (sealed trait), `engine.rs` (state-specific impl blocks)
|
||||||
|
|
||||||
|
### R3: ツール・フックスキーマ自動化
|
||||||
|
|
||||||
|
- `#[tool]` マクロでツール定義を自動生成
|
||||||
|
- `#[tool_registry]` マクロでツールサーバーを自動構成
|
||||||
|
- `Hook` traitで10種のフックポイント
|
||||||
|
|
||||||
|
**実装**: `llm-engine-macros/`, `tool.rs`, `tool_server.rs`, `hook.rs`
|
||||||
|
|
||||||
|
### R4: フックは上層の関心事
|
||||||
|
|
||||||
|
フックはLLMクライアント層ではなく、Engine(オーケストレーション)層に配置する。
|
||||||
|
|
||||||
|
- LLMクライアント (`llm_client/`) はストリーミングとプロトコルのみ
|
||||||
|
- Engine層でフック実行、ツール統合、Pause/Resume制御
|
||||||
|
|
||||||
|
**実装**: `engine.rs` (hook integration), `hook.rs` (trait definitions)
|
||||||
+12
-12
@@ -2,9 +2,9 @@
|
|||||||
//!
|
//!
|
||||||
//! Example of cancelling from another thread during streaming
|
//! Example of cancelling from another thread during streaming
|
||||||
|
|
||||||
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
use llm_engine::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
||||||
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
use llm_engine::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||||
use agen::{Engine, EngineRunExit, RunInterruptionReason};
|
use llm_engine::{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!");
|
||||||
@@ -38,9 +38,10 @@ use async_trait::async_trait;
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use agen::{
|
use clap::{Parser, ValueEnum};
|
||||||
Engine, EngineRunExit, RunInterruptionReason,
|
use llm_engine::{
|
||||||
interceptor::{Interceptor, InterceptorResult, PostToolAction, ToolResultInfo},
|
Engine,
|
||||||
|
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
||||||
llm_client::{
|
llm_client::{
|
||||||
LlmClient,
|
LlmClient,
|
||||||
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
|
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
|
||||||
@@ -50,9 +51,12 @@ use agen::{
|
|||||||
transport::{HttpTransport, ResolvedAuth},
|
transport::{HttpTransport, ResolvedAuth},
|
||||||
},
|
},
|
||||||
timeline::{Handler, TextBlockEvent, TextBlockKind, ToolUseBlockEvent, ToolUseBlockKind},
|
timeline::{Handler, TextBlockEvent, TextBlockKind, ToolUseBlockEvent, ToolUseBlockKind},
|
||||||
tool_registry,
|
|
||||||
};
|
};
|
||||||
use clap::{Parser, ValueEnum};
|
use llm_engine_macros::tool_registry;
|
||||||
|
|
||||||
|
// Required imports for macro expansion
|
||||||
|
use schemars;
|
||||||
|
use serde;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Provider Definition
|
// Provider Definition
|
||||||
@@ -280,10 +284,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 +298,7 @@ impl Interceptor for ToolResultPrinterPolicy {
|
|||||||
println!(" Result ({}): ✅ {}", name, info.result.summary);
|
println!(" Result ({}): ✅ {}", name, info.result.summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(PostToolAction::Continue)
|
PostToolAction::Continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +455,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 +480,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 +504,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 +529,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
+3
-3
@@ -19,11 +19,11 @@
|
|||||||
mod recorder;
|
mod recorder;
|
||||||
mod scenarios;
|
mod scenarios;
|
||||||
|
|
||||||
use agen::llm_client::scheme::{
|
use clap::{Parser, ValueEnum};
|
||||||
|
use llm_engine::llm_client::scheme::{
|
||||||
Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme,
|
Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme,
|
||||||
};
|
};
|
||||||
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
|
use llm_engine::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||||
use clap::{Parser, ValueEnum};
|
|
||||||
|
|
||||||
fn make_transport<S: Scheme>(scheme: S, model: &str, auth: ResolvedAuth) -> HttpTransport<S> {
|
fn make_transport<S: Scheme>(scheme: S, model: &str, auth: ResolvedAuth) -> HttpTransport<S> {
|
||||||
let cap = scheme.default_capability();
|
let cap = scheme.default_capability();
|
||||||
+1
-1
@@ -7,8 +7,8 @@ use std::io::{BufWriter, Write};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use agen::llm_client::{LlmClient, Request};
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
use llm_engine::llm_client::{LlmClient, Request};
|
||||||
|
|
||||||
/// Recorded event
|
/// Recorded event
|
||||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Defines requests and output file names for each scenario
|
//! Defines requests and output file names for each scenario
|
||||||
|
|
||||||
use agen::llm_client::{Request, ToolDefinition};
|
use llm_engine::llm_client::{Request, ToolDefinition};
|
||||||
|
|
||||||
/// Test scenario
|
/// Test scenario
|
||||||
pub struct TestScenario {
|
pub struct TestScenario {
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ pub trait Kind {
|
|||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use agen::timeline::{Handler, TextBlockEvent, TextBlockKind};
|
/// use llm_engine::timeline::{Handler, TextBlockEvent, TextBlockKind};
|
||||||
///
|
///
|
||||||
/// struct TextCollector {
|
/// struct TextCollector {
|
||||||
/// texts: Vec<String>,
|
/// texts: Vec<String>,
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
//! Interceptor - control flow delegation for the Engine execution loop
|
||||||
|
//!
|
||||||
|
//! Defines the [`Interceptor`] trait that callers implement to inject
|
||||||
|
//! orchestration decisions (approval, skip, pause, abort) into the Engine's
|
||||||
|
//! turn loop without the Engine knowing about host-application concepts.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::Item;
|
||||||
|
use crate::tool::{Tool, ToolCall, ToolExecutionContext, ToolMeta, ToolResult};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Action Enums
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Action after prompt submission.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum PromptAction {
|
||||||
|
/// Proceed normally.
|
||||||
|
Continue,
|
||||||
|
/// Cancel with a reason.
|
||||||
|
Cancel(String),
|
||||||
|
/// Proceed, and append these items to history right after the user
|
||||||
|
/// message. Mirrors [`TurnEndAction::ContinueWithMessages`] for the
|
||||||
|
/// submit edge: lets the upper layer attach resolver-produced
|
||||||
|
/// system messages (e.g. `@<path>` file content) so they sit
|
||||||
|
/// adjacent to the user message that referenced them.
|
||||||
|
ContinueWith(Vec<Item>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action before an LLM request.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum PreRequestAction {
|
||||||
|
/// Proceed normally.
|
||||||
|
Continue,
|
||||||
|
/// Proceed after appending these items to durable engine history.
|
||||||
|
///
|
||||||
|
/// This is for upper-layer budget/status nudges that the model may react
|
||||||
|
/// to: the items are committed before the request so later turns can see
|
||||||
|
/// why the engine changed course.
|
||||||
|
ContinueWith(Vec<Item>),
|
||||||
|
/// Yield after appending these items to durable engine history.
|
||||||
|
///
|
||||||
|
/// This is for host-mediated pre-request appends that must be visible to
|
||||||
|
/// usage accounting and compaction checks before the current LLM request is
|
||||||
|
/// allowed to proceed.
|
||||||
|
YieldWith(Vec<Item>),
|
||||||
|
/// Cancel with a reason (treated as an error).
|
||||||
|
Cancel(String),
|
||||||
|
/// Yield control to the caller for external processing.
|
||||||
|
///
|
||||||
|
/// The Engine exits the turn loop cleanly with `EngineResult::Yielded`.
|
||||||
|
/// The caller is expected to resume execution later.
|
||||||
|
Yield,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action before a tool call.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum PreToolAction {
|
||||||
|
/// Proceed with execution.
|
||||||
|
Continue,
|
||||||
|
/// Skip this tool call (do not execute).
|
||||||
|
Skip,
|
||||||
|
/// Do not execute the tool call; commit this synthetic result instead.
|
||||||
|
///
|
||||||
|
/// This preserves provider-visible `tool_use` / `tool_result` pairing
|
||||||
|
/// without aborting the whole turn.
|
||||||
|
SyntheticResult(ToolResult),
|
||||||
|
/// Abort the entire run.
|
||||||
|
Abort(String),
|
||||||
|
/// Pause execution (can be resumed later).
|
||||||
|
Pause,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action after a tool call.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum PostToolAction {
|
||||||
|
/// Proceed normally.
|
||||||
|
Continue,
|
||||||
|
/// Abort the entire run.
|
||||||
|
Abort(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Action at the end of a turn (when LLM produces no tool calls).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TurnEndAction {
|
||||||
|
/// Turn is finished, return to caller.
|
||||||
|
Finish,
|
||||||
|
/// Continue with additional messages injected into history.
|
||||||
|
ContinueWithMessages(Vec<Item>),
|
||||||
|
/// Pause execution (can be resumed later).
|
||||||
|
Pause,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Context Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Context for pre-tool-call decisions.
|
||||||
|
pub struct ToolCallInfo {
|
||||||
|
/// Tool call information (modifiable).
|
||||||
|
pub call: ToolCall,
|
||||||
|
/// Tool meta information.
|
||||||
|
pub meta: ToolMeta,
|
||||||
|
/// Tool instance (for state access).
|
||||||
|
pub tool: Arc<dyn Tool>,
|
||||||
|
/// Response-local execution context for this call.
|
||||||
|
pub context: ToolExecutionContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Context for post-tool-call decisions.
|
||||||
|
pub struct ToolResultInfo {
|
||||||
|
/// Original tool call.
|
||||||
|
pub call: ToolCall,
|
||||||
|
/// Tool execution result (modifiable).
|
||||||
|
pub result: ToolResult,
|
||||||
|
/// Tool meta information.
|
||||||
|
pub meta: ToolMeta,
|
||||||
|
/// Tool instance (for state access).
|
||||||
|
pub tool: Arc<dyn Tool>,
|
||||||
|
/// Response-local execution context for this call.
|
||||||
|
pub context: ToolExecutionContext,
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Interceptor Trait
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Intercepts the Engine execution loop at key decision points.
|
||||||
|
///
|
||||||
|
/// All methods have default implementations that let the Engine
|
||||||
|
/// proceed without intervention. Callers provide richer implementations for
|
||||||
|
/// approval flows, permission checks, etc.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Interceptor: Send + Sync {
|
||||||
|
/// Called after receiving user input, before adding to history.
|
||||||
|
async fn on_prompt_submit(&self, _item: &mut Item) -> PromptAction {
|
||||||
|
PromptAction::Continue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Items that should be **committed to `engine.history`** just
|
||||||
|
/// before the next LLM request. Returned items are `extend`ed into
|
||||||
|
/// the persistent history (and therefore picked up by the per-turn
|
||||||
|
/// clone that backs the LLM request, plus the usual
|
||||||
|
/// history-persistence path).
|
||||||
|
///
|
||||||
|
/// Use this for inputs that arrive from outside the LLM and need
|
||||||
|
/// to be reflected in the on-disk history — notifications,
|
||||||
|
/// external events, system reminders. Do **not** use
|
||||||
|
/// [`Self::pre_llm_request`] for that purpose: it mutates a
|
||||||
|
/// per-request clone, so any committed assistant response that
|
||||||
|
/// reacts to the injection would have no visible trigger on the
|
||||||
|
/// next turn (or after resume / compaction).
|
||||||
|
///
|
||||||
|
/// `pre_llm_request` remains the right place for purely
|
||||||
|
/// reproducible per-request transformations (pruning, content
|
||||||
|
/// trimming, cache anchors) that depend only on the existing
|
||||||
|
/// history.
|
||||||
|
async fn pending_history_appends(&self) -> Result<Vec<Item>, String> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called before each LLM request. The context starts as a clone
|
||||||
|
/// of `engine.history` (after `pending_history_appends` and the
|
||||||
|
/// Engine's own prune projection have been applied).
|
||||||
|
///
|
||||||
|
/// Direct mutations to `context` remain request-local and are not persisted.
|
||||||
|
/// If an interceptor derives a human/model-visible nudge from the current
|
||||||
|
/// request context, return [`PreRequestAction::ContinueWith`] so the Engine
|
||||||
|
/// commits it to history before the request is sent.
|
||||||
|
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
|
||||||
|
PreRequestAction::Continue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called before each tool is executed.
|
||||||
|
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
|
||||||
|
PreToolAction::Continue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called after each tool completes.
|
||||||
|
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
|
||||||
|
PostToolAction::Continue
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when a turn ends with no tool calls.
|
||||||
|
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
|
||||||
|
TurnEndAction::Finish
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when execution is interrupted (abort or cancel).
|
||||||
|
async fn on_abort(&self, _reason: &str) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default interceptor: no intervention. Engine proceeds through the loop
|
||||||
|
/// without any external control flow decisions.
|
||||||
|
pub(crate) struct DefaultInterceptor;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Interceptor for DefaultInterceptor {}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//! llm-engine - LLM Engine Library
|
||||||
|
//!
|
||||||
|
//! Provides components for managing interactions with LLMs.
|
||||||
|
//!
|
||||||
|
//! # Main Components
|
||||||
|
//!
|
||||||
|
//! - [`Engine`] - Central component for managing LLM interactions
|
||||||
|
//! - [`tool::Tool`] - Tools that can be invoked by the LLM
|
||||||
|
//! - [`interceptor::Interceptor`] - Control-flow delegation for the execution loop
|
||||||
|
//! - Closure-based event callbacks via `Engine::on_text_block()`, `on_tool_use_block()`, etc.
|
||||||
|
//!
|
||||||
|
//! # Quick Start
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! use llm_engine::{Engine, Item};
|
||||||
|
//!
|
||||||
|
//! // Create a Engine
|
||||||
|
//! let mut engine = Engine::new(client)
|
||||||
|
//! .system_prompt("You are a helpful assistant.");
|
||||||
|
//!
|
||||||
|
//! // Register tools (optional)
|
||||||
|
//! // engine.register_tool(my_tool_definition)?;
|
||||||
|
//!
|
||||||
|
//! // Run the interaction
|
||||||
|
//! let history = engine.run("Hello!").await?;
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! # Cache Protection
|
||||||
|
//!
|
||||||
|
//! `run()` automatically locks the cache. To edit state between turns,
|
||||||
|
//! call `unlock_cache()` first; the next `run()` re-locks automatically.
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! engine.run("user input").await?;
|
||||||
|
//! engine.unlock_cache();
|
||||||
|
//! engine.set_system_prompt("new prompt");
|
||||||
|
//! engine.run("next input").await?;
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
mod engine;
|
||||||
|
mod handler;
|
||||||
|
mod message;
|
||||||
|
|
||||||
|
pub(crate) mod callback;
|
||||||
|
pub mod event;
|
||||||
|
pub mod interceptor;
|
||||||
|
pub mod llm_client;
|
||||||
|
pub mod providers;
|
||||||
|
pub mod prune;
|
||||||
|
pub mod state;
|
||||||
|
pub mod timeline;
|
||||||
|
pub mod token_counter;
|
||||||
|
pub mod tool;
|
||||||
|
pub mod tool_server;
|
||||||
|
pub mod usage_record;
|
||||||
|
|
||||||
|
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
|
||||||
|
pub use engine::{
|
||||||
|
Engine, EngineConfig, EngineError, EngineResult, EngineRunOutput, LlmRetryNotice,
|
||||||
|
ToolRegistryError,
|
||||||
|
};
|
||||||
|
pub use handler::ToolUseBlockStart;
|
||||||
|
pub use interceptor::Interceptor;
|
||||||
|
pub use message::{ContentPart, Item, Message, Role};
|
||||||
|
pub use tool::{ToolCall, ToolExecutionContext, ToolOutputLimits, ToolResult};
|
||||||
|
pub use usage_record::UsageRecord;
|
||||||
@@ -30,7 +30,7 @@ pub enum AuthRequirement {
|
|||||||
/// リクエスト毎に認証ヘッダを動的に組み立てるプロバイダ。
|
/// リクエスト毎に認証ヘッダを動的に組み立てるプロバイダ。
|
||||||
///
|
///
|
||||||
/// access token が refresh で更新されたり、複数ヘッダを同時に注入する
|
/// access token が refresh で更新されたり、複数ヘッダを同時に注入する
|
||||||
/// 必要があるケースで使う。実体は呼び出し側に置き、agen は
|
/// 必要があるケースで使う。実体は呼び出し側に置き、llm-engine は
|
||||||
/// trait を知るだけ。
|
/// trait を知るだけ。
|
||||||
///
|
///
|
||||||
/// 返したヘッダはそのまま `HeaderMap` に挿入される。`Authorization`
|
/// 返したヘッダはそのまま `HeaderMap` に挿入される。`Authorization`
|
||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
/// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。
|
/// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。
|
||||||
///
|
///
|
||||||
/// `Default` は agen 全体の固定値を返す。呼び出し側からの上書きが
|
/// `Default` は llm-engine 全体の固定値を返す。呼び出し側からの上書きが
|
||||||
/// 必要になったら拡張する。
|
/// 必要になったら拡張する。
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RetryPolicy {
|
pub struct RetryPolicy {
|
||||||
+2
-2
@@ -4,8 +4,8 @@
|
|||||||
//! function_call item が first-class で、SSE イベントも `response.*` 名前空間で
|
//! function_call item が first-class で、SSE イベントも `response.*` 名前空間で
|
||||||
//! 流れる。
|
//! 流れる。
|
||||||
//!
|
//!
|
||||||
//! - リクエスト JSON 生成: `request`
|
//! - リクエスト JSON 生成: [`request`]
|
||||||
//! - SSE イベントパース → [`Event`](crate::llm_client::event::Event) 変換: `events`
|
//! - SSE イベントパース → [`Event`](crate::llm_client::event::Event) 変換: [`events`]
|
||||||
|
|
||||||
mod capability;
|
mod capability;
|
||||||
mod events;
|
mod events;
|
||||||
+7
-4
@@ -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};
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ impl fmt::Debug for RequestTrace {
|
|||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use agen::Item;
|
/// use llm_engine::Item;
|
||||||
///
|
///
|
||||||
/// let user = Item::user_message("Hello!");
|
/// let user = Item::user_message("Hello!");
|
||||||
/// let assistant = Item::assistant_message("Hi there!");
|
/// let assistant = Item::assistant_message("Hi there!");
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user