Compare commits
30
Commits
32d3f8f75e
...
e6a2da548f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6a2da548f | ||
|
|
e5f0c4168f | ||
|
|
05e8b00bf0 | ||
|
|
0ffaa6c741 | ||
|
|
ddadc830ac | ||
|
|
0fa36395e7 | ||
|
|
606cd5fa31 | ||
|
|
e5f3c20f64 | ||
|
|
e530150e43 | ||
|
|
0f9f06048a | ||
|
|
24f7267d55 | ||
|
|
11f26a1090 | ||
|
|
72cef5ed9e | ||
|
|
b977c4cbad | ||
|
|
5d4deb258a | ||
|
|
f46c82d171 | ||
|
|
42c69f9f6c | ||
|
|
9308f93de7 | ||
|
|
ecd5751a67 | ||
|
|
ec262f0238 | ||
|
|
21cd672f64 | ||
|
|
9dfcddf40b | ||
|
|
81e631e640 | ||
|
|
3412f1c0ed | ||
|
|
48556e1a2c | ||
|
|
06a98fe30c | ||
|
|
4cf110b28e | ||
|
|
df34d43a23 | ||
|
|
e9a6269d9f | ||
|
|
005f6cb498 |
Generated
+37
-5
@@ -1285,6 +1285,22 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-operation"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"grep-matcher",
|
||||
"grep-regex",
|
||||
"grep-searcher",
|
||||
"ignore",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs4"
|
||||
version = "0.13.1"
|
||||
@@ -4494,12 +4510,8 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"filetime",
|
||||
"globset",
|
||||
"grep-matcher",
|
||||
"grep-regex",
|
||||
"grep-searcher",
|
||||
"fs-operation",
|
||||
"html5ever",
|
||||
"ignore",
|
||||
"llm-engine",
|
||||
"manifest",
|
||||
"markup5ever_rcdom",
|
||||
@@ -4514,6 +4526,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"workdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5923,6 +5936,22 @@ dependencies = [
|
||||
"wasmparser 0.248.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "workdir"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fs-operation",
|
||||
"manifest",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "worker"
|
||||
version = "0.1.0"
|
||||
@@ -5964,6 +5993,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"wasmtime",
|
||||
"wat",
|
||||
"workdir",
|
||||
"yoi-plugin-pdk",
|
||||
]
|
||||
|
||||
@@ -5992,6 +6022,7 @@ dependencies = [
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"toml",
|
||||
"tower",
|
||||
"workdir",
|
||||
"worker",
|
||||
]
|
||||
|
||||
@@ -6113,6 +6144,7 @@ dependencies = [
|
||||
"url",
|
||||
"uuid",
|
||||
"webauthn-rs",
|
||||
"workdir",
|
||||
"worker",
|
||||
"worker-runtime",
|
||||
]
|
||||
|
||||
@@ -17,6 +17,8 @@ members = [
|
||||
"crates/session-analytics",
|
||||
"crates/lint-common",
|
||||
"crates/tools",
|
||||
"crates/fs-operation",
|
||||
"crates/workdir",
|
||||
"crates/tui",
|
||||
"crates/memory",
|
||||
"crates/ticket",
|
||||
@@ -41,6 +43,8 @@ default-members = [
|
||||
"crates/session-analytics",
|
||||
"crates/lint-common",
|
||||
"crates/tools",
|
||||
"crates/fs-operation",
|
||||
"crates/workdir",
|
||||
"crates/tui",
|
||||
"crates/memory",
|
||||
"crates/ticket",
|
||||
@@ -73,6 +77,8 @@ session-analytics = { path = "crates/session-analytics" }
|
||||
session-store = { path = "crates/session-store" }
|
||||
secrets = { path = "crates/secrets" }
|
||||
tools = { path = "crates/tools" }
|
||||
fs-operation = { path = "crates/fs-operation" }
|
||||
workdir = { path = "crates/workdir" }
|
||||
tui = { path = "crates/tui" }
|
||||
yoi-workspace-server = { path = "crates/workspace-server" }
|
||||
|
||||
|
||||
@@ -119,13 +119,15 @@ pub struct BackendWorkingDirectorySummary {
|
||||
pub working_directory_id: String,
|
||||
pub repository_id: String,
|
||||
#[serde(default)]
|
||||
pub requested_selector: Option<String>,
|
||||
pub creation_selector: Option<String>,
|
||||
#[serde(default)]
|
||||
pub creation_ref: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current_selector: Option<String>,
|
||||
#[serde(default)]
|
||||
pub current_ref: Option<String>,
|
||||
pub materializer_kind: String,
|
||||
#[serde(default)]
|
||||
pub resolved_commit: Option<String>,
|
||||
#[serde(default)]
|
||||
pub resolved_tree: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cleanup_target: Option<BackendWorkingDirectoryCleanupTarget>,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "fs-operation"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
globset = "0.4.18"
|
||||
grep-matcher = "0.1.8"
|
||||
grep-regex = "0.1.14"
|
||||
grep-searcher = "0.1.16"
|
||||
ignore = "0.4.25"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
sha2 = "0.10.9"
|
||||
tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use globset::Glob;
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, direct_symlink};
|
||||
|
||||
/// Execute a bounded glob entirely inside the provider process.
|
||||
pub fn run_glob(
|
||||
root: &Path,
|
||||
base: &Path,
|
||||
request: GlobRequest,
|
||||
access: &dyn FsAccessPolicy,
|
||||
) -> Result<GlobResult, FsError> {
|
||||
if !root.is_absolute() {
|
||||
return Err(FsError::RelativePath(root.to_path_buf()));
|
||||
}
|
||||
if !access.is_readable(base) {
|
||||
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)
|
||||
.map_err(|error| FsError::InvalidGlob(error.to_string()))?
|
||||
.compile_matcher();
|
||||
let mut matches = Vec::new();
|
||||
for entry in WalkBuilder::new(base).hidden(false).build().flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() || !access.is_readable(path) {
|
||||
continue;
|
||||
}
|
||||
let relative = path.strip_prefix(base).unwrap_or(path);
|
||||
if !matcher.is_match(relative) {
|
||||
continue;
|
||||
}
|
||||
let logical = path.strip_prefix(root).map_err(|_| {
|
||||
FsError::InvalidArgument("provider returned a path outside its root".to_string())
|
||||
})?;
|
||||
matches.push(FsPath::new(logical.to_string_lossy())?);
|
||||
}
|
||||
matches.sort_by(|left, right| left.as_str().cmp(right.as_str()));
|
||||
let truncated = matches.len() > request.limit;
|
||||
matches.truncate(request.limit);
|
||||
Ok(GlobResult {
|
||||
paths: matches,
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Foundational filesystem operation contracts and provider-side search.
|
||||
//!
|
||||
//! This crate deliberately has no dependency on Workdir identity, Runtime
|
||||
//! transport, or LLM Tool implementations. Paths are logical and root-relative;
|
||||
//! providers supply host roots and access policy.
|
||||
|
||||
mod glob;
|
||||
mod local;
|
||||
mod operation;
|
||||
mod search;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
pub use glob::run_glob;
|
||||
pub use local::{run_edit, run_list, run_read, run_stat, run_write};
|
||||
pub use operation::*;
|
||||
pub use search::run_grep;
|
||||
|
||||
/// Provider-owned access policy used by local filesystem operations.
|
||||
pub trait FsAccessPolicy: Send + Sync {
|
||||
fn is_readable(&self, path: &Path) -> bool;
|
||||
fn is_writable(&self, path: &Path) -> bool;
|
||||
}
|
||||
|
||||
/// First symlink encountered while resolving a provider path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SymlinkInfo {
|
||||
pub link_path: PathBuf,
|
||||
pub target_path: PathBuf,
|
||||
pub resolved_path: PathBuf,
|
||||
pub target_exists: bool,
|
||||
}
|
||||
|
||||
pub fn first_symlink(path: &Path) -> Option<SymlinkInfo> {
|
||||
if !path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
let mut current = PathBuf::new();
|
||||
let mut components = path.components().peekable();
|
||||
while let Some(component) = components.next() {
|
||||
current.push(component.as_os_str());
|
||||
let metadata = std::fs::symlink_metadata(¤t).ok()?;
|
||||
if !metadata.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
let raw_target = std::fs::read_link(¤t).ok()?;
|
||||
let target_path = if raw_target.is_absolute() {
|
||||
raw_target
|
||||
} else {
|
||||
current
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("/"))
|
||||
.join(raw_target)
|
||||
};
|
||||
let target_exists = target_path.exists();
|
||||
let mut resolved_path = target_path
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| target_path.clone());
|
||||
for remaining in components {
|
||||
resolved_path.push(remaining.as_os_str());
|
||||
}
|
||||
return Some(SymlinkInfo {
|
||||
link_path: current,
|
||||
target_path,
|
||||
resolved_path,
|
||||
target_exists,
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn direct_symlink(path: &Path) -> Option<SymlinkInfo> {
|
||||
let metadata = std::fs::symlink_metadata(path).ok()?;
|
||||
metadata
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
.then(|| first_symlink(path))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum FsError {
|
||||
#[error("invalid logical filesystem path: {0}")]
|
||||
InvalidPath(String),
|
||||
#[error("operation requires an absolute provider path, got {0}")]
|
||||
RelativePath(PathBuf),
|
||||
#[error("path is outside readable provider scope: {0}")]
|
||||
OutOfScope(PathBuf),
|
||||
#[error("path not found: {0}")]
|
||||
NotFound(PathBuf),
|
||||
#[error("broken symbolic link {link}: {target}")]
|
||||
BrokenSymlink {
|
||||
path: PathBuf,
|
||||
link: PathBuf,
|
||||
target: PathBuf,
|
||||
},
|
||||
#[error("symbolic-link target is outside {required_permission} scope: {path} -> {target}")]
|
||||
SymlinkOutOfScope {
|
||||
path: PathBuf,
|
||||
target: PathBuf,
|
||||
required_permission: &'static str,
|
||||
},
|
||||
#[error("symbolic-link directories are not traversed by {tool}: {path} -> {target}")]
|
||||
SymlinkDirectoryNotTraversed {
|
||||
tool: &'static str,
|
||||
path: PathBuf,
|
||||
target: PathBuf,
|
||||
},
|
||||
#[error("path is read-only: {0}")]
|
||||
ReadOnly(PathBuf),
|
||||
#[error("path is a directory: {0}")]
|
||||
IsDirectory(PathBuf),
|
||||
#[error("path is not a directory: {0}")]
|
||||
NotDirectory(PathBuf),
|
||||
#[error("symbolic-link target is a directory: {path} -> {target}")]
|
||||
SymlinkTargetIsDirectory { path: PathBuf, target: PathBuf },
|
||||
#[error("filesystem content conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("invalid glob: {0}")]
|
||||
InvalidGlob(String),
|
||||
#[error("invalid regular expression: {0}")]
|
||||
InvalidRegex(String),
|
||||
#[error("invalid filesystem operation argument: {0}")]
|
||||
InvalidArgument(String),
|
||||
#[error("filesystem operation failed for {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
impl FsError {
|
||||
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
|
||||
Self::Io {
|
||||
path: path.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct RootAccess(PathBuf);
|
||||
|
||||
impl FsAccessPolicy for RootAccess {
|
||||
fn is_readable(&self, path: &Path) -> bool {
|
||||
path.starts_with(&self.0)
|
||||
}
|
||||
|
||||
fn is_writable(&self, path: &Path) -> bool {
|
||||
path.starts_with(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logical_paths_reject_absolute_parent_and_backslash_forms() {
|
||||
assert!(FsPath::new("src/lib.rs").is_ok());
|
||||
assert!(FsPath::new("/tmp/file").is_err());
|
||||
assert!(FsPath::new("../file").is_err());
|
||||
assert!(FsPath::new("src\\lib.rs").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialization_cannot_bypass_logical_path_validation() {
|
||||
assert!(serde_json::from_str::<FsPath>(r#""../secret""#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_operations_cover_stat_read_write_edit_and_list() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let access = RootAccess(root.clone());
|
||||
let path = FsPath::new("notes/item.txt").unwrap();
|
||||
|
||||
let written = run_write(
|
||||
&root,
|
||||
WriteRequest {
|
||||
path: path.clone(),
|
||||
content: b"alpha\nbeta\n".to_vec(),
|
||||
expected_hash: None,
|
||||
},
|
||||
&access,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(written.created);
|
||||
|
||||
let read = run_read(
|
||||
&root,
|
||||
ReadRequest {
|
||||
path: path.clone(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 1024,
|
||||
},
|
||||
&access,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(read.bytes, b"alpha\nbeta\n");
|
||||
|
||||
let edited = run_edit(
|
||||
&root,
|
||||
EditRequest {
|
||||
path: path.clone(),
|
||||
old_string: "beta".to_string(),
|
||||
new_string: "gamma".to_string(),
|
||||
replace_all: false,
|
||||
expected_hash: read.content_hash,
|
||||
},
|
||||
&access,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(edited.replacements, 1);
|
||||
|
||||
let stat = run_stat(&root, StatRequest { path: path.clone() }, &access).unwrap();
|
||||
assert_eq!(stat.kind, EntryKind::File);
|
||||
|
||||
let listed = run_list(
|
||||
&root,
|
||||
ListRequest {
|
||||
path: FsPath::new("notes").unwrap(),
|
||||
limit: 10,
|
||||
},
|
||||
&access,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(listed.total_entries, 1);
|
||||
assert_eq!(listed.entries[0].path, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_and_grep_execute_as_bounded_provider_side_operations() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(temp.path().join("src")).unwrap();
|
||||
std::fs::write(temp.path().join("src/a.rs"), "needle one\n").unwrap();
|
||||
std::fs::write(temp.path().join("src/b.rs"), "needle two\n").unwrap();
|
||||
std::fs::write(temp.path().join("src/c.txt"), "needle hidden\n").unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let readable = RootAccess(root.clone());
|
||||
|
||||
let glob = run_glob(
|
||||
&root,
|
||||
&root,
|
||||
GlobRequest {
|
||||
pattern: "**/*.rs".to_string(),
|
||||
path: FsPath::root(),
|
||||
limit: 1,
|
||||
},
|
||||
&readable,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(glob.paths, vec![FsPath::new("src/a.rs").unwrap()]);
|
||||
assert!(glob.truncated);
|
||||
|
||||
let grep = run_grep(
|
||||
&root,
|
||||
root.clone(),
|
||||
GrepRequest {
|
||||
pattern: "needle".to_string(),
|
||||
path: FsPath::root(),
|
||||
glob: Some("**/*.rs".to_string()),
|
||||
output_mode: GrepOutputMode::Count,
|
||||
case_insensitive: false,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
multiline: false,
|
||||
file_type: None,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
&readable,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(grep.match_count, 2);
|
||||
assert_eq!(grep.matched_files, 2);
|
||||
assert!(!grep.output.contains("c.txt"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{
|
||||
ContentHash, EditRequest, EditResult, EntryKind, FsAccessPolicy, FsError, FsPath, ListEntry,
|
||||
ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest,
|
||||
WriteResult, direct_symlink,
|
||||
};
|
||||
|
||||
/// Execute stat while keeping host paths inside the provider boundary.
|
||||
pub fn run_stat(
|
||||
root: &Path,
|
||||
request: StatRequest,
|
||||
access: &dyn FsAccessPolicy,
|
||||
) -> Result<StatResult, FsError> {
|
||||
let logical = request.path;
|
||||
let path = resolve(root, &logical)?;
|
||||
if !access.is_readable(&path) {
|
||||
return Err(FsError::OutOfScope(PathBuf::from(logical.as_str())));
|
||||
}
|
||||
let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||
let kind = if metadata.file_type().is_symlink() {
|
||||
EntryKind::Symlink
|
||||
} else if metadata.is_file() {
|
||||
EntryKind::File
|
||||
} else if metadata.is_dir() {
|
||||
EntryKind::Directory
|
||||
} else {
|
||||
EntryKind::Other
|
||||
};
|
||||
Ok(StatResult {
|
||||
path: logical,
|
||||
kind,
|
||||
size: metadata.len(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_read(
|
||||
root: &Path,
|
||||
request: ReadRequest,
|
||||
access: &dyn FsAccessPolicy,
|
||||
) -> Result<ReadResult, FsError> {
|
||||
let logical = request.path;
|
||||
let path = resolve(root, &logical)?;
|
||||
let path = require_access(&path, &logical, access, false)?;
|
||||
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||
if metadata.is_dir() {
|
||||
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
||||
}
|
||||
let bytes = fs::read(&path).map_err(|error| map_io(&logical, error))?;
|
||||
let content_hash = hash_bytes(&bytes);
|
||||
let lines = bytes
|
||||
.split_inclusive(|byte| *byte == b'\n')
|
||||
.collect::<Vec<_>>();
|
||||
let total_lines = lines.len();
|
||||
if request.offset > total_lines && request.offset != 0 {
|
||||
return Err(FsError::InvalidArgument(format!(
|
||||
"offset {} exceeds file length {total_lines}",
|
||||
request.offset
|
||||
)));
|
||||
}
|
||||
let end = request
|
||||
.offset
|
||||
.saturating_add(request.limit)
|
||||
.min(total_lines);
|
||||
let mut selected = lines[request.offset.min(total_lines)..end]
|
||||
.iter()
|
||||
.flat_map(|line| line.iter().copied())
|
||||
.collect::<Vec<_>>();
|
||||
let byte_truncated = selected.len() > request.max_bytes;
|
||||
if byte_truncated {
|
||||
let mut byte_end = request.max_bytes;
|
||||
if let Ok(text) = std::str::from_utf8(&selected) {
|
||||
while byte_end > 0 && !text.is_char_boundary(byte_end) {
|
||||
byte_end -= 1;
|
||||
}
|
||||
}
|
||||
selected.truncate(byte_end);
|
||||
}
|
||||
Ok(ReadResult {
|
||||
path: logical,
|
||||
bytes: selected,
|
||||
start_line: request.offset,
|
||||
total_lines,
|
||||
content_hash,
|
||||
truncated: end < total_lines || byte_truncated,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_write(
|
||||
root: &Path,
|
||||
request: WriteRequest,
|
||||
access: &dyn FsAccessPolicy,
|
||||
) -> Result<WriteResult, FsError> {
|
||||
let logical = request.path;
|
||||
let path = resolve(root, &logical)?;
|
||||
let created = !path.exists();
|
||||
if path.exists() {
|
||||
let target = require_access(&path, &logical, access, true)?;
|
||||
let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?;
|
||||
if metadata.is_dir() {
|
||||
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
||||
}
|
||||
let actual = hash_bytes(&fs::read(&target).map_err(|error| map_io(&logical, error))?);
|
||||
if request.expected_hash != Some(actual) {
|
||||
return Err(FsError::Conflict(logical.as_str().to_string()));
|
||||
}
|
||||
atomic_write(&target, &request.content, &logical)?;
|
||||
} else {
|
||||
if request.expected_hash.is_some() {
|
||||
return Err(FsError::Conflict(logical.as_str().to_string()));
|
||||
}
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
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 {
|
||||
bytes_written: request.content.len(),
|
||||
created,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_edit(
|
||||
root: &Path,
|
||||
request: EditRequest,
|
||||
access: &dyn FsAccessPolicy,
|
||||
) -> Result<EditResult, FsError> {
|
||||
let logical = request.path;
|
||||
let path = resolve(root, &logical)?;
|
||||
let target = require_access(&path, &logical, access, true)?;
|
||||
let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?;
|
||||
let actual_hash = hash_bytes(&bytes);
|
||||
if actual_hash != request.expected_hash {
|
||||
return Err(FsError::Conflict(logical.as_str().to_string()));
|
||||
}
|
||||
let content = String::from_utf8(bytes).map_err(|_| {
|
||||
FsError::InvalidArgument(format!("{} is not valid UTF-8", logical.as_str()))
|
||||
})?;
|
||||
let occurrences = content.matches(&request.old_string).count();
|
||||
if occurrences == 0 {
|
||||
return Err(FsError::InvalidArgument(
|
||||
"old_string was not found".to_string(),
|
||||
));
|
||||
}
|
||||
if !request.replace_all && occurrences != 1 {
|
||||
return Err(FsError::InvalidArgument(format!(
|
||||
"old_string matched {occurrences} times; set replace_all=true or provide a unique string"
|
||||
)));
|
||||
}
|
||||
let edited = if request.replace_all {
|
||||
content.replace(&request.old_string, &request.new_string)
|
||||
} else {
|
||||
content.replacen(&request.old_string, &request.new_string, 1)
|
||||
};
|
||||
atomic_write(&target, edited.as_bytes(), &logical)?;
|
||||
Ok(EditResult {
|
||||
replacements: if request.replace_all { occurrences } else { 1 },
|
||||
bytes_written: edited.len(),
|
||||
content_hash: hash_bytes(edited.as_bytes()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_list(
|
||||
root: &Path,
|
||||
request: ListRequest,
|
||||
access: &dyn FsAccessPolicy,
|
||||
) -> Result<ListResult, FsError> {
|
||||
let logical = request.path;
|
||||
let path = resolve(root, &logical)?;
|
||||
let path = require_access(&path, &logical, access, false)?;
|
||||
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let read_dir = fs::read_dir(&path).map_err(|error| map_io(&logical, error))?;
|
||||
for entry in read_dir {
|
||||
let entry = entry.map_err(|error| map_io(&logical, error))?;
|
||||
let absolute = entry.path();
|
||||
if !access.is_readable(&absolute) {
|
||||
continue;
|
||||
}
|
||||
let link_metadata =
|
||||
fs::symlink_metadata(&absolute).map_err(|error| map_io(&logical, error))?;
|
||||
let is_symlink = link_metadata.file_type().is_symlink();
|
||||
let metadata = if is_symlink {
|
||||
link_metadata
|
||||
} else {
|
||||
entry.metadata().map_err(|error| map_io(&logical, error))?
|
||||
};
|
||||
let kind = if is_symlink {
|
||||
EntryKind::Symlink
|
||||
} else if metadata.is_file() {
|
||||
EntryKind::File
|
||||
} else if metadata.is_dir() {
|
||||
EntryKind::Directory
|
||||
} else {
|
||||
EntryKind::Other
|
||||
};
|
||||
let relative = absolute.strip_prefix(root).map_err(|_| {
|
||||
FsError::InvalidArgument("provider returned a path outside its root".to_string())
|
||||
})?;
|
||||
entries.push(ListEntry {
|
||||
path: FsPath::new(relative.to_string_lossy())?,
|
||||
kind,
|
||||
size: metadata.len(),
|
||||
});
|
||||
}
|
||||
entries.sort_by(|left, right| {
|
||||
let left_dir = left.kind == EntryKind::Directory;
|
||||
let right_dir = right.kind == EntryKind::Directory;
|
||||
right_dir
|
||||
.cmp(&left_dir)
|
||||
.then_with(|| left.path.as_str().cmp(right.path.as_str()))
|
||||
});
|
||||
let total_entries = entries.len();
|
||||
let total_bytes = entries.iter().map(|entry| entry.size).sum();
|
||||
let truncated = entries.len() > request.limit;
|
||||
entries.truncate(request.limit);
|
||||
Ok(ListResult {
|
||||
entries,
|
||||
total_entries,
|
||||
total_bytes,
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve(root: &Path, logical: &FsPath) -> Result<PathBuf, FsError> {
|
||||
if !root.is_absolute() {
|
||||
return Err(FsError::RelativePath(root.to_path_buf()));
|
||||
}
|
||||
Ok(if logical.as_str().is_empty() {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
root.join(logical.as_str())
|
||||
})
|
||||
}
|
||||
|
||||
fn require_access(
|
||||
path: &Path,
|
||||
logical: &FsPath,
|
||||
access: &dyn FsAccessPolicy,
|
||||
write: bool,
|
||||
) -> Result<PathBuf, FsError> {
|
||||
if let Some(info) = direct_symlink(path) {
|
||||
if !info.target_exists {
|
||||
return Err(FsError::BrokenSymlink {
|
||||
path: PathBuf::from(logical.as_str()),
|
||||
link: PathBuf::from(logical.as_str()),
|
||||
target: PathBuf::from("<provider-internal target>"),
|
||||
});
|
||||
}
|
||||
let allowed = if write {
|
||||
access.is_writable(&info.resolved_path)
|
||||
} else {
|
||||
access.is_readable(&info.resolved_path)
|
||||
};
|
||||
if !allowed {
|
||||
return Err(FsError::SymlinkOutOfScope {
|
||||
path: PathBuf::from(logical.as_str()),
|
||||
target: PathBuf::from("<provider-internal target>"),
|
||||
required_permission: if write { "write" } else { "read" },
|
||||
});
|
||||
}
|
||||
if write && info.resolved_path.is_dir() {
|
||||
return Err(FsError::SymlinkTargetIsDirectory {
|
||||
path: PathBuf::from(logical.as_str()),
|
||||
target: PathBuf::from("<provider-internal target>"),
|
||||
});
|
||||
}
|
||||
return Ok(info.resolved_path);
|
||||
}
|
||||
let allowed = if write {
|
||||
access.is_writable(path)
|
||||
} else {
|
||||
access.is_readable(path)
|
||||
};
|
||||
if allowed {
|
||||
Ok(path.to_path_buf())
|
||||
} else if write {
|
||||
Err(FsError::ReadOnly(PathBuf::from(logical.as_str())))
|
||||
} else {
|
||||
Err(FsError::OutOfScope(PathBuf::from(logical.as_str())))
|
||||
}
|
||||
}
|
||||
|
||||
fn logical_parent(path: &FsPath) -> FsPath {
|
||||
let parent = Path::new(path.as_str())
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(""))
|
||||
.to_string_lossy();
|
||||
FsPath::new(parent).unwrap_or_else(|_| FsPath::root())
|
||||
}
|
||||
|
||||
fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| FsError::InvalidArgument(format!("{} has no parent", logical.as_str())))?;
|
||||
fs::create_dir_all(parent).map_err(|error| map_io(logical, error))?;
|
||||
let mut temporary =
|
||||
tempfile::NamedTempFile::new_in(parent).map_err(|error| map_io(logical, error))?;
|
||||
temporary
|
||||
.write_all(content)
|
||||
.map_err(|error| map_io(logical, error))?;
|
||||
temporary.flush().map_err(|error| map_io(logical, error))?;
|
||||
temporary
|
||||
.persist(path)
|
||||
.map_err(|error| map_io(logical, error.error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash_bytes(content: &[u8]) -> ContentHash {
|
||||
Sha256::digest(content).into()
|
||||
}
|
||||
|
||||
fn map_io(logical: &FsPath, error: std::io::Error) -> FsError {
|
||||
match error.kind() {
|
||||
std::io::ErrorKind::NotFound => FsError::NotFound(PathBuf::from(logical.as_str())),
|
||||
_ => FsError::Io {
|
||||
path: PathBuf::from(logical.as_str()),
|
||||
source: error,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
use std::fmt;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::FsError;
|
||||
|
||||
/// Logical path relative to the bound Workdir root.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct FsPath(String);
|
||||
|
||||
impl<'de> Deserialize<'de> for FsPath {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = String::deserialize(deserializer)?;
|
||||
Self::new(&value).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
impl FsPath {
|
||||
pub fn root() -> Self {
|
||||
Self(String::new())
|
||||
}
|
||||
|
||||
pub fn new(value: impl AsRef<str>) -> Result<Self, FsError> {
|
||||
let value = value.as_ref();
|
||||
if value.is_empty() || value == "." {
|
||||
return Ok(Self::root());
|
||||
}
|
||||
let path = Path::new(value);
|
||||
if path.is_absolute() || value.contains('\\') {
|
||||
return Err(FsError::InvalidPath(value.to_owned()));
|
||||
}
|
||||
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::Normal(part) => normalized.push(part),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
|
||||
return Err(FsError::InvalidPath(value.to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let value = normalized.to_string_lossy().replace('\\', "/");
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn is_root(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FsPath {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
if self.0.is_empty() {
|
||||
f.write_str(".")
|
||||
} else {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StatRequest {
|
||||
pub path: FsPath,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StatResult {
|
||||
pub path: FsPath,
|
||||
pub kind: EntryKind,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EntryKind {
|
||||
File,
|
||||
Directory,
|
||||
Symlink,
|
||||
Other,
|
||||
}
|
||||
|
||||
pub type ContentHash = [u8; 32];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReadRequest {
|
||||
pub path: FsPath,
|
||||
pub offset: usize,
|
||||
pub limit: usize,
|
||||
pub max_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReadResult {
|
||||
pub path: FsPath,
|
||||
pub bytes: Vec<u8>,
|
||||
pub start_line: usize,
|
||||
pub total_lines: usize,
|
||||
pub content_hash: ContentHash,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WriteRequest {
|
||||
pub path: FsPath,
|
||||
pub content: Vec<u8>,
|
||||
pub expected_hash: Option<ContentHash>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WriteResult {
|
||||
pub bytes_written: usize,
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EditRequest {
|
||||
pub path: FsPath,
|
||||
pub old_string: String,
|
||||
pub new_string: String,
|
||||
pub replace_all: bool,
|
||||
pub expected_hash: ContentHash,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EditResult {
|
||||
pub replacements: usize,
|
||||
pub bytes_written: usize,
|
||||
pub content_hash: ContentHash,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ListRequest {
|
||||
pub path: FsPath,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ListEntry {
|
||||
pub path: FsPath,
|
||||
pub kind: EntryKind,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ListResult {
|
||||
pub entries: Vec<ListEntry>,
|
||||
pub total_entries: usize,
|
||||
pub total_bytes: u64,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GlobRequest {
|
||||
pub pattern: String,
|
||||
pub path: FsPath,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GlobResult {
|
||||
pub paths: Vec<FsPath>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GrepOutputMode {
|
||||
Content,
|
||||
FilesWithMatches,
|
||||
Count,
|
||||
}
|
||||
|
||||
impl Default for GrepOutputMode {
|
||||
fn default() -> Self {
|
||||
Self::FilesWithMatches
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GrepRequest {
|
||||
pub pattern: String,
|
||||
pub path: FsPath,
|
||||
pub glob: Option<String>,
|
||||
pub file_type: Option<String>,
|
||||
pub case_insensitive: bool,
|
||||
pub before_context: usize,
|
||||
pub after_context: usize,
|
||||
pub multiline: bool,
|
||||
pub output_mode: GrepOutputMode,
|
||||
pub limit: usize,
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GrepResult {
|
||||
/// Provider-rendered bounded grep report. Keeping rendering here avoids
|
||||
/// transferring candidate files across a remote provider boundary.
|
||||
pub output: String,
|
||||
pub match_count: usize,
|
||||
pub matched_files: usize,
|
||||
pub truncated: bool,
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::FsAccessPolicy;
|
||||
use grep_regex::RegexMatcherBuilder;
|
||||
use grep_searcher::sinks::UTF8 as UTF8Sink;
|
||||
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch};
|
||||
use ignore::WalkBuilder;
|
||||
use ignore::overrides::OverrideBuilder;
|
||||
use ignore::types::TypesBuilder;
|
||||
|
||||
use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink};
|
||||
|
||||
struct ContentLine {
|
||||
path: PathBuf,
|
||||
line_number: Option<u64>,
|
||||
text: String,
|
||||
is_match: bool,
|
||||
}
|
||||
|
||||
struct GrepReport {
|
||||
mode: GrepOutputMode,
|
||||
show_line_numbers: bool,
|
||||
files: Vec<PathBuf>,
|
||||
counts: Vec<(PathBuf, usize)>,
|
||||
lines: Vec<ContentLine>,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
impl GrepReport {
|
||||
fn into_result(self, root: &Path) -> GrepResult {
|
||||
let (match_count, matched_files) = match self.mode {
|
||||
GrepOutputMode::FilesWithMatches => (self.files.len(), self.files.len()),
|
||||
GrepOutputMode::Count => (
|
||||
self.counts.iter().map(|(_, count)| *count).sum(),
|
||||
self.counts.len(),
|
||||
),
|
||||
GrepOutputMode::Content => (
|
||||
self.lines.iter().filter(|line| line.is_match).count(),
|
||||
self.lines
|
||||
.iter()
|
||||
.map(|line| line.path.as_path())
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.len(),
|
||||
),
|
||||
};
|
||||
let mut output = String::new();
|
||||
match self.mode {
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
for path in &self.files {
|
||||
output.push_str(&logical_display(root, path));
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
GrepOutputMode::Count => {
|
||||
for (path, count) in &self.counts {
|
||||
output.push_str(&format!("{}:{count}\n", logical_display(root, path)));
|
||||
}
|
||||
}
|
||||
GrepOutputMode::Content => {
|
||||
for line in &self.lines {
|
||||
let separator = if line.is_match { ':' } else { '-' };
|
||||
let path = logical_display(root, &line.path);
|
||||
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 {
|
||||
output,
|
||||
match_count,
|
||||
matched_files,
|
||||
truncated: self.truncated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn logical_display(root: &Path, path: &Path) -> String {
|
||||
path.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
|
||||
const DEFAULT_HEAD_LIMIT: usize = 250;
|
||||
|
||||
struct GrepParams {
|
||||
pattern: String,
|
||||
path: Option<PathBuf>,
|
||||
glob: Option<String>,
|
||||
file_type: Option<String>,
|
||||
case_insensitive: bool,
|
||||
before: Option<usize>,
|
||||
after: Option<usize>,
|
||||
context: Option<usize>,
|
||||
line_numbers: Option<bool>,
|
||||
multiline: bool,
|
||||
output_mode: Option<GrepOutputMode>,
|
||||
head_limit: Option<usize>,
|
||||
offset: Option<usize>,
|
||||
}
|
||||
|
||||
pub fn run_grep(
|
||||
root: &Path,
|
||||
base: PathBuf,
|
||||
request: GrepRequest,
|
||||
access: &dyn FsAccessPolicy,
|
||||
) -> Result<GrepResult, FsError> {
|
||||
let p = GrepParams {
|
||||
pattern: request.pattern,
|
||||
path: Some(base.clone()),
|
||||
glob: request.glob,
|
||||
file_type: request.file_type,
|
||||
case_insensitive: request.case_insensitive,
|
||||
before: Some(request.before_context),
|
||||
after: Some(request.after_context),
|
||||
context: None,
|
||||
line_numbers: Some(true),
|
||||
multiline: request.multiline,
|
||||
output_mode: Some(request.output_mode),
|
||||
head_limit: Some(request.limit),
|
||||
offset: Some(request.offset),
|
||||
};
|
||||
let matcher = RegexMatcherBuilder::new()
|
||||
.case_insensitive(p.case_insensitive)
|
||||
.multi_line(p.multiline)
|
||||
.dot_matches_new_line(p.multiline)
|
||||
.build(&p.pattern)
|
||||
.map_err(|e| FsError::InvalidRegex(e.to_string()))?;
|
||||
|
||||
let (before, after) = match (p.before, p.after, p.context) {
|
||||
(_, _, Some(c)) => (c, c),
|
||||
(b, a, None) => (b.unwrap_or(0), a.unwrap_or(0)),
|
||||
};
|
||||
|
||||
let mut sb = SearcherBuilder::new();
|
||||
sb.binary_detection(BinaryDetection::quit(b'\x00'))
|
||||
.line_number(p.line_numbers.unwrap_or(true))
|
||||
.multi_line(p.multiline)
|
||||
.before_context(before)
|
||||
.after_context(after);
|
||||
let mut searcher = sb.build();
|
||||
|
||||
let base = p.path.unwrap_or(base);
|
||||
if !base.is_absolute() {
|
||||
return Err(FsError::RelativePath(base));
|
||||
}
|
||||
let symlink = direct_symlink(&base);
|
||||
if !access.is_readable(&base) {
|
||||
return Err(if let Some(info) = symlink.as_ref() {
|
||||
let link_parent_readable = info
|
||||
.link_path
|
||||
.parent()
|
||||
.map(|parent| access.is_readable(parent))
|
||||
.unwrap_or(false);
|
||||
if info.target_exists && link_parent_readable {
|
||||
FsError::SymlinkOutOfScope {
|
||||
path: base.clone(),
|
||||
target: info.resolved_path.clone(),
|
||||
required_permission: "read",
|
||||
}
|
||||
} else {
|
||||
FsError::OutOfScope(base.clone())
|
||||
}
|
||||
} else {
|
||||
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() {
|
||||
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
|
||||
_ => FsError::io(&base, e),
|
||||
})?;
|
||||
if !base_meta.is_dir() {
|
||||
return Err(FsError::InvalidArgument(format!(
|
||||
"grep search path is not a directory: {}",
|
||||
base.display()
|
||||
)));
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
return Err(FsError::SymlinkDirectoryNotTraversed {
|
||||
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 head_limit = p.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
||||
let offset = p.offset.unwrap_or(0);
|
||||
let show_line_numbers = p.line_numbers.unwrap_or(true);
|
||||
|
||||
let mut report = GrepReport {
|
||||
mode,
|
||||
show_line_numbers,
|
||||
files: Vec::new(),
|
||||
counts: Vec::new(),
|
||||
lines: Vec::new(),
|
||||
truncated: false,
|
||||
};
|
||||
|
||||
// Per-mode walker state.
|
||||
let mut matching_files_seen: usize = 0;
|
||||
let mut matches_seen: usize = 0;
|
||||
|
||||
'walker: for entry in wb.build().flatten() {
|
||||
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if !access.is_readable(path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match mode {
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
let hit = scan_any_match(&mut searcher, &matcher, path)?;
|
||||
if !hit {
|
||||
continue;
|
||||
}
|
||||
if matching_files_seen >= offset {
|
||||
report.files.push(path.to_path_buf());
|
||||
if report.files.len() >= head_limit {
|
||||
report.truncated = true;
|
||||
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))
|
||||
}
|
||||
|
||||
fn scan_any_match(
|
||||
searcher: &mut Searcher,
|
||||
matcher: &grep_regex::RegexMatcher,
|
||||
path: &Path,
|
||||
) -> Result<bool, FsError> {
|
||||
let mut hit = false;
|
||||
let sink = UTF8Sink(|_, _| {
|
||||
hit = true;
|
||||
Ok(false) // stop searching this file immediately
|
||||
});
|
||||
searcher
|
||||
.search_path(matcher, path, sink)
|
||||
.map_err(|e| FsError::io(path, e))?;
|
||||
Ok(hit)
|
||||
}
|
||||
|
||||
fn scan_count(
|
||||
searcher: &mut Searcher,
|
||||
matcher: &grep_regex::RegexMatcher,
|
||||
path: &Path,
|
||||
) -> Result<usize, FsError> {
|
||||
let mut count = 0usize;
|
||||
let sink = UTF8Sink(|_, _| {
|
||||
count += 1;
|
||||
Ok(true)
|
||||
});
|
||||
searcher
|
||||
.search_path(matcher, path, sink)
|
||||
.map_err(|e| FsError::io(path, e))?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
struct ContentSink<'a> {
|
||||
path: PathBuf,
|
||||
lines: &'a mut Vec<ContentLine>,
|
||||
matches_seen: &'a mut usize,
|
||||
offset: usize,
|
||||
head_limit: usize,
|
||||
}
|
||||
|
||||
impl Sink for ContentSink<'_> {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, Self::Error> {
|
||||
let idx = *self.matches_seen;
|
||||
*self.matches_seen += 1;
|
||||
|
||||
// Skip matches before offset.
|
||||
if idx < self.offset {
|
||||
return Ok(true);
|
||||
}
|
||||
// Stop searching this file once we've filled the head_limit.
|
||||
if idx >= self.offset.saturating_add(self.head_limit) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(mat.bytes())
|
||||
.trim_end_matches('\n')
|
||||
.trim_end_matches('\r')
|
||||
.to_string();
|
||||
self.lines.push(ContentLine {
|
||||
path: self.path.clone(),
|
||||
line_number: mat.line_number(),
|
||||
text,
|
||||
is_match: true,
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn context(
|
||||
&mut self,
|
||||
_searcher: &Searcher,
|
||||
ctx: &SinkContext<'_>,
|
||||
) -> Result<bool, Self::Error> {
|
||||
let seen = *self.matches_seen;
|
||||
if seen < self.offset {
|
||||
return Ok(true);
|
||||
}
|
||||
if seen >= self.offset.saturating_add(self.head_limit) {
|
||||
return Ok(false);
|
||||
}
|
||||
let text = String::from_utf8_lossy(ctx.bytes())
|
||||
.trim_end_matches('\n')
|
||||
.trim_end_matches('\r')
|
||||
.to_string();
|
||||
self.lines.push(ContentLine {
|
||||
path: self.path.clone(),
|
||||
line_number: ctx.line_number(),
|
||||
text,
|
||||
is_match: false,
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -421,14 +421,14 @@ impl Scope {
|
||||
|
||||
/// Shared, atomically-swappable view of a [`Scope`].
|
||||
///
|
||||
/// Built around [`ArcSwap`] so the hot path (permission checks inside
|
||||
/// `ScopedFs`) reads the current scope lock-free. Mutators are
|
||||
/// Built around [`ArcSwap`] so the hot path (permission checks inside a local
|
||||
/// WorkdirSession provider) reads the current scope lock-free. Mutators are
|
||||
/// serialised by an internal `Mutex` so concurrent `update` calls do
|
||||
/// not lose each other's contributions.
|
||||
///
|
||||
/// All clones share the same underlying state — a `SharedScope` cloned
|
||||
/// out to multiple consumers (Worker, ScopedFs, future grant/revoke
|
||||
/// callers) sees every update.
|
||||
/// out to multiple consumers (Worker, local WorkdirSession providers, future
|
||||
/// grant/revoke callers) sees every update.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SharedScope {
|
||||
inner: Arc<SharedScopeInner>,
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
//!
|
||||
//! Worker is expected to call [`deny_write_rules`] when memory is enabled
|
||||
//! and append the result to the manifest's `scope.deny` list before
|
||||
//! constructing the [`Scope`] passed to `tools::ScopedFs`. The memory
|
||||
//! tools themselves bypass `ScopedFs` and write directly under the
|
||||
//! workspace root, so this deny does not affect their operation.
|
||||
//! constructing the [`Scope`] passed to the local WorkdirSession provider. The
|
||||
//! memory tools themselves bypass generic WorkdirSession filesystem operations and
|
||||
//! write directly under the workspace root, so this deny does not affect them.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#[cfg(feature = "stream")]
|
||||
pub mod stream;
|
||||
pub mod subscription;
|
||||
#[cfg(feature = "typescript")]
|
||||
pub mod typescript;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,14 @@ use crate::{
|
||||
InFlightBlock, InFlightSnapshot, InFlightToolCallState, InvokeKind, MemoryWorkerEvent, Method,
|
||||
Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment,
|
||||
TurnResult, WorkerEvent, WorkerStatus,
|
||||
subscription::{
|
||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||
SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot,
|
||||
SubscriptionTerminationCode, SubscriptionWorkdir, SubscriptionWorkdirId,
|
||||
SubscriptionWorker, SubscriptionWorkerId, SubscriptionWorkerIds,
|
||||
SubscriptionWorkerProtocolMethod, SubscriptionWorkerState,
|
||||
},
|
||||
};
|
||||
|
||||
const GENERATED_RELATIVE_PATH: &str = "../../web/workspace/src/lib/generated/protocol.ts";
|
||||
@@ -50,6 +58,25 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
|
||||
push_decl::<Segment>(&cfg, &mut output);
|
||||
push_decl::<WorkerEvent>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionRequestId>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionId>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionWorkerId>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionWorkdirId>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionWorkerIds>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionWorkerState>(&cfg, &mut output);
|
||||
push_decl::<EventSubscriptionSelector>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionWorker>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionWorkdir>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionSnapshot>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionEventPayload>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionRejectionCode>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionTerminationCode>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionRequest>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionWorkerProtocolMethod>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionResponse>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionEvent>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionFramePayload>(&cfg, &mut output);
|
||||
push_decl::<SubscriptionFrame>(&cfg, &mut output);
|
||||
push_decl::<Method>(&cfg, &mut output);
|
||||
push_decl::<Event>(&cfg, &mut output);
|
||||
|
||||
|
||||
+98
-14
@@ -9,6 +9,7 @@ use std::fmt;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use fs4::fs_std::FileExt;
|
||||
@@ -1790,17 +1791,6 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum TicketBackendHttpResponse {
|
||||
Ok {
|
||||
result: TicketBackendOperationResult,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalTicketBackend {
|
||||
root: PathBuf,
|
||||
@@ -2274,11 +2264,40 @@ impl LocalTicketBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SqliteTicketMutationEvent {
|
||||
pub workspace_id: String,
|
||||
pub ticket_id: String,
|
||||
pub event_index: i64,
|
||||
pub event_kind: TicketEventKind,
|
||||
}
|
||||
|
||||
pub type SqliteTicketMutationHook =
|
||||
dyn Fn(&Connection, &SqliteTicketMutationEvent) -> Result<()> + Send + Sync;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteTicketBackend {
|
||||
db_path: PathBuf,
|
||||
workspace_id: String,
|
||||
record_language: Option<String>,
|
||||
event_attributes: BTreeMap<String, String>,
|
||||
mutation_hook: Option<Arc<SqliteTicketMutationHook>>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for SqliteTicketBackend {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("SqliteTicketBackend")
|
||||
.field("db_path", &self.db_path)
|
||||
.field("workspace_id", &self.workspace_id)
|
||||
.field("record_language", &self.record_language)
|
||||
.field("event_attributes", &self.event_attributes)
|
||||
.field(
|
||||
"mutation_hook",
|
||||
&self.mutation_hook.as_ref().map(|_| "configured"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl SqliteTicketBackend {
|
||||
@@ -2287,9 +2306,21 @@ impl SqliteTicketBackend {
|
||||
db_path: db_path.into(),
|
||||
workspace_id: workspace_id.into(),
|
||||
record_language: None,
|
||||
event_attributes: BTreeMap::new(),
|
||||
mutation_hook: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_event_attributes(mut self, attributes: BTreeMap<String, String>) -> Self {
|
||||
self.event_attributes = attributes;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_mutation_hook(mut self, hook: Arc<SqliteTicketMutationHook>) -> Self {
|
||||
self.mutation_hook = Some(hook);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_record_language(mut self, language: Option<&str>) -> Self {
|
||||
self.record_language = language.and_then(normalized_record_language);
|
||||
self
|
||||
@@ -2506,10 +2537,25 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts (
|
||||
conn.execute("INSERT INTO typed_ticket_event_references (workspace_id, ticket_id, event_index, ordinal, kind, target) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![self.workspace_id, ticket_id, next_index, ordinal as i64, reference.kind, reference.target]).map_err(sqlite_err)?;
|
||||
}
|
||||
for (key, value) in &event.attributes {
|
||||
let mut attributes = event.attributes.clone();
|
||||
for (key, value) in &self.event_attributes {
|
||||
attributes.insert(key.clone(), value.clone());
|
||||
}
|
||||
for (key, value) in &attributes {
|
||||
conn.execute("INSERT INTO typed_ticket_event_attributes (workspace_id, ticket_id, event_index, key, value) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![self.workspace_id, ticket_id, next_index, key, value]).map_err(sqlite_err)?;
|
||||
}
|
||||
if let Some(hook) = &self.mutation_hook {
|
||||
hook(
|
||||
conn,
|
||||
&SqliteTicketMutationEvent {
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
ticket_id: ticket_id.to_string(),
|
||||
event_index: next_index,
|
||||
event_kind: event.kind.clone(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2718,6 +2764,9 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts (
|
||||
for row in rows {
|
||||
let (index, kind, author, at, status, from, to, reason, state_field, heading, body) =
|
||||
row.map_err(sqlite_err)?;
|
||||
let mut attributes = self.load_event_attributes(conn, ticket_id, index)?;
|
||||
attributes.insert("event_id".to_string(), format!("{ticket_id}:{index}"));
|
||||
attributes.insert("event_sequence".to_string(), index.to_string());
|
||||
events.push(TicketEvent {
|
||||
kind: TicketEventKind::from(kind.as_str()),
|
||||
author,
|
||||
@@ -2730,7 +2779,7 @@ CREATE TABLE IF NOT EXISTS typed_ticket_artifacts (
|
||||
heading,
|
||||
body: MarkdownText::new(body),
|
||||
references: self.load_event_references(conn, ticket_id, index)?,
|
||||
attributes: self.load_event_attributes(conn, ticket_id, index)?,
|
||||
attributes,
|
||||
});
|
||||
}
|
||||
Ok(events)
|
||||
@@ -6393,6 +6442,41 @@ state: planning
|
||||
assert_partial_body_replacement_semantics(&backend);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_mutation_hook_failure_rolls_back_ticket_event() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let db_path = tmp.path().join("workspace.db");
|
||||
let backend = SqliteTicketBackend::new(&db_path, "workspace-test");
|
||||
let created = backend.create(NewTicket::new("Atomic mutation")).unwrap();
|
||||
let before = backend
|
||||
.show(TicketIdOrSlug::Id(created.id.clone()))
|
||||
.unwrap()
|
||||
.events
|
||||
.len();
|
||||
let failing = backend.clone().with_mutation_hook(Arc::new(|_, event| {
|
||||
Err(TicketError::Conflict(format!(
|
||||
"reject outbox for {}:{}",
|
||||
event.ticket_id, event.event_index
|
||||
)))
|
||||
}));
|
||||
assert!(
|
||||
failing
|
||||
.add_event(
|
||||
TicketIdOrSlug::Id(created.id.clone()),
|
||||
NewTicketEvent::new(TicketEventKind::Comment, "must roll back"),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let after = backend.show(TicketIdOrSlug::Id(created.id)).unwrap();
|
||||
assert_eq!(after.events.len(), before);
|
||||
assert!(
|
||||
after
|
||||
.events
|
||||
.iter()
|
||||
.all(|event| event.body.as_str() != "must roll back")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_backend_persists_core_ticket_operations() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
+148
-79
@@ -34,12 +34,15 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024;
|
||||
const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100;
|
||||
const MAX_DIAGNOSTIC_LIMIT: usize = 500;
|
||||
|
||||
pub const TICKET_BASE_TOOL_NAMES: [&str; 12] = [
|
||||
pub const TICKET_BASE_TOOL_NAMES: [&str; 15] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"TicketComment",
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketReview",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
@@ -66,12 +69,15 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 4] = [
|
||||
pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] =
|
||||
["TicketRelationQuery", "TicketOrchestrationPlanQuery"];
|
||||
|
||||
pub const TICKET_TOOL_NAMES: [&str; 16] = [
|
||||
pub const TICKET_TOOL_NAMES: [&str; 19] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketList",
|
||||
"TicketShow",
|
||||
"TicketComment",
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketReview",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
@@ -94,10 +100,13 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [
|
||||
"TicketOrchestrationPlanQuery",
|
||||
];
|
||||
|
||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 10] = [
|
||||
pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketComment",
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketReview",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
@@ -120,9 +129,11 @@ routing, closing, planning, or implementation decisions.";
|
||||
const SHOW_DESCRIPTION: &str = "Show one Ticket by id or exact query through the configured \
|
||||
typed Ticket backend. Output includes bounded Markdown body, recent thread events, resolution, and \
|
||||
artifact metadata.";
|
||||
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket thread event. `role` must be `comment`, \
|
||||
`plan`, `decision`, or `implementation_report`; `body` is Markdown. Writes stay inside the \
|
||||
configured Ticket backend root.";
|
||||
const COMMENT_DESCRIPTION: &str = "Append a typed Ticket comment event. `body` is Markdown.";
|
||||
const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Markdown.";
|
||||
const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown.";
|
||||
const IMPLEMENTATION_REPORT_DESCRIPTION: &str =
|
||||
"Append a typed Ticket implementation_report event. `body` is Markdown.";
|
||||
const REVIEW_DESCRIPTION: &str = "Append a Ticket review event. `result` must be `approve` or \
|
||||
`request_changes`; `body` is Markdown. Writes stay inside the configured Ticket backend root.";
|
||||
const INTAKE_READY_DESCRIPTION: &str = "Mark an existing Ticket planning lane ready through the typed \
|
||||
@@ -161,6 +172,9 @@ fn base_tool_description(name: &str) -> &'static str {
|
||||
"TicketList" => LIST_DESCRIPTION,
|
||||
"TicketShow" => SHOW_DESCRIPTION,
|
||||
"TicketComment" => COMMENT_DESCRIPTION,
|
||||
"TicketPlan" => PLAN_DESCRIPTION,
|
||||
"TicketDecision" => DECISION_DESCRIPTION,
|
||||
"TicketImplementationReport" => IMPLEMENTATION_REPORT_DESCRIPTION,
|
||||
"TicketReview" => REVIEW_DESCRIPTION,
|
||||
"TicketIntakeReady" => INTAKE_READY_DESCRIPTION,
|
||||
"TicketQueue" => QUEUE_DESCRIPTION,
|
||||
@@ -361,9 +375,6 @@ struct TicketCreateParams {
|
||||
/// Markdown body for item.md. If omitted, a small default body is used.
|
||||
#[serde(default)]
|
||||
body: Option<String>,
|
||||
/// Optional thread author for the create event.
|
||||
#[serde(default)]
|
||||
author: Option<String>,
|
||||
/// Optional assignee frontmatter value.
|
||||
#[serde(default)]
|
||||
assignee: Option<String>,
|
||||
@@ -376,9 +387,6 @@ struct TicketCreateParams {
|
||||
/// Optional state frontmatter value. Defaults to `planning`.
|
||||
#[serde(default)]
|
||||
state: Option<TicketWorkflowStateParam>,
|
||||
/// Optional queued_by frontmatter value.
|
||||
#[serde(default)]
|
||||
queued_by: Option<String>,
|
||||
/// Optional queued_at frontmatter value.
|
||||
#[serde(default)]
|
||||
queued_at: Option<String>,
|
||||
@@ -412,9 +420,6 @@ struct TicketEditItemParams {
|
||||
/// Optional target repository/ref update.
|
||||
#[serde(default)]
|
||||
target: Option<crate::TicketTargetEdit>,
|
||||
/// Optional thread author for the audited item_edit event.
|
||||
#[serde(default)]
|
||||
author: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)]
|
||||
@@ -542,25 +547,11 @@ struct TicketShowParams {
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum TicketCommentRoleParam {
|
||||
Comment,
|
||||
Plan,
|
||||
Decision,
|
||||
ImplementationReport,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TicketCommentParams {
|
||||
struct TicketThreadEventParams {
|
||||
/// Ticket id.
|
||||
ticket: String,
|
||||
/// Thread event role: `comment`, `plan`, `decision`, or `implementation_report`.
|
||||
role: TicketCommentRoleParam,
|
||||
/// Markdown event body.
|
||||
body: String,
|
||||
/// Optional thread author.
|
||||
#[serde(default)]
|
||||
author: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
@@ -578,9 +569,6 @@ struct TicketReviewParams {
|
||||
result: TicketReviewResultParam,
|
||||
/// Markdown review body.
|
||||
body: String,
|
||||
/// Optional thread author.
|
||||
#[serde(default)]
|
||||
author: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
@@ -589,9 +577,6 @@ struct TicketIntakeReadyParams {
|
||||
ticket: String,
|
||||
/// Concise bounded intake summary to append as a typed intake_summary event.
|
||||
intake_summary: String,
|
||||
/// Optional author for both intake_summary and state_changed events.
|
||||
#[serde(default)]
|
||||
author: Option<String>,
|
||||
/// Reason attached to the state_changed event. Defaults to `planning_ready`.
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
@@ -604,9 +589,6 @@ struct TicketIntakeReadyParams {
|
||||
struct TicketQueueParams {
|
||||
/// Ticket id.
|
||||
ticket: String,
|
||||
/// Optional queued_by frontmatter value. Defaults to the backend/user default.
|
||||
#[serde(default)]
|
||||
queued_by: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
@@ -621,9 +603,6 @@ struct TicketWorkflowStateParams {
|
||||
reason: String,
|
||||
/// Markdown body for the typed state_changed event.
|
||||
body: String,
|
||||
/// Optional thread author.
|
||||
#[serde(default)]
|
||||
author: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
@@ -673,9 +652,6 @@ struct TicketRelationRecordParams {
|
||||
/// Optional bounded rationale/note.
|
||||
#[serde(default)]
|
||||
note: Option<String>,
|
||||
/// Optional record author.
|
||||
#[serde(default)]
|
||||
author: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
@@ -757,9 +733,6 @@ struct TicketOrchestrationPlanRecordParams {
|
||||
/// Accepted plan fields. Required for accepted_plan and invalid for other kinds.
|
||||
#[serde(default)]
|
||||
accepted_plan: Option<AcceptedOrchestrationPlanParams>,
|
||||
/// Optional record author.
|
||||
#[serde(default)]
|
||||
author: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
@@ -851,6 +824,21 @@ struct TicketCommentTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketPlanTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketDecisionTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketImplementationReportTool {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TicketReviewTool {
|
||||
backend: TicketToolBackend,
|
||||
@@ -918,12 +906,12 @@ impl Tool for TicketCreateTool {
|
||||
if let Some(body) = params.body {
|
||||
input.body = MarkdownText::new(body);
|
||||
}
|
||||
input.author = params.author;
|
||||
input.author = None;
|
||||
input.assignee = params.assignee;
|
||||
input.readiness = params.readiness;
|
||||
input.risk_flags = params.risk_flags;
|
||||
input.workflow_state = params.state.map(TicketWorkflowStateParam::into_state);
|
||||
input.queued_by = params.queued_by;
|
||||
input.queued_by = None;
|
||||
input.queued_at = params.queued_at;
|
||||
input.repository_id = params.repository_id;
|
||||
input.ref_selector = params.ref_selector;
|
||||
@@ -971,7 +959,7 @@ impl Tool for TicketEditItemTool {
|
||||
body: params.body.map(MarkdownText::new),
|
||||
body_replacement,
|
||||
target: params.target,
|
||||
author: params.author,
|
||||
author: None,
|
||||
};
|
||||
let ticket = self
|
||||
.backend
|
||||
@@ -1066,6 +1054,26 @@ impl Tool for TicketShowTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_ticket_thread_event(
|
||||
backend: &TicketToolBackend,
|
||||
tool_name: &str,
|
||||
kind: TicketEventKind,
|
||||
input_json: &str,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketThreadEventParams = parse_input(tool_name, input_json)?;
|
||||
let role = kind.as_str().to_string();
|
||||
backend
|
||||
.add_event(
|
||||
TicketIdOrSlug::Query(params.ticket.clone()),
|
||||
NewTicketEvent::new(kind, params.body),
|
||||
)
|
||||
.map_err(|error| backend_error(tool_name, error))?;
|
||||
Ok(json_output(
|
||||
format!("Appended {role} event to ticket {}", params.ticket),
|
||||
json!({ "ticket": params.ticket, "event": role, "ok": true }),
|
||||
))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketCommentTool {
|
||||
async fn execute(
|
||||
@@ -1073,26 +1081,42 @@ impl Tool for TicketCommentTool {
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketCommentParams = parse_input("TicketComment", input_json)?;
|
||||
let kind = match params.role {
|
||||
TicketCommentRoleParam::Comment => TicketEventKind::Comment,
|
||||
TicketCommentRoleParam::Plan => TicketEventKind::Plan,
|
||||
TicketCommentRoleParam::Decision => TicketEventKind::Decision,
|
||||
TicketCommentRoleParam::ImplementationReport => TicketEventKind::ImplementationReport,
|
||||
};
|
||||
let role = kind.as_str().to_string();
|
||||
let mut event = NewTicketEvent::new(kind, params.body);
|
||||
event.author = params.author;
|
||||
self.backend
|
||||
.add_event(TicketIdOrSlug::Query(params.ticket.clone()), event)
|
||||
.map_err(|error| backend_error("TicketComment", error))?;
|
||||
Ok(json_output(
|
||||
format!("Appended {role} event to ticket {}", params.ticket),
|
||||
json!({ "ticket": params.ticket, "event": role, "ok": true }),
|
||||
))
|
||||
execute_ticket_thread_event(
|
||||
&self.backend,
|
||||
"TicketComment",
|
||||
TicketEventKind::Comment,
|
||||
input_json,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! impl_ticket_thread_event_tool {
|
||||
($tool:ty, $name:literal, $kind:expr) => {
|
||||
#[async_trait]
|
||||
impl Tool for $tool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
execute_ticket_thread_event(&self.backend, $name, $kind, input_json)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl_ticket_thread_event_tool!(TicketPlanTool, "TicketPlan", TicketEventKind::Plan);
|
||||
impl_ticket_thread_event_tool!(
|
||||
TicketDecisionTool,
|
||||
"TicketDecision",
|
||||
TicketEventKind::Decision
|
||||
);
|
||||
impl_ticket_thread_event_tool!(
|
||||
TicketImplementationReportTool,
|
||||
"TicketImplementationReport",
|
||||
TicketEventKind::ImplementationReport
|
||||
);
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TicketReviewTool {
|
||||
async fn execute(
|
||||
@@ -1108,7 +1132,7 @@ impl Tool for TicketReviewTool {
|
||||
let result_str = result.as_str().to_string();
|
||||
let review = TicketReview {
|
||||
result,
|
||||
author: params.author,
|
||||
author: None,
|
||||
body: MarkdownText::new(params.body),
|
||||
};
|
||||
self.backend
|
||||
@@ -1138,14 +1162,14 @@ impl Tool for TicketIntakeReadyTool {
|
||||
.default_intake_ready_state_change_body(from.as_str())
|
||||
});
|
||||
let mut summary = TicketIntakeSummary::new(params.intake_summary);
|
||||
summary.author = params.author.clone();
|
||||
summary.author = None;
|
||||
let mut change = TicketStateChange::new(
|
||||
from.as_str(),
|
||||
TicketWorkflowState::Ready.as_str(),
|
||||
reason,
|
||||
body,
|
||||
);
|
||||
change.author = params.author;
|
||||
change.author = None;
|
||||
self.backend
|
||||
.mark_intake_ready(
|
||||
TicketIdOrSlug::Query(params.ticket.clone()),
|
||||
@@ -1168,7 +1192,7 @@ impl Tool for TicketQueueTool {
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
|
||||
let queued_by = params.queued_by.unwrap_or_else(default_author);
|
||||
let queued_by = default_author();
|
||||
self.backend
|
||||
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
|
||||
.map_err(|error| backend_error("TicketQueue", error))?;
|
||||
@@ -1196,7 +1220,7 @@ impl Tool for TicketWorkflowStateTool {
|
||||
}
|
||||
let mut change =
|
||||
TicketStateChange::new(from.as_str(), to.as_str(), params.reason, params.body);
|
||||
change.author = params.author;
|
||||
change.author = None;
|
||||
self.backend
|
||||
.set_workflow_state(TicketIdOrSlug::Query(params.ticket.clone()), change)
|
||||
.map_err(|error| backend_error("TicketWorkflowState", error))?;
|
||||
@@ -1251,7 +1275,7 @@ impl Tool for TicketRelationRecordTool {
|
||||
kind: params.kind.into_kind(),
|
||||
target: params.target.clone(),
|
||||
note: params.note,
|
||||
author: params.author,
|
||||
author: None,
|
||||
};
|
||||
let output = self
|
||||
.backend
|
||||
@@ -1325,7 +1349,7 @@ impl Tool for TicketOrchestrationPlanRecordTool {
|
||||
related_ticket: params.related_ticket,
|
||||
note: params.note,
|
||||
accepted_plan,
|
||||
author: params.author,
|
||||
author: None,
|
||||
};
|
||||
let output = self
|
||||
.backend
|
||||
@@ -1703,7 +1727,9 @@ fn input_schema(name: &str) -> Value {
|
||||
"TicketEditItem" => serde_json::to_value(schemars::schema_for!(TicketEditItemParams)),
|
||||
"TicketList" => serde_json::to_value(schemars::schema_for!(TicketListParams)),
|
||||
"TicketShow" => serde_json::to_value(schemars::schema_for!(TicketShowParams)),
|
||||
"TicketComment" => serde_json::to_value(schemars::schema_for!(TicketCommentParams)),
|
||||
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport" => {
|
||||
serde_json::to_value(schemars::schema_for!(TicketThreadEventParams))
|
||||
}
|
||||
"TicketReview" => serde_json::to_value(schemars::schema_for!(TicketReviewParams)),
|
||||
"TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)),
|
||||
"TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)),
|
||||
@@ -1747,6 +1773,9 @@ impl_from_backend!(TicketEditItemTool);
|
||||
impl_from_backend!(TicketListTool);
|
||||
impl_from_backend!(TicketShowTool);
|
||||
impl_from_backend!(TicketCommentTool);
|
||||
impl_from_backend!(TicketPlanTool);
|
||||
impl_from_backend!(TicketDecisionTool);
|
||||
impl_from_backend!(TicketImplementationReportTool);
|
||||
impl_from_backend!(TicketReviewTool);
|
||||
impl_from_backend!(TicketIntakeReadyTool);
|
||||
impl_from_backend!(TicketQueueTool);
|
||||
@@ -1768,6 +1797,12 @@ pub fn ticket_tools(backend: impl Into<TicketToolBackend>) -> Vec<ToolDefinition
|
||||
tool_definition::<TicketListTool>("TicketList", backend.clone()),
|
||||
tool_definition::<TicketShowTool>("TicketShow", backend.clone()),
|
||||
tool_definition::<TicketCommentTool>("TicketComment", backend.clone()),
|
||||
tool_definition::<TicketPlanTool>("TicketPlan", backend.clone()),
|
||||
tool_definition::<TicketDecisionTool>("TicketDecision", backend.clone()),
|
||||
tool_definition::<TicketImplementationReportTool>(
|
||||
"TicketImplementationReport",
|
||||
backend.clone(),
|
||||
),
|
||||
tool_definition::<TicketReviewTool>("TicketReview", backend.clone()),
|
||||
tool_definition::<TicketIntakeReadyTool>("TicketIntakeReady", backend.clone()),
|
||||
tool_definition::<TicketQueueTool>("TicketQueue", backend.clone()),
|
||||
@@ -1841,6 +1876,9 @@ mod tests {
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketComment",
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketReview",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
@@ -2338,16 +2376,15 @@ mod tests {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let backend = backend(&temp);
|
||||
let created = backend.create(NewTicket::new("Flow Tool")).unwrap();
|
||||
let comment = tool_by_name(backend.clone(), "TicketComment");
|
||||
let report = tool_by_name(backend.clone(), "TicketImplementationReport");
|
||||
let review = tool_by_name(backend.clone(), "TicketReview");
|
||||
let close = tool_by_name(backend.clone(), "TicketClose");
|
||||
let doctor = tool_by_name(backend.clone(), "TicketDoctor");
|
||||
|
||||
comment
|
||||
report
|
||||
.execute(
|
||||
&json!({
|
||||
"ticket": created.id.clone(),
|
||||
"role": "implementation_report",
|
||||
"body": "Implemented."
|
||||
})
|
||||
.to_string(),
|
||||
@@ -2807,6 +2844,38 @@ mod tests {
|
||||
assert!(edit_schema.contains("old_string"));
|
||||
assert!(edit_schema.contains("new_string"));
|
||||
assert!(edit_schema.contains("replace_all"));
|
||||
for name in [
|
||||
"TicketCreate",
|
||||
"TicketEditItem",
|
||||
"TicketComment",
|
||||
"TicketPlan",
|
||||
"TicketDecision",
|
||||
"TicketImplementationReport",
|
||||
"TicketReview",
|
||||
"TicketIntakeReady",
|
||||
"TicketQueue",
|
||||
"TicketRelationRecord",
|
||||
"TicketOrchestrationPlanRecord",
|
||||
] {
|
||||
let schema = tools
|
||||
.iter()
|
||||
.map(|definition| definition().0)
|
||||
.find(|meta| meta.name == name)
|
||||
.unwrap()
|
||||
.input_schema;
|
||||
let properties = schema["properties"].as_object().unwrap();
|
||||
assert!(!properties.contains_key("author"), "{name} exposes author");
|
||||
assert!(
|
||||
!properties.contains_key("queued_by"),
|
||||
"{name} exposes queued_by"
|
||||
);
|
||||
if matches!(
|
||||
name,
|
||||
"TicketComment" | "TicketPlan" | "TicketDecision" | "TicketImplementationReport"
|
||||
) {
|
||||
assert!(!properties.contains_key("role"), "{name} exposes role");
|
||||
}
|
||||
}
|
||||
let names = tools
|
||||
.into_iter()
|
||||
.map(|definition| definition().0)
|
||||
|
||||
@@ -6,11 +6,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = { workspace = true }
|
||||
globset = "0.4.18"
|
||||
grep-matcher = "0.1.8"
|
||||
grep-regex = "0.1.14"
|
||||
grep-searcher = "0.1.16"
|
||||
ignore = "0.4.25"
|
||||
fs-operation.workspace = true
|
||||
html5ever = "0.26"
|
||||
llm-engine = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
@@ -26,6 +22,7 @@ tempfile = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
|
||||
tracing = { workspace = true }
|
||||
workdir = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
filetime = "0.2.27"
|
||||
|
||||
+70
-561
@@ -1,100 +1,39 @@
|
||||
//! `Bash` tool — execute shell commands in a one-shot, stateless way.
|
||||
//!
|
||||
//! Each call runs `bash -c <command>` via [`tokio::process::Command`].
|
||||
//! The wrapper redirects all output to a file so we never have to read
|
||||
//! from a pipe (which would expose us to bg-pipe hangs). There is no
|
||||
//! shell session: every call starts fresh at `cwd`, so the agent must
|
||||
//! chain `cd <dir> && cmd` when it wants to operate elsewhere. This
|
||||
//! mirrors Claude Code's own Bash tool — predictable, no hidden state.
|
||||
//!
|
||||
//! Output handling: when output is short (≤ 80 lines, ≤ 12 KiB) it is
|
||||
//! returned inline and the file is cleaned up. When it is longer the
|
||||
//! full output is left on disk and only the **last 80 lines** are
|
||||
//! returned, prefixed with the saved file's path. This sidesteps the
|
||||
//! Engine's blanket `ToolOutputLimits` (default 64 KiB), which would
|
||||
//! otherwise drop the *tail* of the output — usually the most useful
|
||||
//! part (errors, exit messages, summary). The saved file lives under
|
||||
//! a caller-supplied directory that the parent has added to the
|
||||
//! `ScopedFs` allow set, so the agent can inspect it via either Read
|
||||
//! or a follow-up Bash call.
|
||||
//!
|
||||
//! Filesystem and network access are NOT mediated by `ScopedFs`: the
|
||||
//! child process can touch any path. Safety is delegated to the
|
||||
//! Permission layer (deny/allow rules on the command string).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::scoped_fs::ScopedFs;
|
||||
|
||||
const DESCRIPTION: &str = "Execute a shell command via bash. Supports the \
|
||||
full shell — pipes, redirects, command substitution, `&&`/`||`. Each call \
|
||||
runs in a fresh shell rooted at the workspace; chain `cd <subdir> && cmd` \
|
||||
when you need to operate elsewhere. stdout and stderr are merged. Default \
|
||||
timeout 120s, max 600s.\n\n\
|
||||
Output handling: when the command produces more than 80 lines (or ~12 KiB), \
|
||||
the full output is saved to a file and only the LAST 80 lines are returned, \
|
||||
prefixed with the saved path. The path is readable by Read; you can also \
|
||||
inspect it from a follow-up Bash call (`grep ... <path>`, etc.).\n\n\
|
||||
Prefer dedicated tools when one fits: Read instead of `cat`/`head`/`tail` \
|
||||
on workspace files, Edit instead of `sed`/`awk` rewrites, Glob instead of \
|
||||
`find <name>`, Grep instead of `grep`/`rg`. Reach for Bash when the task \
|
||||
is shell-shaped: building, testing, version control, package management.";
|
||||
use workdir::{CommandHandle, CommandOutputRequest, CommandRequest, WorkdirSessionHandle};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 120;
|
||||
const MAX_TIMEOUT_SECS: u64 = 600;
|
||||
|
||||
/// Number of trailing lines returned when output spills to a file.
|
||||
const TAIL_LINES: usize = 80;
|
||||
|
||||
/// Inline-return budget. Outputs at or below this are returned in full;
|
||||
/// above it triggers the spill-to-file path. Sized to leave headroom under
|
||||
/// the Engine's 64 KiB default `ToolOutputLimits` cap so the inline path
|
||||
/// reliably reaches the model intact.
|
||||
const INLINE_BYTE_BUDGET: usize = 12 * 1024;
|
||||
|
||||
/// Maximum bytes loaded into memory from the spilled output file. The
|
||||
/// file itself can be arbitrarily large; we only ever read the tail end
|
||||
/// since that is what we return.
|
||||
const TAIL_READ_BUDGET: usize = 256 * 1024;
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct BashParams {
|
||||
/// Shell command to execute. Passed verbatim to `bash -c`.
|
||||
pub command: String,
|
||||
/// Timeout in seconds. Defaults to 120, capped at 600.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct BashParams {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
pub timeout: Option<u64>,
|
||||
timeout: Option<u64>,
|
||||
}
|
||||
|
||||
pub(crate) struct BashTool {
|
||||
/// Workspace root that every invocation starts in. Snapshot of
|
||||
/// `ScopedFs::cwd()` at registration time; never mutated, since we
|
||||
/// don't track `cd` across calls.
|
||||
cwd: PathBuf,
|
||||
/// Directory to spill long outputs into. Caller is expected to have
|
||||
/// added this path to the readable scope so the agent can Read the
|
||||
/// saved files. The directory itself is created lazily.
|
||||
output_dir: PathBuf,
|
||||
/// Files we left on disk for follow-up inspection. Cleaned up on
|
||||
/// `Drop` (= session end). `std::sync::Mutex` because access is
|
||||
/// always synchronous and very brief.
|
||||
spilled_outputs: std::sync::Mutex<Vec<PathBuf>>,
|
||||
session: WorkdirSessionHandle,
|
||||
}
|
||||
|
||||
impl Drop for BashTool {
|
||||
struct CommandGuard {
|
||||
session: WorkdirSessionHandle,
|
||||
handle: Option<CommandHandle>,
|
||||
}
|
||||
|
||||
impl Drop for CommandGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut paths) = self.spilled_outputs.lock() {
|
||||
for p in paths.drain(..) {
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
if let Some(handle) = self.handle.take() {
|
||||
let workdir = self.session.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = workdir.cancel_command(handle).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,509 +46,79 @@ impl Tool for BashTool {
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: BashParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Bash input: {e}")))?;
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid Bash input: {error}")))?;
|
||||
let timeout_secs = params
|
||||
.timeout
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS)
|
||||
.clamp(1, MAX_TIMEOUT_SECS);
|
||||
|
||||
// Persistent output file in the caller-supplied directory.
|
||||
// `keep()` opts out of auto-delete so the agent can inspect the
|
||||
// full output later; cleanup is deferred to `Drop` on this tool.
|
||||
std::fs::create_dir_all(&self.output_dir).map_err(|e| {
|
||||
ToolError::Internal(format!(
|
||||
"create bash output dir {}: {e}",
|
||||
self.output_dir.display()
|
||||
))
|
||||
})?;
|
||||
let output_path: PathBuf = tempfile::Builder::new()
|
||||
.prefix("bash-")
|
||||
.suffix(".log")
|
||||
.tempfile_in(&self.output_dir)
|
||||
.map_err(|e| ToolError::Internal(format!("output tempfile: {e}")))?
|
||||
.into_temp_path()
|
||||
.keep()
|
||||
.map_err(|e| ToolError::Internal(format!("persist output tempfile: {e}")))?;
|
||||
|
||||
let output_path_str = output_path
|
||||
.to_str()
|
||||
.ok_or_else(|| ToolError::Internal("output path is not UTF-8".into()))?;
|
||||
|
||||
// Wrapper:
|
||||
// exec >file 2>&1 redirect stdout/stderr to the output file
|
||||
// { user_cmd } run in a brace group (no subshell, so any
|
||||
// `cd` inside still affects $? capture below)
|
||||
// __exit=$? preserve the user command's exit code…
|
||||
// wait 2>/dev/null …since `wait` clobbers $?. Reaping bg jobs
|
||||
// guarantees the output file's writers all
|
||||
// close before bash itself exits.
|
||||
// exit $__exit propagate the user's exit
|
||||
let wrapped = format!(
|
||||
"exec >{out} 2>&1\n{{ {user_cmd}\n}}\n__yoi_exit=$?\nwait 2>/dev/null\nexit $__yoi_exit\n",
|
||||
out = shell_single_quote(output_path_str),
|
||||
user_cmd = params.command,
|
||||
);
|
||||
|
||||
tracing::debug!(cmd = %params.command, cwd = %self.cwd.display(), timeout_secs, "Bash");
|
||||
|
||||
let mut child = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(&wrapped)
|
||||
.current_dir(&self.cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null()) // bash inherits — but the wrapper redirected via `exec`
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
let _ = std::fs::remove_file(&output_path);
|
||||
ToolError::ExecutionFailed(format!("spawn bash: {e}"))
|
||||
})?;
|
||||
|
||||
let timeout_dur = Duration::from_secs(timeout_secs);
|
||||
let wait_result = tokio::time::timeout(timeout_dur, child.wait()).await;
|
||||
let (status, timed_out) = match wait_result {
|
||||
Ok(Ok(s)) => (Some(s), false),
|
||||
Ok(Err(e)) => {
|
||||
let _ = std::fs::remove_file(&output_path);
|
||||
return Err(ToolError::ExecutionFailed(format!("bash wait: {e}")));
|
||||
}
|
||||
Err(_) => (None, true),
|
||||
};
|
||||
|
||||
// Inspect the on-disk output: total size first, tail bytes second.
|
||||
let total_bytes = std::fs::metadata(&output_path)
|
||||
.map(|m| m.len() as usize)
|
||||
.unwrap_or(0);
|
||||
let tail_bytes = read_tail_bytes(&output_path, TAIL_READ_BUDGET).unwrap_or_default();
|
||||
let tail_text = String::from_utf8_lossy(&tail_bytes).into_owned();
|
||||
|
||||
let cmd_summary = truncate_for_summary(¶ms.command);
|
||||
|
||||
if timed_out {
|
||||
// Preserve the partial output file — even cut-short logs help
|
||||
// diagnose hangs.
|
||||
let content = if total_bytes > 0 {
|
||||
let last = take_last_n_lines(&tail_text, TAIL_LINES);
|
||||
self.remember_spilled(&output_path);
|
||||
Some(format!(
|
||||
"[partial output before timeout — full at {}]\n{last}",
|
||||
output_path.display()
|
||||
))
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&output_path);
|
||||
None
|
||||
};
|
||||
return Ok(ToolOutput {
|
||||
summary: format!("$ {cmd_summary} (timed out after {timeout_secs}s)"),
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
let status = status.expect("status set on the success branch");
|
||||
let summary = match status.code() {
|
||||
Some(0) => format!("$ {cmd_summary}"),
|
||||
Some(c) => format!("$ {cmd_summary} (exit {c})"),
|
||||
None => format!("$ {cmd_summary} (terminated by signal)"),
|
||||
let handle = self
|
||||
.session
|
||||
.start_command(CommandRequest {
|
||||
command: params.command,
|
||||
timeout_secs,
|
||||
output_limit: INLINE_BYTE_BUDGET,
|
||||
})
|
||||
.await
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
let mut guard = CommandGuard {
|
||||
session: self.session.clone(),
|
||||
handle: Some(handle.clone()),
|
||||
};
|
||||
let output = self
|
||||
.session
|
||||
.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: INLINE_BYTE_BUDGET,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
guard.handle = None;
|
||||
|
||||
if total_bytes == 0 {
|
||||
let _ = std::fs::remove_file(&output_path);
|
||||
return Ok(ToolOutput {
|
||||
summary,
|
||||
content: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Inline if the whole output fits in our tail-read window AND is
|
||||
// small enough to ride under the Engine's default cap.
|
||||
let line_count = tail_text.lines().count();
|
||||
let fully_loaded = total_bytes <= tail_bytes.len();
|
||||
let fits_inline =
|
||||
fully_loaded && total_bytes <= INLINE_BYTE_BUDGET && line_count <= TAIL_LINES;
|
||||
|
||||
let content = if fits_inline {
|
||||
let _ = std::fs::remove_file(&output_path);
|
||||
Some(tail_text)
|
||||
let summary = if output.timed_out {
|
||||
format!("$ {cmd_summary} (timed out after {timeout_secs}s)")
|
||||
} else {
|
||||
let last = take_last_n_lines(&tail_text, TAIL_LINES);
|
||||
// When `fully_loaded` we know the exact line count; otherwise
|
||||
// the file is bigger than our read window so we report bytes
|
||||
// and an "approximate" disclaimer.
|
||||
let header = if fully_loaded {
|
||||
format!(
|
||||
"[showing last {TAIL_LINES} of {line_count} lines — full output ({total_bytes} bytes) at {}]",
|
||||
output_path.display()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"[showing last {TAIL_LINES} lines (tail of {total_bytes}-byte output) — full at {}]",
|
||||
output_path.display()
|
||||
)
|
||||
};
|
||||
self.remember_spilled(&output_path);
|
||||
Some(format!("{header}\n{last}"))
|
||||
match output.exit_code {
|
||||
Some(0) => format!("$ {cmd_summary}"),
|
||||
Some(code) => format!("$ {cmd_summary} (exit {code})"),
|
||||
None => format!("$ {cmd_summary} (terminated)"),
|
||||
}
|
||||
};
|
||||
let content = if output.content.is_empty() {
|
||||
None
|
||||
} else if output.truncated {
|
||||
Some(format!(
|
||||
"[showing bounded WorkdirSession command output; additional output was truncated]\n{}",
|
||||
output.content
|
||||
))
|
||||
} else {
|
||||
Some(output.content)
|
||||
};
|
||||
|
||||
Ok(ToolOutput { summary, content })
|
||||
}
|
||||
}
|
||||
|
||||
impl BashTool {
|
||||
fn remember_spilled(&self, path: &Path) {
|
||||
if let Ok(mut v) = self.spilled_outputs.lock() {
|
||||
v.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read up to `max_bytes` from the end of `path`. If the file is smaller
|
||||
/// than `max_bytes`, the entire file is returned.
|
||||
fn read_tail_bytes(path: &Path, max_bytes: usize) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut f = std::fs::File::open(path)?;
|
||||
let len = f.seek(SeekFrom::End(0))?;
|
||||
let start = if len > max_bytes as u64 {
|
||||
len - max_bytes as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
f.seek(SeekFrom::Start(start))?;
|
||||
let mut buf = Vec::with_capacity((len - start) as usize);
|
||||
f.read_to_end(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Return the last `n` lines of `text`. If `text` has `n` or fewer lines
|
||||
/// (per [`str::lines`]), the input is returned as-is (no allocation).
|
||||
fn take_last_n_lines(text: &str, n: usize) -> String {
|
||||
if text.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let total = text.lines().count();
|
||||
if total <= n {
|
||||
return text.to_owned();
|
||||
}
|
||||
let skip = total - n;
|
||||
let mut count = 0usize;
|
||||
for (i, b) in text.bytes().enumerate() {
|
||||
if b == b'\n' {
|
||||
count += 1;
|
||||
if count == skip {
|
||||
return text[i + 1..].to_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
text.to_owned()
|
||||
}
|
||||
|
||||
fn truncate_for_summary(command: &str) -> String {
|
||||
let one_line = command.lines().next().unwrap_or("");
|
||||
let mut chars = one_line.chars();
|
||||
let head: String = chars.by_ref().take(80).collect();
|
||||
if chars.next().is_some() {
|
||||
let mut shortened = head;
|
||||
while shortened.chars().count() > 77 {
|
||||
shortened.pop();
|
||||
}
|
||||
shortened.push_str("...");
|
||||
shortened
|
||||
} else {
|
||||
head
|
||||
const MAX: usize = 100;
|
||||
if command.chars().count() <= MAX {
|
||||
return command.to_owned();
|
||||
}
|
||||
let mut summary = command.chars().take(MAX - 1).collect::<String>();
|
||||
summary.push('…');
|
||||
summary
|
||||
}
|
||||
|
||||
/// Wrap a string in single quotes for safe inclusion in a bash command.
|
||||
fn shell_single_quote(s: &str) -> String {
|
||||
let escaped = s.replace('\'', "'\\''");
|
||||
format!("'{escaped}'")
|
||||
}
|
||||
|
||||
/// Factory for the `Bash` tool.
|
||||
///
|
||||
/// `output_dir` is where long outputs spill to; the caller is responsible
|
||||
/// for arranging that the path is in the agent's readable scope. Every
|
||||
/// invocation starts at `fs.cwd()` — the tool is intentionally stateless
|
||||
/// w.r.t. the working directory.
|
||||
pub fn bash_tool(fs: ScopedFs, output_dir: PathBuf) -> ToolDefinition {
|
||||
pub fn bash_tool(session: WorkdirSessionHandle, _output_dir: PathBuf) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(BashParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("Bash")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
.description("Execute a shell command in the bound Workdir. Process start, bounded output, timeout and cancellation are owned by the WorkdirSession provider. This is not a sandbox.")
|
||||
.input_schema(serde_json::to_value(schema).expect("Bash schema serialization"));
|
||||
let tool: Arc<dyn Tool> = Arc::new(BashTool {
|
||||
cwd: fs.cwd().to_path_buf(),
|
||||
output_dir: output_dir.clone(),
|
||||
spilled_outputs: std::sync::Mutex::new(Vec::new()),
|
||||
session: session.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Test harness: workspace tempdir + a separate spill tempdir kept
|
||||
/// alive for the test's lifetime. The spill dir is added to the
|
||||
/// scope as readable so callers exercise the production path.
|
||||
struct Harness {
|
||||
_workspace: TempDir,
|
||||
spill: TempDir,
|
||||
fs: ScopedFs,
|
||||
}
|
||||
|
||||
fn setup() -> Harness {
|
||||
let workspace = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let base = Scope::writable(workspace.path()).unwrap();
|
||||
let mut config = manifest::ScopeConfig {
|
||||
allow: base.allow_rules(),
|
||||
deny: base.deny_rules(),
|
||||
};
|
||||
config.allow.push(manifest::ScopeRule {
|
||||
target: spill.path().to_path_buf(),
|
||||
permission: manifest::Permission::Read,
|
||||
recursive: true,
|
||||
});
|
||||
let scope = Scope::from_config(&config).unwrap();
|
||||
let fs = ScopedFs::new(scope, workspace.path().to_path_buf());
|
||||
Harness {
|
||||
_workspace: workspace,
|
||||
spill,
|
||||
fs,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_tool(h: &Harness) -> Arc<dyn Tool> {
|
||||
let def = bash_tool(h.fs.clone(), h.spill.path().to_path_buf());
|
||||
let (_, tool) = def();
|
||||
tool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runs_simple_command() {
|
||||
let h = setup();
|
||||
let def = bash_tool(h.fs.clone(), h.spill.path().to_path_buf());
|
||||
let (meta, tool) = def();
|
||||
assert_eq!(meta.name, "Bash");
|
||||
|
||||
let inp = serde_json::json!({ "command": "echo hello" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.summary, "$ echo hello");
|
||||
assert_eq!(out.content.as_deref().map(str::trim), Some("hello"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn merges_stdout_and_stderr() {
|
||||
let h = setup();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
let inp = serde_json::json!({
|
||||
"command": "echo out; echo err 1>&2",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("out"));
|
||||
assert!(body.contains("err"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn nonzero_exit_is_reported() {
|
||||
let h = setup();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
let inp = serde_json::json!({ "command": "exit 7" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("exit 7"), "summary: {}", out.summary);
|
||||
assert!(
|
||||
out.content.is_none(),
|
||||
"no output expected, got {:?}",
|
||||
out.content
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cd_does_not_persist_across_calls() {
|
||||
// Stateless: a `cd` in one call must NOT leak into the next.
|
||||
let h = setup();
|
||||
let sub = h._workspace.path().join("nested");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
tool.execute(
|
||||
&serde_json::json!({
|
||||
"command": format!("cd {}", sub.to_str().unwrap()),
|
||||
})
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pwd_out = tool
|
||||
.execute(
|
||||
&serde_json::json!({ "command": "pwd" }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let body = pwd_out.content.unwrap();
|
||||
let actual = std::fs::canonicalize(body.trim()).unwrap();
|
||||
let workspace = std::fs::canonicalize(h._workspace.path()).unwrap();
|
||||
assert_eq!(
|
||||
actual, workspace,
|
||||
"second call should start at workspace root, not the previous cd target"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_kills_long_command() {
|
||||
let h = setup();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
let inp = serde_json::json!({
|
||||
"command": "sleep 30",
|
||||
"timeout": 1,
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.summary.contains("timed out"),
|
||||
"summary: {}",
|
||||
out.summary
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_json_is_invalid_argument() {
|
||||
let h = setup();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
let err = tool
|
||||
.execute("not json", Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn long_output_spills_and_returns_tail() {
|
||||
let h = setup();
|
||||
let spill_dir = h.spill.path().to_path_buf();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
// 200 lines: "line 1" .. "line 200". Tail of 80 keeps lines 121-200.
|
||||
let inp = serde_json::json!({
|
||||
"command": "for i in $(seq 1 200); do echo line $i; done",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.expect("expected content");
|
||||
|
||||
assert!(
|
||||
body.contains(&format!("showing last {TAIL_LINES} of 200 lines")),
|
||||
"tail header missing in: {}",
|
||||
&body[..body.len().min(300)]
|
||||
);
|
||||
assert!(
|
||||
body.contains(spill_dir.to_str().unwrap()),
|
||||
"spill dir path missing: {body}"
|
||||
);
|
||||
// Last 80 lines are 121..200.
|
||||
assert!(body.contains("\nline 200\n"));
|
||||
assert!(body.contains("\nline 121\n"));
|
||||
// line 120 is the last *elided* line.
|
||||
assert!(!body.contains("\nline 120\n"), "elided line leaked: {body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wide_short_output_still_spills_when_byte_budget_exceeded() {
|
||||
let h = setup();
|
||||
let spill_dir = h.spill.path().to_path_buf();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
// One single line of ~20 KiB (over INLINE_BYTE_BUDGET = 12 KiB).
|
||||
let inp = serde_json::json!({
|
||||
"command": "printf 'x%.0s' {1..20480}",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(
|
||||
body.contains(spill_dir.to_str().unwrap()),
|
||||
"expected spill marker in: {}",
|
||||
&body[..body.len().min(200)]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_job_does_not_hang() {
|
||||
let h = setup();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
// The wrapper's `wait` ensures we don't hang on a stray bg pipe.
|
||||
let inp = serde_json::json!({
|
||||
"command": "(sleep 0.05; echo bg) &",
|
||||
"timeout": 5,
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!out.summary.contains("timed out"),
|
||||
"summary: {}",
|
||||
out.summary
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spilled_files_are_cleaned_up_on_drop() {
|
||||
let h = setup();
|
||||
let spill_dir = h.spill.path().to_path_buf();
|
||||
let tool = make_tool(&h);
|
||||
|
||||
let inp = serde_json::json!({
|
||||
"command": "for i in $(seq 1 200); do echo $i; done",
|
||||
});
|
||||
tool.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The spill dir should now contain exactly one bash-*.log file.
|
||||
let files_before: Vec<_> = std::fs::read_dir(&spill_dir)
|
||||
.unwrap()
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
assert_eq!(files_before.len(), 1, "expected one spilled file");
|
||||
let path = files_before.into_iter().next().unwrap();
|
||||
assert!(path.exists());
|
||||
|
||||
drop(tool);
|
||||
// Drop runs synchronously; file should be gone.
|
||||
assert!(
|
||||
!path.exists(),
|
||||
"spilled file should be cleaned up on drop: {path:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+39
-64
@@ -8,18 +8,18 @@ use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::ToolsError;
|
||||
use crate::scoped_fs::ScopedFs;
|
||||
use crate::tracker::Tracker;
|
||||
use workdir::{EditRequest, WorkdirPath, WorkdirSessionHandle};
|
||||
|
||||
const DESCRIPTION: &str = "Replace a substring in an existing file. By default \
|
||||
`old_string` must be unique in the file; set `replace_all: true` to replace \
|
||||
every occurrence. The file must have been read first (via the Read tool) in \
|
||||
this session. Paths must be absolute.";
|
||||
this session. Paths are relative to the bound Workdir.";
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct EditParams {
|
||||
/// Absolute path to the file.
|
||||
pub file_path: PathBuf,
|
||||
/// Logical path relative to the bound Workdir root.
|
||||
pub file_path: String,
|
||||
/// String to replace. Must be unique in the file unless `replace_all` is true.
|
||||
pub old_string: String,
|
||||
/// Replacement string. Must differ from `old_string`.
|
||||
@@ -30,7 +30,7 @@ pub(crate) struct EditParams {
|
||||
}
|
||||
|
||||
pub(crate) struct EditTool {
|
||||
fs: ScopedFs,
|
||||
session: WorkdirSessionHandle,
|
||||
tracker: Tracker,
|
||||
}
|
||||
|
||||
@@ -44,11 +44,8 @@ impl Tool for EditTool {
|
||||
let params: EditParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Edit input: {e}")))?;
|
||||
|
||||
tracing::debug!(
|
||||
path = %params.file_path.display(),
|
||||
replace_all = params.replace_all,
|
||||
"Edit"
|
||||
);
|
||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
||||
tracing::debug!(path = %path, replace_all = params.replace_all, "Edit");
|
||||
|
||||
if params.old_string.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
@@ -61,51 +58,29 @@ impl Tool for EditTool {
|
||||
));
|
||||
}
|
||||
|
||||
let _mutation_permit = self.tracker.acquire_mutation(¶ms.file_path, &ctx).await;
|
||||
|
||||
// Load current content and verify it matches the recorded hash.
|
||||
let current_bytes = self.fs.read_bytes(¶ms.file_path)?;
|
||||
self.tracker.verify(¶ms.file_path, ¤t_bytes)?;
|
||||
|
||||
let current_text = std::str::from_utf8(¤t_bytes).map_err(|_| {
|
||||
ToolsError::InvalidArgument(format!(
|
||||
"file is not valid UTF-8: {}",
|
||||
params.file_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let count = current_text.matches(¶ms.old_string).count();
|
||||
if count == 0 {
|
||||
return Err(ToolsError::StringNotFound {
|
||||
path: params.file_path.clone(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
if !params.replace_all && count > 1 {
|
||||
return Err(ToolsError::NotUnique {
|
||||
path: params.file_path.clone(),
|
||||
count,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let new_text = if params.replace_all {
|
||||
current_text.replace(¶ms.old_string, ¶ms.new_string)
|
||||
} else {
|
||||
current_text.replacen(¶ms.old_string, ¶ms.new_string, 1)
|
||||
};
|
||||
let occurrences = if params.replace_all { count } else { 1 };
|
||||
|
||||
self.fs.write(¶ms.file_path, new_text.as_bytes())?;
|
||||
self.tracker.record(¶ms.file_path, new_text.as_bytes());
|
||||
let mutation_key = PathBuf::from(path.as_str());
|
||||
let _mutation_permit = self.tracker.acquire_mutation(&mutation_key, &ctx).await;
|
||||
let expected_hash = self.tracker.expected_workdir_hash(&path)?;
|
||||
let result = self
|
||||
.session
|
||||
.edit(EditRequest {
|
||||
path: path.clone(),
|
||||
old_string: params.old_string.clone(),
|
||||
new_string: params.new_string.clone(),
|
||||
replace_all: params.replace_all,
|
||||
expected_hash,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
self.tracker.record_workdir_hash(&path, result.content_hash);
|
||||
|
||||
let summary = format!(
|
||||
"Edited {} ({} replacement{})",
|
||||
params.file_path.display(),
|
||||
occurrences,
|
||||
if occurrences == 1 { "" } else { "s" }
|
||||
path,
|
||||
result.replacements,
|
||||
if result.replacements == 1 { "" } else { "s" }
|
||||
);
|
||||
let preview = make_preview(&new_text, ¶ms.new_string);
|
||||
let preview = make_preview(¶ms.new_string, ¶ms.new_string);
|
||||
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
@@ -140,7 +115,7 @@ fn make_preview(text: &str, needle: &str) -> String {
|
||||
}
|
||||
|
||||
/// Factory for the `Edit` tool.
|
||||
pub fn edit_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
|
||||
pub fn edit_tool(session: WorkdirSessionHandle, tracker: Tracker) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(EditParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
@@ -148,7 +123,7 @@ pub fn edit_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(EditTool {
|
||||
fs: fs.clone(),
|
||||
session: session.clone(),
|
||||
tracker: tracker.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
@@ -162,19 +137,19 @@ mod tests {
|
||||
use manifest::Scope;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, ScopedFs, Tracker) {
|
||||
fn setup() -> (TempDir, WorkdirSessionHandle, Tracker) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(
|
||||
let fs: WorkdirSessionHandle = Arc::new(workdir::LocalWorkdirSession::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
);
|
||||
));
|
||||
(dir, fs, Tracker::new())
|
||||
}
|
||||
|
||||
async fn read_first(fs: &ScopedFs, tracker: &Tracker, file: &std::path::Path) {
|
||||
async fn read_first(fs: &WorkdirSessionHandle, tracker: &Tracker, file: &std::path::Path) {
|
||||
let def = read_tool(fs.clone(), tracker.clone());
|
||||
let (_, reader) = def();
|
||||
let inp = serde_json::json!({ "file_path": file.to_str().unwrap() });
|
||||
let inp = serde_json::json!({ "file_path": file.file_name().unwrap().to_str().unwrap() });
|
||||
reader
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
@@ -193,7 +168,7 @@ mod tests {
|
||||
assert_eq!(meta.name, "Edit");
|
||||
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "foo bar",
|
||||
"new_string": "foo baz",
|
||||
});
|
||||
@@ -219,7 +194,7 @@ mod tests {
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "x",
|
||||
"new_string": "y",
|
||||
"replace_all": true,
|
||||
@@ -242,7 +217,7 @@ mod tests {
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "a",
|
||||
"new_string": "b",
|
||||
});
|
||||
@@ -263,7 +238,7 @@ mod tests {
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "world",
|
||||
"new_string": "x",
|
||||
});
|
||||
@@ -283,7 +258,7 @@ mod tests {
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "foo",
|
||||
"new_string": "bar",
|
||||
});
|
||||
@@ -307,7 +282,7 @@ mod tests {
|
||||
let def = edit_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "foo",
|
||||
"new_string": "bar",
|
||||
});
|
||||
|
||||
+21
-100
@@ -1,8 +1,8 @@
|
||||
//! Error type shared across the `tools` crate.
|
||||
//! Error types for builtin tools.
|
||||
//!
|
||||
//! `ToolsError` is the crate-level error returned by `ScopedFs` and each
|
||||
//! builtin tool's internal logic. Tool `execute()` impls convert it to
|
||||
//! [`llm_engine::tool::ToolError`] via the `From` impl defined here.
|
||||
//! `ToolsError` keeps tool-specific policy failures separate from WorkdirSession
|
||||
//! operation failures. Filesystem, search, and command errors originate in
|
||||
//! `workdir` and remain transparent here.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -10,61 +10,11 @@ use llm_engine::tool::ToolError;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ToolsError {
|
||||
#[error("path must be absolute: {}", .0.display())]
|
||||
RelativePath(PathBuf),
|
||||
#[error(transparent)]
|
||||
FileSystem(#[from] fs_operation::FsError),
|
||||
|
||||
#[error("path is outside allowed scope: {}", .0.display())]
|
||||
OutOfScope(PathBuf),
|
||||
|
||||
#[error(
|
||||
"path resolves through a symlink outside allowed {required_permission} scope: {} -> {}; add the symlink target to the Worker {required_permission} scope, copy it into the workspace, or recreate the symlink with the correct target",
|
||||
.path.display(),
|
||||
.target.display()
|
||||
)]
|
||||
SymlinkOutOfScope {
|
||||
path: PathBuf,
|
||||
target: PathBuf,
|
||||
required_permission: &'static str,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"broken symlink while resolving {}: {} -> {} (target does not exist); recreate the symlink with an absolute target or a correct relative target",
|
||||
.path.display(),
|
||||
.link.display(),
|
||||
.target.display()
|
||||
)]
|
||||
BrokenSymlink {
|
||||
path: PathBuf,
|
||||
link: PathBuf,
|
||||
target: PathBuf,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"path resolves through a symlink to a directory, not a file: {} -> {}",
|
||||
.path.display(),
|
||||
.target.display()
|
||||
)]
|
||||
SymlinkTargetIsDirectory { path: PathBuf, target: PathBuf },
|
||||
|
||||
#[error(
|
||||
"{tool} does not follow symlink directories: {} -> {}; use the resolved target path directly, or add the target to read scope and reference it without the symlink",
|
||||
.path.display(),
|
||||
.target.display()
|
||||
)]
|
||||
SymlinkDirectoryNotTraversed {
|
||||
tool: &'static str,
|
||||
path: PathBuf,
|
||||
target: PathBuf,
|
||||
},
|
||||
|
||||
#[error("path is read-only in this scope: {}", .0.display())]
|
||||
ReadOnly(PathBuf),
|
||||
|
||||
#[error("path is a directory: {}", .0.display())]
|
||||
IsDirectory(PathBuf),
|
||||
|
||||
#[error("file not found: {}", .0.display())]
|
||||
NotFound(PathBuf),
|
||||
#[error(transparent)]
|
||||
WorkdirSession(#[from] workdir::WorkdirError),
|
||||
|
||||
#[error("file has not been read in this session; read it first: {}", .0.display())]
|
||||
NotRead(PathBuf),
|
||||
@@ -83,52 +33,23 @@ pub enum ToolsError {
|
||||
|
||||
#[error("invalid argument: {0}")]
|
||||
InvalidArgument(String),
|
||||
|
||||
#[error("invalid regex: {0}")]
|
||||
InvalidRegex(String),
|
||||
|
||||
#[error("invalid glob pattern: {0}")]
|
||||
InvalidGlob(String),
|
||||
|
||||
#[error("I/O error at {}: {source}", .path.display())]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
impl ToolsError {
|
||||
/// Helper to wrap an [`std::io::Error`] with the path it occurred on.
|
||||
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
|
||||
Self::Io {
|
||||
path: path.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ToolsError> for ToolError {
|
||||
fn from(err: ToolsError) -> Self {
|
||||
use ToolsError::*;
|
||||
match err {
|
||||
RelativePath(_)
|
||||
| OutOfScope(_)
|
||||
| SymlinkOutOfScope { .. }
|
||||
| BrokenSymlink { .. }
|
||||
| SymlinkTargetIsDirectory { .. }
|
||||
| SymlinkDirectoryNotTraversed { .. }
|
||||
| ReadOnly(_)
|
||||
| IsDirectory(_)
|
||||
| NotRead(_)
|
||||
| ExternallyModified(_)
|
||||
| StringNotFound { .. }
|
||||
| NotUnique { .. }
|
||||
| InvalidArgument(_)
|
||||
| InvalidRegex(_)
|
||||
| InvalidGlob(_) => ToolError::InvalidArgument(err.to_string()),
|
||||
NotFound(_) => ToolError::ExecutionFailed(err.to_string()),
|
||||
Io { .. } => ToolError::ExecutionFailed(err.to_string()),
|
||||
match &err {
|
||||
ToolsError::WorkdirSession(
|
||||
workdir::WorkdirError::NotFound(_)
|
||||
| workdir::WorkdirError::Io { .. }
|
||||
| workdir::WorkdirError::Unavailable(_),
|
||||
) => ToolError::ExecutionFailed(err.to_string()),
|
||||
ToolsError::FileSystem(_)
|
||||
| ToolsError::WorkdirSession(_)
|
||||
| ToolsError::NotRead(_)
|
||||
| ToolsError::ExternallyModified(_)
|
||||
| ToolsError::StringNotFound { .. }
|
||||
| ToolsError::NotUnique { .. }
|
||||
| ToolsError::InvalidArgument(_) => ToolError::InvalidArgument(err.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+46
-357
@@ -1,36 +1,26 @@
|
||||
//! `Glob` tool — recursive file search by glob pattern, sorted by mtime.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use manifest::Scope;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use workdir::{GlobRequest, WorkdirPath, WorkdirSessionHandle};
|
||||
|
||||
use crate::error::ToolsError;
|
||||
use crate::scoped_fs::{ScopedFs, direct_symlink};
|
||||
|
||||
const DESCRIPTION: &str = "Recursively find files matching a glob pattern \
|
||||
(e.g. \"**/*.rs\"). Results are sorted by modification time, newest first, \
|
||||
and capped at 1000 entries. Hidden files are included. The `path` parameter \
|
||||
defaults to the scope root when omitted. Paths must be absolute.";
|
||||
use crate::ToolsError;
|
||||
|
||||
const RESULT_LIMIT: usize = 1000;
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct GlobParams {
|
||||
/// Glob pattern, e.g. `"**/*.rs"`. Matched against paths relative to
|
||||
/// `path` (or the scope root if omitted).
|
||||
pub pattern: String,
|
||||
/// Absolute directory to search under. Defaults to the scope root.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct GlobParams {
|
||||
/// Glob pattern, for example `**/*.rs` or `src/**/test_*.py`.
|
||||
pattern: String,
|
||||
/// Logical Workdir-relative directory. Defaults to the Workdir root.
|
||||
#[serde(default)]
|
||||
pub path: Option<PathBuf>,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct GlobTool {
|
||||
fs: ScopedFs,
|
||||
struct GlobTool {
|
||||
session: WorkdirSessionHandle,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -41,358 +31,57 @@ impl Tool for GlobTool {
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: GlobParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Glob input: {e}")))?;
|
||||
|
||||
tracing::debug!(
|
||||
pattern = %params.pattern,
|
||||
path = ?params.path,
|
||||
"Glob"
|
||||
);
|
||||
|
||||
let base = params
|
||||
.path
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.fs.cwd().to_path_buf());
|
||||
let pattern = params.pattern.clone();
|
||||
let scope = self.fs.scope().clone();
|
||||
|
||||
// ignore::Walk is synchronous; run it on a blocking thread so we
|
||||
// don't stall the runtime for large trees.
|
||||
let results = tokio::task::spawn_blocking(move || run_glob(&base, &pattern, &scope))
|
||||
.await
|
||||
.map_err(|e| ToolError::Internal(format!("spawn_blocking failed: {e}")))??;
|
||||
|
||||
let total = results.len();
|
||||
let (shown, truncated) = if total > RESULT_LIMIT {
|
||||
(&results[..RESULT_LIMIT], true)
|
||||
} else {
|
||||
(&results[..], false)
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid Glob input: {error}")))?;
|
||||
let path = match params.path {
|
||||
Some(path) => WorkdirPath::new(&path).map_err(ToolsError::from)?,
|
||||
None => WorkdirPath::root(),
|
||||
};
|
||||
|
||||
if shown.is_empty() {
|
||||
return Ok(ToolOutput {
|
||||
summary: format!("No files found matching {}", params.pattern),
|
||||
content: None,
|
||||
});
|
||||
}
|
||||
|
||||
let mut body = String::new();
|
||||
for p in shown {
|
||||
body.push_str(&p.display().to_string());
|
||||
let pattern = params.pattern;
|
||||
tracing::debug!(%pattern, %path, "Glob");
|
||||
let result = self
|
||||
.session
|
||||
.glob(GlobRequest {
|
||||
pattern: pattern.clone(),
|
||||
path,
|
||||
limit: RESULT_LIMIT,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
let mut body = result
|
||||
.paths
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !body.is_empty() {
|
||||
body.push('\n');
|
||||
}
|
||||
|
||||
let summary = if truncated {
|
||||
let summary = if result.paths.is_empty() {
|
||||
format!("No files found matching {pattern}")
|
||||
} else if result.truncated {
|
||||
format!(
|
||||
"Found {total}+ files matching {} (truncated to {RESULT_LIMIT})",
|
||||
params.pattern
|
||||
"Found {}+ files matching {pattern} (truncated to {RESULT_LIMIT})",
|
||||
result.paths.len()
|
||||
)
|
||||
} else {
|
||||
format!("Found {total} file(s) matching {}", params.pattern)
|
||||
format!("Found {} file(s) matching {pattern}", result.paths.len())
|
||||
};
|
||||
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: Some(body),
|
||||
content: (!body.is_empty()).then_some(body),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn run_glob(base: &Path, pattern: &str, scope: &Scope) -> Result<Vec<PathBuf>, ToolsError> {
|
||||
if !base.is_absolute() {
|
||||
return Err(ToolsError::RelativePath(base.to_path_buf()));
|
||||
}
|
||||
let symlink = direct_symlink(base);
|
||||
if !scope.is_readable(base) {
|
||||
return Err(if let Some(info) = symlink.as_ref() {
|
||||
let link_parent_readable = info
|
||||
.link_path
|
||||
.parent()
|
||||
.map(|parent| scope.is_readable(parent))
|
||||
.unwrap_or(false);
|
||||
if info.target_exists && link_parent_readable {
|
||||
ToolsError::SymlinkOutOfScope {
|
||||
path: base.to_path_buf(),
|
||||
target: info.resolved_path.clone(),
|
||||
required_permission: "read",
|
||||
}
|
||||
} else {
|
||||
ToolsError::OutOfScope(base.to_path_buf())
|
||||
}
|
||||
} else {
|
||||
ToolsError::OutOfScope(base.to_path_buf())
|
||||
});
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
if !info.target_exists {
|
||||
return Err(ToolsError::BrokenSymlink {
|
||||
path: base.to_path_buf(),
|
||||
link: info.link_path.clone(),
|
||||
target: info.target_path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let base_meta = std::fs::metadata(base).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => ToolsError::NotFound(base.to_path_buf()),
|
||||
_ => ToolsError::io(base, e),
|
||||
})?;
|
||||
if !base_meta.is_dir() {
|
||||
return Err(ToolsError::InvalidArgument(format!(
|
||||
"glob search path is not a directory: {}",
|
||||
base.display()
|
||||
)));
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
return Err(ToolsError::SymlinkDirectoryNotTraversed {
|
||||
tool: "Glob",
|
||||
path: base.to_path_buf(),
|
||||
target: info.resolved_path.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let glob = globset::Glob::new(pattern)
|
||||
.map_err(|e| ToolsError::InvalidGlob(e.to_string()))?
|
||||
.compile_matcher();
|
||||
|
||||
// Glob is an explicit-pattern tool, so gitignore/hidden are *not* honored.
|
||||
let walker = ignore::WalkBuilder::new(base)
|
||||
.hidden(false)
|
||||
.git_ignore(false)
|
||||
.git_global(false)
|
||||
.git_exclude(false)
|
||||
.ignore(false)
|
||||
.parents(false)
|
||||
.follow_links(false)
|
||||
.build();
|
||||
|
||||
let mut hits: Vec<(PathBuf, SystemTime)> = Vec::new();
|
||||
for entry in walker.flatten() {
|
||||
let ft = match entry.file_type() {
|
||||
Some(ft) => ft,
|
||||
None => continue,
|
||||
};
|
||||
if !ft.is_file() {
|
||||
continue;
|
||||
}
|
||||
let rel = match entry.path().strip_prefix(base) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !glob.is_match(rel) {
|
||||
continue;
|
||||
}
|
||||
if !scope.is_readable(entry.path()) {
|
||||
continue;
|
||||
}
|
||||
let mtime = entry
|
||||
.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
hits.push((entry.path().to_path_buf(), mtime));
|
||||
}
|
||||
|
||||
hits.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
Ok(hits.into_iter().map(|(p, _)| p).collect())
|
||||
}
|
||||
|
||||
/// Factory for the `Glob` tool.
|
||||
pub fn glob_tool(fs: ScopedFs) -> ToolDefinition {
|
||||
pub fn glob_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(GlobParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("Glob")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(GlobTool { fs: fs.clone() });
|
||||
.description("Find files matching a glob pattern inside the bound Workdir. Results are sorted and capped at 1000 entries. Paths are Workdir-relative.")
|
||||
.input_schema(serde_json::to_value(schema).expect("Glob schema serialization"));
|
||||
let tool: Arc<dyn Tool> = Arc::new(GlobTool {
|
||||
session: session.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, ScopedFs) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
);
|
||||
(dir, fs)
|
||||
}
|
||||
|
||||
fn touch(path: &Path, content: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_finds_matching_files() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.rs"), "");
|
||||
touch(&dir.path().join("sub/b.rs"), "");
|
||||
touch(&dir.path().join("sub/c.txt"), "");
|
||||
|
||||
let def = glob_tool(fs);
|
||||
let (meta, tool) = def();
|
||||
assert_eq!(meta.name, "Glob");
|
||||
|
||||
let inp = serde_json::json!({ "pattern": "**/*.rs" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("2 file(s)"));
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("a.rs"));
|
||||
assert!(body.contains("b.rs"));
|
||||
assert!(!body.contains("c.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_sorts_by_mtime_desc() {
|
||||
let (dir, fs) = setup();
|
||||
let older = dir.path().join("old.rs");
|
||||
let newer = dir.path().join("new.rs");
|
||||
touch(&older, "");
|
||||
touch(&newer, "");
|
||||
|
||||
filetime::set_file_mtime(&older, filetime::FileTime::from_unix_time(1_000, 0)).unwrap();
|
||||
filetime::set_file_mtime(&newer, filetime::FileTime::from_unix_time(2_000, 0)).unwrap();
|
||||
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "*.rs" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
let new_pos = body.find("new.rs").unwrap();
|
||||
let old_pos = body.find("old.rs").unwrap();
|
||||
assert!(new_pos < old_pos, "newer file should come first:\n{body}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_empty_results() {
|
||||
let (_dir, fs) = setup();
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "**/*.nonexistent" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("No files"));
|
||||
assert!(out.content.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_invalid_pattern() {
|
||||
let (_dir, fs) = setup();
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "[unterminated" });
|
||||
let err = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_filters_results_by_scope_readability() {
|
||||
use manifest::{Permission, ScopeConfig, ScopeRule};
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let secret_dir = dir.path().join("secret");
|
||||
std::fs::create_dir(&secret_dir).unwrap();
|
||||
touch(&dir.path().join("visible.rs"), "");
|
||||
touch(&secret_dir.join("hidden.rs"), "");
|
||||
|
||||
let cfg = ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
deny: vec![ScopeRule {
|
||||
target: secret_dir.clone(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
}],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "**/*.rs" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap_or_default();
|
||||
assert!(body.contains("visible.rs"));
|
||||
assert!(
|
||||
!body.contains("hidden.rs"),
|
||||
"scope-denied file leaked into glob output: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_honors_hidden_files() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join(".hidden.rs"), "");
|
||||
touch(&dir.path().join("visible.rs"), "");
|
||||
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "*.rs" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains(".hidden.rs"));
|
||||
assert!(body.contains("visible.rs"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn glob_reports_scope_inside_symlink_directory_is_not_traversed() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let (dir, fs) = setup();
|
||||
let target = dir.path().join("target-dir");
|
||||
touch(&target.join("visible.rs"), "");
|
||||
let link = dir.path().join("external-project");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let def = glob_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"path": link.to_str().unwrap(),
|
||||
"pattern": "**/*.rs",
|
||||
});
|
||||
let err = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("Glob does not follow symlink directories"),
|
||||
"{msg}"
|
||||
);
|
||||
assert!(msg.contains(&link.display().to_string()), "{msg}");
|
||||
assert!(
|
||||
msg.contains(&target.canonicalize().unwrap().display().to_string()),
|
||||
"{msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+91
-828
@@ -1,83 +1,54 @@
|
||||
//! `Grep` tool — recursive regex search powered by ripgrep's component crates.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use grep_regex::RegexMatcherBuilder;
|
||||
use grep_searcher::sinks::UTF8 as UTF8Sink;
|
||||
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch};
|
||||
use ignore::WalkBuilder;
|
||||
use ignore::overrides::OverrideBuilder;
|
||||
use ignore::types::TypesBuilder;
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use manifest::Scope;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use workdir::{GrepOutputMode, GrepRequest, WorkdirPath, WorkdirSessionHandle};
|
||||
|
||||
use crate::error::ToolsError;
|
||||
use crate::scoped_fs::{ScopedFs, direct_symlink};
|
||||
|
||||
const DESCRIPTION: &str = "Recursive regex search across files, powered by \
|
||||
ripgrep. Supports file filtering (`glob`, `type`), context lines, multiline \
|
||||
matching, and three output modes: `files_with_matches` (default), `content`, \
|
||||
and `count`. Honors .gitignore. Binary files are skipped. Paths must be \
|
||||
absolute.";
|
||||
use crate::ToolsError;
|
||||
|
||||
const DEFAULT_HEAD_LIMIT: usize = 250;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Copy, Default, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum GrepOutputMode {
|
||||
enum OutputMode {
|
||||
Content,
|
||||
#[default]
|
||||
FilesWithMatches,
|
||||
Content,
|
||||
Count,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct GrepParams {
|
||||
/// Regex pattern to search for.
|
||||
pub pattern: String,
|
||||
/// Absolute path to search under. Defaults to the scope root.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct GrepParams {
|
||||
pattern: String,
|
||||
/// Logical Workdir-relative path to search. Defaults to the Workdir root.
|
||||
#[serde(default)]
|
||||
pub path: Option<PathBuf>,
|
||||
/// Glob filter applied to candidate files, e.g. `"*.rs"`.
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub glob: Option<String>,
|
||||
/// File type filter, e.g. `"rust"` or `"py"`. See ripgrep's default types.
|
||||
glob: Option<String>,
|
||||
#[serde(default, rename = "type")]
|
||||
pub file_type: Option<String>,
|
||||
/// Output mode: `files_with_matches` (default), `content`, or `count`.
|
||||
file_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub output_mode: Option<GrepOutputMode>,
|
||||
/// Show line numbers in content mode. Defaults to true.
|
||||
#[serde(default, rename = "-n")]
|
||||
pub line_numbers: Option<bool>,
|
||||
/// Case-insensitive matching.
|
||||
#[serde(default, rename = "-i")]
|
||||
pub case_insensitive: bool,
|
||||
/// Trailing context lines after each match.
|
||||
#[serde(default, rename = "-A")]
|
||||
pub after: Option<usize>,
|
||||
/// Leading context lines before each match.
|
||||
case_insensitive: bool,
|
||||
#[serde(default, rename = "-B")]
|
||||
pub before: Option<usize>,
|
||||
/// Context lines before AND after each match (overrides -A/-B when set).
|
||||
before: Option<usize>,
|
||||
#[serde(default, rename = "-A")]
|
||||
after: Option<usize>,
|
||||
#[serde(default, rename = "-C")]
|
||||
pub context: Option<usize>,
|
||||
/// Allow patterns to match across newlines.
|
||||
context: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub multiline: bool,
|
||||
/// Maximum number of output entries. Defaults to 250.
|
||||
multiline: bool,
|
||||
#[serde(default)]
|
||||
pub head_limit: Option<usize>,
|
||||
/// Skip the first N output entries (pagination).
|
||||
output_mode: Option<OutputMode>,
|
||||
#[serde(default)]
|
||||
pub offset: Option<usize>,
|
||||
head_limit: Option<usize>,
|
||||
#[serde(default)]
|
||||
offset: Option<usize>,
|
||||
}
|
||||
|
||||
pub(crate) struct GrepTool {
|
||||
fs: ScopedFs,
|
||||
struct GrepTool {
|
||||
session: WorkdirSessionHandle,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -88,788 +59,80 @@ impl Tool for GrepTool {
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: GrepParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Grep input: {e}")))?;
|
||||
|
||||
tracing::debug!(
|
||||
pattern = %params.pattern,
|
||||
mode = ?params.output_mode,
|
||||
"Grep"
|
||||
);
|
||||
|
||||
let default_base = self.fs.cwd().to_path_buf();
|
||||
let scope = self.fs.scope().clone();
|
||||
let report = tokio::task::spawn_blocking(move || run_grep(default_base, params, &scope))
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid Grep input: {error}")))?;
|
||||
let path = match params.path {
|
||||
Some(path) => WorkdirPath::new(&path).map_err(ToolsError::from)?,
|
||||
None => WorkdirPath::root(),
|
||||
};
|
||||
let mode = match params.output_mode.unwrap_or_default() {
|
||||
OutputMode::FilesWithMatches => GrepOutputMode::FilesWithMatches,
|
||||
OutputMode::Content => GrepOutputMode::Content,
|
||||
OutputMode::Count => GrepOutputMode::Count,
|
||||
};
|
||||
let (before_context, after_context) = params
|
||||
.context
|
||||
.map(|context| (context, context))
|
||||
.unwrap_or((params.before.unwrap_or(0), params.after.unwrap_or(0)));
|
||||
let head_limit = params.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
||||
let result = self
|
||||
.session
|
||||
.grep(GrepRequest {
|
||||
pattern: params.pattern,
|
||||
path,
|
||||
glob: params.glob,
|
||||
file_type: params.file_type,
|
||||
case_insensitive: params.case_insensitive,
|
||||
before_context,
|
||||
after_context,
|
||||
multiline: params.multiline,
|
||||
output_mode: mode,
|
||||
limit: head_limit,
|
||||
offset: params.offset.unwrap_or(0),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ToolError::Internal(format!("spawn_blocking failed: {e}")))??;
|
||||
.map_err(ToolsError::from)?;
|
||||
|
||||
Ok(report.render())
|
||||
let summary = if result.match_count == 0 {
|
||||
match mode {
|
||||
GrepOutputMode::Content => "No matches".to_owned(),
|
||||
_ => "No files matched".to_owned(),
|
||||
}
|
||||
} else {
|
||||
match mode {
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
format!("Found matches in {} file(s)", result.matched_files)
|
||||
}
|
||||
GrepOutputMode::Count => format!(
|
||||
"Found matches in {} file(s), {} total line(s)",
|
||||
result.matched_files, result.match_count
|
||||
),
|
||||
GrepOutputMode::Content => format!(
|
||||
"{} matching line(s) in {} file(s)",
|
||||
result.match_count, result.matched_files
|
||||
),
|
||||
}
|
||||
};
|
||||
let summary = if result.truncated {
|
||||
format!("{summary} (truncated at {head_limit})")
|
||||
} else {
|
||||
summary
|
||||
};
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: (!result.output.is_empty()).then_some(result.output),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory for the `Grep` tool.
|
||||
pub fn grep_tool(fs: ScopedFs) -> ToolDefinition {
|
||||
pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(GrepParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("Grep")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(GrepTool { fs: fs.clone() });
|
||||
.description("Search Workdir file contents with a regex. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
|
||||
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
|
||||
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
|
||||
session: session.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Implementation
|
||||
// =============================================================================
|
||||
|
||||
struct ContentLine {
|
||||
path: PathBuf,
|
||||
line_number: Option<u64>,
|
||||
text: String,
|
||||
is_match: bool,
|
||||
}
|
||||
|
||||
struct GrepReport {
|
||||
mode: GrepOutputMode,
|
||||
show_line_numbers: bool,
|
||||
files: Vec<PathBuf>,
|
||||
counts: Vec<(PathBuf, usize)>,
|
||||
lines: Vec<ContentLine>,
|
||||
truncated: bool,
|
||||
head_limit: usize,
|
||||
}
|
||||
|
||||
impl GrepReport {
|
||||
fn render(self) -> ToolOutput {
|
||||
match self.mode {
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
if self.files.is_empty() {
|
||||
return ToolOutput {
|
||||
summary: "No files matched".into(),
|
||||
content: None,
|
||||
};
|
||||
}
|
||||
let mut body = String::new();
|
||||
for p in &self.files {
|
||||
body.push_str(&p.display().to_string());
|
||||
body.push('\n');
|
||||
}
|
||||
let mut summary = format!("Found matches in {} file(s)", self.files.len());
|
||||
if self.truncated {
|
||||
summary.push_str(&format!(" (truncated at {})", self.head_limit));
|
||||
}
|
||||
ToolOutput {
|
||||
summary,
|
||||
content: Some(body),
|
||||
}
|
||||
}
|
||||
GrepOutputMode::Count => {
|
||||
if self.counts.is_empty() {
|
||||
return ToolOutput {
|
||||
summary: "No files matched".into(),
|
||||
content: None,
|
||||
};
|
||||
}
|
||||
let total_lines: usize = self.counts.iter().map(|(_, n)| *n).sum();
|
||||
let mut body = String::new();
|
||||
for (p, n) in &self.counts {
|
||||
body.push_str(&format!("{}:{}\n", p.display(), n));
|
||||
}
|
||||
let mut summary = format!(
|
||||
"Found matches in {} file(s), {} total line(s)",
|
||||
self.counts.len(),
|
||||
total_lines
|
||||
);
|
||||
if self.truncated {
|
||||
summary.push_str(&format!(" (truncated at {})", self.head_limit));
|
||||
}
|
||||
ToolOutput {
|
||||
summary,
|
||||
content: Some(body),
|
||||
}
|
||||
}
|
||||
GrepOutputMode::Content => {
|
||||
if self.lines.is_empty() {
|
||||
return ToolOutput {
|
||||
summary: "No matches".into(),
|
||||
content: None,
|
||||
};
|
||||
}
|
||||
let match_count = self.lines.iter().filter(|l| l.is_match).count();
|
||||
let file_set: std::collections::BTreeSet<&Path> =
|
||||
self.lines.iter().map(|l| l.path.as_path()).collect();
|
||||
let mut body = String::new();
|
||||
for line in &self.lines {
|
||||
let sep = if line.is_match { ':' } else { '-' };
|
||||
if self.show_line_numbers {
|
||||
if let Some(n) = line.line_number {
|
||||
body.push_str(&format!(
|
||||
"{}{}{}{}{}\n",
|
||||
line.path.display(),
|
||||
sep,
|
||||
n,
|
||||
sep,
|
||||
line.text
|
||||
));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
body.push_str(&format!("{}{}{}\n", line.path.display(), sep, line.text));
|
||||
}
|
||||
let mut summary = format!(
|
||||
"{} matching line(s) in {} file(s)",
|
||||
match_count,
|
||||
file_set.len()
|
||||
);
|
||||
if self.truncated {
|
||||
summary.push_str(&format!(" (truncated at {})", self.head_limit));
|
||||
}
|
||||
ToolOutput {
|
||||
summary,
|
||||
content: Some(body),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_grep(default_base: PathBuf, p: GrepParams, scope: &Scope) -> Result<GrepReport, ToolsError> {
|
||||
let matcher = RegexMatcherBuilder::new()
|
||||
.case_insensitive(p.case_insensitive)
|
||||
.multi_line(p.multiline)
|
||||
.dot_matches_new_line(p.multiline)
|
||||
.build(&p.pattern)
|
||||
.map_err(|e| ToolsError::InvalidRegex(e.to_string()))?;
|
||||
|
||||
let (before, after) = match (p.before, p.after, p.context) {
|
||||
(_, _, Some(c)) => (c, c),
|
||||
(b, a, None) => (b.unwrap_or(0), a.unwrap_or(0)),
|
||||
};
|
||||
|
||||
let mut sb = SearcherBuilder::new();
|
||||
sb.binary_detection(BinaryDetection::quit(b'\x00'))
|
||||
.line_number(p.line_numbers.unwrap_or(true))
|
||||
.multi_line(p.multiline)
|
||||
.before_context(before)
|
||||
.after_context(after);
|
||||
let mut searcher = sb.build();
|
||||
|
||||
let base = p.path.unwrap_or(default_base);
|
||||
if !base.is_absolute() {
|
||||
return Err(ToolsError::RelativePath(base));
|
||||
}
|
||||
let symlink = direct_symlink(&base);
|
||||
if !scope.is_readable(&base) {
|
||||
return Err(if let Some(info) = symlink.as_ref() {
|
||||
let link_parent_readable = info
|
||||
.link_path
|
||||
.parent()
|
||||
.map(|parent| scope.is_readable(parent))
|
||||
.unwrap_or(false);
|
||||
if info.target_exists && link_parent_readable {
|
||||
ToolsError::SymlinkOutOfScope {
|
||||
path: base.clone(),
|
||||
target: info.resolved_path.clone(),
|
||||
required_permission: "read",
|
||||
}
|
||||
} else {
|
||||
ToolsError::OutOfScope(base.clone())
|
||||
}
|
||||
} else {
|
||||
ToolsError::OutOfScope(base.clone())
|
||||
});
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
if !info.target_exists {
|
||||
return Err(ToolsError::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() {
|
||||
std::io::ErrorKind::NotFound => ToolsError::NotFound(base.clone()),
|
||||
_ => ToolsError::io(&base, e),
|
||||
})?;
|
||||
if !base_meta.is_dir() {
|
||||
return Err(ToolsError::InvalidArgument(format!(
|
||||
"grep search path is not a directory: {}",
|
||||
base.display()
|
||||
)));
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
return Err(ToolsError::SymlinkDirectoryNotTraversed {
|
||||
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| ToolsError::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| ToolsError::InvalidGlob(e.to_string()))?;
|
||||
let ov = ob
|
||||
.build()
|
||||
.map_err(|e| ToolsError::InvalidGlob(e.to_string()))?;
|
||||
wb.overrides(ov);
|
||||
}
|
||||
|
||||
let mode = p.output_mode.unwrap_or_default();
|
||||
let head_limit = p.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
||||
let offset = p.offset.unwrap_or(0);
|
||||
let show_line_numbers = p.line_numbers.unwrap_or(true);
|
||||
|
||||
let mut report = GrepReport {
|
||||
mode,
|
||||
show_line_numbers,
|
||||
files: Vec::new(),
|
||||
counts: Vec::new(),
|
||||
lines: Vec::new(),
|
||||
truncated: false,
|
||||
head_limit,
|
||||
};
|
||||
|
||||
// Per-mode walker state.
|
||||
let mut matching_files_seen: usize = 0;
|
||||
let mut matches_seen: usize = 0;
|
||||
|
||||
'walker: for entry in wb.build().flatten() {
|
||||
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if !scope.is_readable(path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match mode {
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
let hit = scan_any_match(&mut searcher, &matcher, path)?;
|
||||
if !hit {
|
||||
continue;
|
||||
}
|
||||
if matching_files_seen >= offset {
|
||||
report.files.push(path.to_path_buf());
|
||||
if report.files.len() >= head_limit {
|
||||
report.truncated = true;
|
||||
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| ToolsError::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)
|
||||
}
|
||||
|
||||
fn scan_any_match(
|
||||
searcher: &mut Searcher,
|
||||
matcher: &grep_regex::RegexMatcher,
|
||||
path: &Path,
|
||||
) -> Result<bool, ToolsError> {
|
||||
let mut hit = false;
|
||||
let sink = UTF8Sink(|_, _| {
|
||||
hit = true;
|
||||
Ok(false) // stop searching this file immediately
|
||||
});
|
||||
searcher
|
||||
.search_path(matcher, path, sink)
|
||||
.map_err(|e| ToolsError::io(path, e))?;
|
||||
Ok(hit)
|
||||
}
|
||||
|
||||
fn scan_count(
|
||||
searcher: &mut Searcher,
|
||||
matcher: &grep_regex::RegexMatcher,
|
||||
path: &Path,
|
||||
) -> Result<usize, ToolsError> {
|
||||
let mut count = 0usize;
|
||||
let sink = UTF8Sink(|_, _| {
|
||||
count += 1;
|
||||
Ok(true)
|
||||
});
|
||||
searcher
|
||||
.search_path(matcher, path, sink)
|
||||
.map_err(|e| ToolsError::io(path, e))?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
struct ContentSink<'a> {
|
||||
path: PathBuf,
|
||||
lines: &'a mut Vec<ContentLine>,
|
||||
matches_seen: &'a mut usize,
|
||||
offset: usize,
|
||||
head_limit: usize,
|
||||
}
|
||||
|
||||
impl Sink for ContentSink<'_> {
|
||||
type Error = std::io::Error;
|
||||
|
||||
fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, Self::Error> {
|
||||
let idx = *self.matches_seen;
|
||||
*self.matches_seen += 1;
|
||||
|
||||
// Skip matches before offset.
|
||||
if idx < self.offset {
|
||||
return Ok(true);
|
||||
}
|
||||
// Stop searching this file once we've filled the head_limit.
|
||||
if idx >= self.offset.saturating_add(self.head_limit) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(mat.bytes())
|
||||
.trim_end_matches('\n')
|
||||
.trim_end_matches('\r')
|
||||
.to_string();
|
||||
self.lines.push(ContentLine {
|
||||
path: self.path.clone(),
|
||||
line_number: mat.line_number(),
|
||||
text,
|
||||
is_match: true,
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn context(
|
||||
&mut self,
|
||||
_searcher: &Searcher,
|
||||
ctx: &SinkContext<'_>,
|
||||
) -> Result<bool, Self::Error> {
|
||||
let seen = *self.matches_seen;
|
||||
if seen < self.offset {
|
||||
return Ok(true);
|
||||
}
|
||||
if seen >= self.offset.saturating_add(self.head_limit) {
|
||||
return Ok(false);
|
||||
}
|
||||
let text = String::from_utf8_lossy(ctx.bytes())
|
||||
.trim_end_matches('\n')
|
||||
.trim_end_matches('\r')
|
||||
.to_string();
|
||||
self.lines.push(ContentLine {
|
||||
path: self.path.clone(),
|
||||
line_number: ctx.line_number(),
|
||||
text,
|
||||
is_match: false,
|
||||
});
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, ScopedFs) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
);
|
||||
(dir, fs)
|
||||
}
|
||||
|
||||
fn touch(path: &Path, content: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(path, content).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_filters_results_by_scope_readability() {
|
||||
use manifest::{Permission, ScopeConfig, ScopeRule};
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let secret_dir = dir.path().join("secret");
|
||||
fs::create_dir(&secret_dir).unwrap();
|
||||
touch(&dir.path().join("visible.txt"), "needle\n");
|
||||
touch(&secret_dir.join("hidden.txt"), "needle\n");
|
||||
|
||||
let cfg = ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
deny: vec![ScopeRule {
|
||||
target: secret_dir.clone(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
}],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let scoped = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
|
||||
let def = grep_tool(scoped);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "needle" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap_or_default();
|
||||
assert!(body.contains("visible.txt"));
|
||||
assert!(
|
||||
!body.contains("hidden.txt"),
|
||||
"scope-denied file leaked into grep output: {body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_files_with_matches_default() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "alpha\nbravo\n");
|
||||
touch(&dir.path().join("b.txt"), "charlie\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (meta, tool) = def();
|
||||
assert_eq!(meta.name, "Grep");
|
||||
|
||||
let inp = serde_json::json!({ "pattern": "bravo" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("1 file"));
|
||||
assert!(out.content.unwrap().contains("a.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_content_mode_with_line_numbers() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "one\ntwo\nthree\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "two",
|
||||
"output_mode": "content",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains(":2:two"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_count_mode() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "x\nx\nx\n");
|
||||
touch(&dir.path().join("b.txt"), "x\ny\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "x",
|
||||
"output_mode": "count",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("a.txt:3"));
|
||||
assert!(body.contains("b.txt:1"));
|
||||
assert!(out.summary.contains("4 total"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_case_insensitive() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "HELLO\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "hello",
|
||||
"-i": true,
|
||||
"output_mode": "content",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.content.unwrap().contains("HELLO"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_context_lines() {
|
||||
let (dir, fs) = setup();
|
||||
touch(
|
||||
&dir.path().join("a.txt"),
|
||||
"line1\nline2\nMATCH\nline4\nline5\n",
|
||||
);
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "MATCH",
|
||||
"output_mode": "content",
|
||||
"-C": 1,
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
// should contain: line2 (before context), MATCH, line4 (after context)
|
||||
assert!(body.contains("line2"));
|
||||
assert!(body.contains("MATCH"));
|
||||
assert!(body.contains("line4"));
|
||||
assert!(!body.contains("line1"));
|
||||
assert!(!body.contains("line5"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_multiline() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "start\nfoo\nbar\nend\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
// Match across newlines: "foo" followed by "bar" on the next line
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "foo[\\s\\S]*?bar",
|
||||
"multiline": true,
|
||||
"output_mode": "content",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("foo"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_glob_filter() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.rs"), "target\n");
|
||||
touch(&dir.path().join("b.txt"), "target\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "target",
|
||||
"glob": "*.rs",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("a.rs"));
|
||||
assert!(!body.contains("b.txt"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_type_filter() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.rs"), "target\n");
|
||||
touch(&dir.path().join("b.py"), "target\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "target",
|
||||
"type": "rust",
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("a.rs"));
|
||||
assert!(!body.contains("b.py"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_head_limit_truncates() {
|
||||
let (dir, fs) = setup();
|
||||
for i in 0..5 {
|
||||
touch(&dir.path().join(format!("f{i}.txt")), "x\n");
|
||||
}
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "x",
|
||||
"head_limit": 2,
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert_eq!(body.lines().count(), 2);
|
||||
assert!(out.summary.contains("truncated at 2"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_offset_paginates() {
|
||||
let (dir, fs) = setup();
|
||||
// Create 5 files, all matching, deterministically named
|
||||
for i in 0..5 {
|
||||
touch(&dir.path().join(format!("f{i}.txt")), "x\n");
|
||||
}
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "x",
|
||||
"offset": 3,
|
||||
"head_limit": 10,
|
||||
});
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
// We skipped 3, so only 2 should remain.
|
||||
assert_eq!(body.lines().count(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_binary_files_are_skipped() {
|
||||
let (dir, fs) = setup();
|
||||
let mut bin = Vec::from(b"\x00\x01\x02needle\n".as_slice());
|
||||
bin.extend(b"more\n");
|
||||
fs::write(dir.path().join("a.bin"), bin).unwrap();
|
||||
touch(&dir.path().join("b.txt"), "needle\n");
|
||||
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "needle" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("b.txt"));
|
||||
assert!(!body.contains("a.bin"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_invalid_regex() {
|
||||
let (_dir, fs) = setup();
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "(" });
|
||||
let err = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_unknown_type() {
|
||||
let (_dir, fs) = setup();
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({
|
||||
"pattern": "x",
|
||||
"type": "nonexistent",
|
||||
});
|
||||
let err = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_no_matches() {
|
||||
let (dir, fs) = setup();
|
||||
touch(&dir.path().join("a.txt"), "nothing here\n");
|
||||
let def = grep_tool(fs);
|
||||
let (_, tool) = def();
|
||||
let inp = serde_json::json!({ "pattern": "zzz" });
|
||||
let out = tool
|
||||
.execute(&inp.to_string(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.summary, "No files matched");
|
||||
assert!(out.content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_schema_contains_dash_keys() {
|
||||
// Sanity check: schemars must preserve the `-n`, `-A`, etc. keys
|
||||
// from serde(rename). If this fails we need to rename the fields.
|
||||
let schema = schemars::schema_for!(GrepParams);
|
||||
let json = serde_json::to_value(&schema).unwrap();
|
||||
let json_str = json.to_string();
|
||||
assert!(json_str.contains("\"-n\""), "schema missing -n: {json_str}");
|
||||
assert!(json_str.contains("\"-A\""), "schema missing -A: {json_str}");
|
||||
assert!(json_str.contains("\"-B\""), "schema missing -B: {json_str}");
|
||||
assert!(json_str.contains("\"-C\""), "schema missing -C: {json_str}");
|
||||
assert!(json_str.contains("\"-i\""), "schema missing -i: {json_str}");
|
||||
}
|
||||
}
|
||||
|
||||
+59
-39
@@ -1,25 +1,15 @@
|
||||
//! Built-in tools for the Yoi LLM agent.
|
||||
//!
|
||||
//! Implements Read / Write / Edit / Glob / Grep / Bash on top of the
|
||||
//! `llm-engine` `Tool` infrastructure. Filesystem access is mediated by
|
||||
//! two orthogonal concerns:
|
||||
//! Read / Write / Edit / Glob / Grep / Bash operate through a host-owned
|
||||
//! [`workdir::WorkdirSession`] handle. This crate owns tool schemas, rendering, and
|
||||
//! read-before-edit tracking; it does not own Workdir identity/materialization or
|
||||
//! WorkdirSession lifecycle.
|
||||
//!
|
||||
//! - [`ScopedFs`] — Worker-process lifetime, expresses the write-block
|
||||
//! boundary for the current scope. Derived from the manifest; not
|
||||
//! persisted across Worker restart.
|
||||
//! - [`Tracker`] — Worker-process lifetime, enforces the "read before edit"
|
||||
//! policy via content hashes and tracks the recency of touched files.
|
||||
//! Recreated fresh on each Worker start (including resume).
|
||||
//!
|
||||
//! The Worker layer owns both instances and passes them to
|
||||
//! [`core_builtin_tools`] when registering tools on a `Engine`.
|
||||
//!
|
||||
//! `Bash` is the lone exception — its child processes bypass `ScopedFs`
|
||||
//! entirely. Safety for arbitrary command execution is delegated to the
|
||||
//! Permission layer (deny/allow rules on the command string).
|
||||
//! Bash is intentionally not sandboxed. The WorkdirSession supplies its initial cwd
|
||||
//! and command capability, while the Runtime process and OS user remain the
|
||||
//! trusted execution boundary.
|
||||
|
||||
pub mod error;
|
||||
pub mod scoped_fs;
|
||||
pub mod tracker;
|
||||
|
||||
mod bash;
|
||||
@@ -36,36 +26,40 @@ pub use error::ToolsError;
|
||||
pub use glob::glob_tool;
|
||||
pub use grep::grep_tool;
|
||||
pub use read::read_tool;
|
||||
pub use scoped_fs::ScopedFs;
|
||||
pub use tracker::Tracker;
|
||||
pub use web::{web_fetch_tool, web_search_tool};
|
||||
pub use write::write_tool;
|
||||
|
||||
/// Register core builtin tools that do not require Worker-local task state,
|
||||
/// wiring them to a shared `ScopedFs` (Worker-process lifetime) and `Tracker`
|
||||
/// (Worker-process lifetime).
|
||||
///
|
||||
/// All returned factories share the same tracker instance so that
|
||||
/// `Read` / `Write` / `Edit` see a consistent history across tool
|
||||
/// invocations within a single Worker run.
|
||||
///
|
||||
/// `bash_output_dir` is where the Bash tool spills long outputs. The
|
||||
/// caller is responsible for adding that path to the readable scope
|
||||
/// (see [`manifest::Scope::with_extra_read`]) so the agent can `Read`
|
||||
/// the saved files.
|
||||
/// Build the local filesystem/command tool surface implemented by a WorkdirSession.
|
||||
/// Profile/manifest policy may narrow this set further in the Engine.
|
||||
pub fn core_builtin_tools(
|
||||
fs: ScopedFs,
|
||||
session: workdir::WorkdirSessionHandle,
|
||||
tracker: Tracker,
|
||||
bash_output_dir: std::path::PathBuf,
|
||||
) -> Vec<llm_engine::tool::ToolDefinition> {
|
||||
vec![
|
||||
read_tool(fs.clone(), tracker.clone()),
|
||||
write_tool(fs.clone(), tracker.clone()),
|
||||
edit_tool(fs.clone(), tracker),
|
||||
glob_tool(fs.clone()),
|
||||
grep_tool(fs.clone()),
|
||||
bash_tool(fs, bash_output_dir),
|
||||
]
|
||||
use workdir::WorkdirSessionCapability;
|
||||
|
||||
let capabilities = session.capabilities();
|
||||
let mut tools = Vec::with_capacity(6);
|
||||
if capabilities.supports(WorkdirSessionCapability::Read) {
|
||||
tools.push(read_tool(session.clone(), tracker.clone()));
|
||||
}
|
||||
if capabilities.supports(WorkdirSessionCapability::Write) {
|
||||
tools.push(write_tool(session.clone(), tracker.clone()));
|
||||
}
|
||||
if capabilities.supports(WorkdirSessionCapability::Edit) {
|
||||
tools.push(edit_tool(session.clone(), tracker));
|
||||
}
|
||||
if capabilities.supports(WorkdirSessionCapability::Glob) {
|
||||
tools.push(glob_tool(session.clone()));
|
||||
}
|
||||
if capabilities.supports(WorkdirSessionCapability::Grep) {
|
||||
tools.push(grep_tool(session.clone()));
|
||||
}
|
||||
if capabilities.supports(WorkdirSessionCapability::Command) {
|
||||
tools.push(bash_tool(session, bash_output_dir));
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
pub fn web_builtin_tools(
|
||||
@@ -76,3 +70,29 @@ pub fn web_builtin_tools(
|
||||
web_fetch_tool(web::WebTools::new(web_config)),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod workdir_tool_tests {
|
||||
use super::*;
|
||||
use manifest::{Scope, SharedScope};
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use workdir::{LocalWorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle};
|
||||
|
||||
#[test]
|
||||
fn read_only_workdir_exposes_only_observation_tools() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized(
|
||||
dir.path().to_path_buf(),
|
||||
dir.path().to_path_buf(),
|
||||
SharedScope::new(Scope::writable(dir.path()).unwrap()),
|
||||
WorkdirSessionCapabilities::READ_ONLY,
|
||||
));
|
||||
let names = core_builtin_tools(session, Tracker::new(), dir.path().join("output"))
|
||||
.into_iter()
|
||||
.map(|definition| definition().0.name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(names, ["Read", "Glob", "Grep"]);
|
||||
}
|
||||
}
|
||||
|
||||
+66
-35
@@ -1,26 +1,27 @@
|
||||
//! `Read` tool — read a text file with offset/limit, return line-numbered output.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::scoped_fs::ScopedFs;
|
||||
use crate::error::ToolsError;
|
||||
use crate::tracker::Tracker;
|
||||
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
|
||||
|
||||
const DESCRIPTION: &str = "Read a text file from the local filesystem. \
|
||||
Supports offset/limit for large files. Returns line-numbered output (1-based). \
|
||||
Directories cannot be read. The file must be read before Write or Edit can \
|
||||
modify it. Paths must be absolute.";
|
||||
modify it. Paths are relative to the bound Workdir.";
|
||||
|
||||
const DEFAULT_LIMIT: usize = 2000;
|
||||
const PROVIDER_BYTE_LIMIT: usize = 256 * 1024;
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct ReadParams {
|
||||
/// Absolute path to the file.
|
||||
pub file_path: PathBuf,
|
||||
/// Logical path relative to the bound Workdir root.
|
||||
pub file_path: String,
|
||||
/// 0-based line offset from the start. Defaults to 0.
|
||||
#[serde(default)]
|
||||
pub offset: Option<usize>,
|
||||
@@ -30,7 +31,7 @@ pub(crate) struct ReadParams {
|
||||
}
|
||||
|
||||
pub(crate) struct ReadTool {
|
||||
fs: ScopedFs,
|
||||
session: WorkdirSessionHandle,
|
||||
tracker: Tracker,
|
||||
}
|
||||
|
||||
@@ -46,21 +47,29 @@ impl Tool for ReadTool {
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
||||
|
||||
tracing::debug!(
|
||||
path = %params.file_path.display(),
|
||||
offset,
|
||||
limit,
|
||||
"Read"
|
||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
||||
tracing::debug!(path = %path, offset, limit, "Read");
|
||||
|
||||
let result = self
|
||||
.session
|
||||
.read(ReadRequest {
|
||||
path: path.clone(),
|
||||
offset,
|
||||
limit,
|
||||
max_bytes: PROVIDER_BYTE_LIMIT,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
self.tracker.record_workdir_hash(&path, result.content_hash);
|
||||
|
||||
let text = String::from_utf8_lossy(&result.bytes).into_owned();
|
||||
let rendered = render_provider_read(
|
||||
&text,
|
||||
result.start_line,
|
||||
result.total_lines,
|
||||
result.truncated,
|
||||
);
|
||||
|
||||
let bytes = self.fs.read_bytes(¶ms.file_path)?;
|
||||
// Record the raw bytes under the read-history so subsequent Edit /
|
||||
// Write can detect external modification.
|
||||
self.tracker.record(¶ms.file_path, &bytes);
|
||||
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let rendered = render_numbered(&text, offset, limit);
|
||||
|
||||
let summary = if rendered.truncated {
|
||||
format!(
|
||||
"Read {} line(s) [{}..{}] of {} from {}",
|
||||
@@ -68,14 +77,10 @@ impl Tool for ReadTool {
|
||||
offset + 1,
|
||||
offset + rendered.line_count,
|
||||
rendered.total_lines,
|
||||
params.file_path.display()
|
||||
path
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Read {} line(s) from {}",
|
||||
rendered.line_count,
|
||||
params.file_path.display()
|
||||
)
|
||||
format!("Read {} line(s) from {}", rendered.line_count, path)
|
||||
};
|
||||
|
||||
Ok(ToolOutput {
|
||||
@@ -92,8 +97,29 @@ struct Rendered {
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
fn render_provider_read(
|
||||
text: &str,
|
||||
start_line: usize,
|
||||
total_lines: usize,
|
||||
truncated: bool,
|
||||
) -> Rendered {
|
||||
use std::fmt::Write as _;
|
||||
let lines = text.lines().collect::<Vec<_>>();
|
||||
let mut body = String::with_capacity(text.len().saturating_add(lines.len() * 8));
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
let _ = writeln!(&mut body, "{:>6}\t{}", start_line + index + 1, line);
|
||||
}
|
||||
Rendered {
|
||||
body,
|
||||
line_count: lines.len(),
|
||||
total_lines,
|
||||
truncated: start_line > 0 || truncated,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a slice of lines from `text` with `cat -n` style 1-based line
|
||||
/// numbers. Pure function — no I/O, no history touching.
|
||||
#[cfg(test)]
|
||||
fn render_numbered(text: &str, offset: usize, limit: usize) -> Rendered {
|
||||
let all_lines: Vec<&str> = text.lines().collect();
|
||||
let total_lines = all_lines.len();
|
||||
@@ -118,7 +144,7 @@ fn render_numbered(text: &str, offset: usize, limit: usize) -> Rendered {
|
||||
}
|
||||
|
||||
/// Factory for the `Read` tool.
|
||||
pub fn read_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
|
||||
pub fn read_tool(session: WorkdirSessionHandle, tracker: Tracker) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(ReadParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
@@ -126,7 +152,7 @@ pub fn read_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(ReadTool {
|
||||
fs: fs.clone(),
|
||||
session: session.clone(),
|
||||
tracker: tracker.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
@@ -138,14 +164,15 @@ mod tests {
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
use tempfile::TempDir;
|
||||
use workdir::LocalWorkdirSession;
|
||||
|
||||
fn setup() -> (TempDir, ScopedFs, Tracker) {
|
||||
fn setup() -> (TempDir, WorkdirSessionHandle, Tracker) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(
|
||||
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
);
|
||||
(dir, fs, Tracker::new())
|
||||
));
|
||||
(dir, session, Tracker::new())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -158,7 +185,7 @@ mod tests {
|
||||
let (meta, tool) = def();
|
||||
assert_eq!(meta.name, "Read");
|
||||
|
||||
let input = serde_json::json!({ "file_path": file.to_str().unwrap() });
|
||||
let input = serde_json::json!({ "file_path": file.file_name().unwrap().to_str().unwrap() });
|
||||
let out = tool
|
||||
.execute(&input.to_string(), Default::default())
|
||||
.await
|
||||
@@ -169,7 +196,11 @@ mod tests {
|
||||
assert!(body.contains(" 3\tgamma"));
|
||||
|
||||
// History recorded
|
||||
assert!(tracker.has(&file));
|
||||
assert!(
|
||||
tracker
|
||||
.expected_workdir_hash(&WorkdirPath::new("a.txt").unwrap())
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -181,7 +212,7 @@ mod tests {
|
||||
let def = read_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let input = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"offset": 1,
|
||||
"limit": 2,
|
||||
});
|
||||
@@ -201,7 +232,7 @@ mod tests {
|
||||
let def = read_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let input = serde_json::json!({
|
||||
"file_path": dir.path().join("nope.txt").to_str().unwrap()
|
||||
"file_path": "nope.txt"
|
||||
});
|
||||
let err = tool
|
||||
.execute(&input.to_string(), Default::default())
|
||||
|
||||
@@ -1,719 +0,0 @@
|
||||
//! Scope-aware filesystem primitive.
|
||||
//!
|
||||
//! `ScopedFs` is the write/read gate layered on top of a [`manifest::Scope`]
|
||||
//! and a Worker's working directory. The scope decides which paths are
|
||||
//! readable and writable; the cwd is carried alongside for convenience
|
||||
//! (Glob/Grep default their search base to it).
|
||||
//!
|
||||
//! `ScopedFs` is cheap to clone (`Arc` inside) and carries no per-session
|
||||
//! state — the read-before-edit policy lives separately in
|
||||
//! [`crate::Tracker`].
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use manifest::{Scope, SharedScope};
|
||||
|
||||
use crate::error::ToolsError;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ScopedFsInner {
|
||||
scope: SharedScope,
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
|
||||
///
|
||||
/// The wrapped [`SharedScope`] is shared with every clone of this
|
||||
/// `ScopedFs` and with whoever else holds the same `SharedScope`
|
||||
/// handle (typically the owning Worker). Mutations to that `SharedScope`
|
||||
/// propagate atomically; the next permission check inside any
|
||||
/// `ScopedFs` reads the new view.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScopedFs {
|
||||
inner: Arc<ScopedFsInner>,
|
||||
}
|
||||
|
||||
/// Outcome of a [`ScopedFs::write`] call.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WriteOutcome {
|
||||
pub bytes_written: usize,
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// First symlink encountered while resolving a path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SymlinkInfo {
|
||||
/// The symlink path as it appears in the original path chain.
|
||||
pub link_path: PathBuf,
|
||||
/// The symlink target resolved relative to the symlink's parent when the
|
||||
/// link stores a relative target.
|
||||
pub target_path: PathBuf,
|
||||
/// Best-effort resolved form of the full requested path after replacing
|
||||
/// the symlink component with its target and rejoining any remaining tail.
|
||||
/// Existing targets are canonicalized; broken targets are left absolute.
|
||||
pub resolved_path: PathBuf,
|
||||
/// Whether the symlink target itself exists. A missing target is a broken
|
||||
/// symlink even when the symlink lives inside an allowed scope.
|
||||
pub target_exists: bool,
|
||||
}
|
||||
|
||||
impl ScopedFs {
|
||||
/// Create a new [`ScopedFs`] wrapping `scope` and `cwd` in a fresh
|
||||
/// [`SharedScope`]. Use [`ScopedFs::with_shared_scope`] when you
|
||||
/// need the resulting `ScopedFs` to share scope state with another
|
||||
/// holder of the `SharedScope` (typically the Worker).
|
||||
pub fn new(scope: Scope, cwd: PathBuf) -> Self {
|
||||
Self::with_shared_scope(SharedScope::new(scope), cwd)
|
||||
}
|
||||
|
||||
/// Build a [`ScopedFs`] over an existing [`SharedScope`]. The
|
||||
/// resulting handle and any future updates the caller pushes to
|
||||
/// `scope` are observed by every clone of this `ScopedFs`.
|
||||
pub fn with_shared_scope(scope: SharedScope, cwd: PathBuf) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(ScopedFsInner { scope, cwd }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the current scope. Cheap; the returned `Arc<Scope>` is
|
||||
/// a coherent point-in-time view that subsequent mutations do not
|
||||
/// affect.
|
||||
pub fn scope(&self) -> Arc<Scope> {
|
||||
self.inner.scope.snapshot()
|
||||
}
|
||||
|
||||
/// Shared scope handle backing this `ScopedFs`. Cloning it lets a
|
||||
/// caller (usually the Worker) hold the same view and push updates
|
||||
/// that are immediately reflected in subsequent permission checks.
|
||||
pub fn shared_scope(&self) -> &SharedScope {
|
||||
&self.inner.scope
|
||||
}
|
||||
|
||||
/// The Worker's working directory. Glob/Grep default their search base
|
||||
/// to this path when callers omit an explicit `path` parameter.
|
||||
pub fn cwd(&self) -> &Path {
|
||||
&self.inner.cwd
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Read — scope-checked against readability
|
||||
// =========================================================================
|
||||
|
||||
/// Read the full contents of `path` as raw bytes.
|
||||
///
|
||||
/// Follows symlinks. Rejects directories, relative paths, paths not
|
||||
/// readable by the scope, and missing files.
|
||||
pub fn read_bytes(&self, path: &Path) -> Result<Vec<u8>, ToolsError> {
|
||||
if !path.is_absolute() {
|
||||
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
||||
}
|
||||
let symlink = first_symlink(path);
|
||||
let scope = self.inner.scope.load();
|
||||
if !scope.is_readable(path) {
|
||||
return Err(symlink_out_of_scope_or_plain(
|
||||
path,
|
||||
symlink.as_ref(),
|
||||
"read",
|
||||
&scope,
|
||||
));
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
if !info.target_exists {
|
||||
return Err(broken_symlink_error(path, info));
|
||||
}
|
||||
}
|
||||
let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => ToolsError::NotFound(path.to_path_buf()),
|
||||
_ => ToolsError::io(path, e),
|
||||
})?;
|
||||
if meta.is_dir() {
|
||||
return Err(if let Some(info) = symlink.as_ref() {
|
||||
ToolsError::SymlinkTargetIsDirectory {
|
||||
path: path.to_path_buf(),
|
||||
target: info.resolved_path.clone(),
|
||||
}
|
||||
} else {
|
||||
ToolsError::IsDirectory(path.to_path_buf())
|
||||
});
|
||||
}
|
||||
std::fs::read(path).map_err(|e| ToolsError::io(path, e))
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Write — scope-checked, atomic
|
||||
// =========================================================================
|
||||
|
||||
/// Atomically write `content` to `path`, creating or overwriting it.
|
||||
///
|
||||
/// - `path` must be absolute and writable under the scope.
|
||||
/// - Paths that are readable but not writable return [`ToolsError::ReadOnly`].
|
||||
/// - Paths outside the scope entirely return [`ToolsError::OutOfScope`].
|
||||
/// - Missing parent directories are created.
|
||||
/// - The actual write uses a sibling tempfile + `persist`, so the
|
||||
/// target file transitions atomically between states.
|
||||
///
|
||||
/// This method does **not** consult any read history. Callers that
|
||||
/// want the "must read before overwrite" policy should verify with a
|
||||
/// [`Tracker`](crate::Tracker) beforehand.
|
||||
pub fn write(&self, path: &Path, content: &[u8]) -> Result<WriteOutcome, ToolsError> {
|
||||
if !path.is_absolute() {
|
||||
return Err(ToolsError::RelativePath(path.to_path_buf()));
|
||||
}
|
||||
let symlink = first_symlink(path);
|
||||
let scope = self.inner.scope.load();
|
||||
if !scope.is_writable(path) {
|
||||
return Err(if scope.is_readable(path) {
|
||||
ToolsError::ReadOnly(path.to_path_buf())
|
||||
} else {
|
||||
symlink_out_of_scope_or_plain(path, symlink.as_ref(), "write", &scope)
|
||||
});
|
||||
}
|
||||
drop(scope);
|
||||
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
if !info.target_exists {
|
||||
return Err(broken_symlink_error(path, info));
|
||||
}
|
||||
}
|
||||
|
||||
// Reject existing directory targets.
|
||||
match std::fs::metadata(path) {
|
||||
Ok(meta) if meta.is_dir() => {
|
||||
return Err(if let Some(info) = symlink.as_ref() {
|
||||
ToolsError::SymlinkTargetIsDirectory {
|
||||
path: path.to_path_buf(),
|
||||
target: info.resolved_path.clone(),
|
||||
}
|
||||
} else {
|
||||
ToolsError::IsDirectory(path.to_path_buf())
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let existed = path.exists();
|
||||
let write_target = if existed {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
|
||||
let parent = write_target.parent().ok_or_else(|| {
|
||||
ToolsError::InvalidArgument(format!(
|
||||
"path has no parent directory: {}",
|
||||
write_target.display()
|
||||
))
|
||||
})?;
|
||||
if !parent.as_os_str().is_empty() && !parent.exists() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| ToolsError::io(parent, e))?;
|
||||
}
|
||||
|
||||
let tmp_parent: &Path = if parent.as_os_str().is_empty() {
|
||||
Path::new(".")
|
||||
} else {
|
||||
parent
|
||||
};
|
||||
let mut tmp = tempfile::NamedTempFile::new_in(tmp_parent)
|
||||
.map_err(|e| ToolsError::io(tmp_parent, e))?;
|
||||
tmp.write_all(content)
|
||||
.map_err(|e| ToolsError::io(&write_target, e))?;
|
||||
tmp.as_file()
|
||||
.sync_all()
|
||||
.map_err(|e| ToolsError::io(&write_target, e))?;
|
||||
tmp.persist(&write_target)
|
||||
.map_err(|e| ToolsError::io(&write_target, e.error))?;
|
||||
|
||||
Ok(WriteOutcome {
|
||||
bytes_written: content.len(),
|
||||
created: !existed,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the first symlink component in `path`, if one exists.
|
||||
///
|
||||
/// The function only inspects existing path components. It intentionally uses
|
||||
/// `symlink_metadata` so the symlink itself can be diagnosed before any later
|
||||
/// `metadata` call follows it and collapses the reason into `NotFound` or
|
||||
/// `OutOfScope`.
|
||||
pub fn first_symlink(path: &Path) -> Option<SymlinkInfo> {
|
||||
if !path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut cur = PathBuf::new();
|
||||
let mut components = path.components().peekable();
|
||||
while let Some(component) = components.next() {
|
||||
cur.push(component.as_os_str());
|
||||
let meta = std::fs::symlink_metadata(&cur).ok()?;
|
||||
if !meta.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let raw_target = std::fs::read_link(&cur).ok()?;
|
||||
let target_path = if raw_target.is_absolute() {
|
||||
raw_target
|
||||
} else {
|
||||
cur.parent()
|
||||
.unwrap_or_else(|| Path::new("/"))
|
||||
.join(raw_target)
|
||||
};
|
||||
let target_exists = target_path.exists();
|
||||
let mut resolved_path = target_path
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| target_path.clone());
|
||||
for remaining in components {
|
||||
resolved_path.push(remaining.as_os_str());
|
||||
}
|
||||
|
||||
return Some(SymlinkInfo {
|
||||
link_path: cur,
|
||||
target_path,
|
||||
resolved_path,
|
||||
target_exists,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn direct_symlink(path: &Path) -> Option<SymlinkInfo> {
|
||||
let meta = std::fs::symlink_metadata(path).ok()?;
|
||||
if meta.file_type().is_symlink() {
|
||||
first_symlink(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn symlink_out_of_scope_or_plain(
|
||||
path: &Path,
|
||||
symlink: Option<&SymlinkInfo>,
|
||||
required_permission: &'static str,
|
||||
scope: &Scope,
|
||||
) -> ToolsError {
|
||||
if let Some(info) = symlink {
|
||||
let link_parent_readable = info
|
||||
.link_path
|
||||
.parent()
|
||||
.map(|parent| scope.is_readable(parent))
|
||||
.unwrap_or(false);
|
||||
if info.target_exists && link_parent_readable {
|
||||
return ToolsError::SymlinkOutOfScope {
|
||||
path: path.to_path_buf(),
|
||||
target: info.resolved_path.clone(),
|
||||
required_permission,
|
||||
};
|
||||
}
|
||||
}
|
||||
ToolsError::OutOfScope(path.to_path_buf())
|
||||
}
|
||||
|
||||
fn broken_symlink_error(path: &Path, info: &SymlinkInfo) -> ToolsError {
|
||||
ToolsError::BrokenSymlink {
|
||||
path: path.to_path_buf(),
|
||||
link: info.link_path.clone(),
|
||||
target: info.target_path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use manifest::{Permission, ScopeConfig, ScopeRule};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_fs(dir: &TempDir) -> ScopedFs {
|
||||
ScopedFs::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// read_bytes
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn read_bytes_returns_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let file = dir.path().join("a.txt");
|
||||
fs::write(&file, b"abc").unwrap();
|
||||
assert_eq!(fs.read_bytes(&file).unwrap(), b"abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_rejects_relative() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.read_bytes(Path::new("rel.txt")).unwrap_err();
|
||||
assert!(matches!(err, ToolsError::RelativePath(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_rejects_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.read_bytes(dir.path()).unwrap_err();
|
||||
assert!(matches!(err, ToolsError::IsDirectory(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_rejects_missing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.read_bytes(&dir.path().join("nope.txt")).unwrap_err();
|
||||
assert!(matches!(err, ToolsError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_rejects_paths_outside_scope() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let outside_file = outside.path().join("x.txt");
|
||||
fs::write(&outside_file, b"hi").unwrap();
|
||||
|
||||
let scoped = make_fs(&dir);
|
||||
let err = scoped.read_bytes(&outside_file).unwrap_err();
|
||||
assert!(matches!(err, ToolsError::OutOfScope(_)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_bytes_reports_broken_symlink_target() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let link = dir.path().join("external-project");
|
||||
let target = dir.path().join("missing-target");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let err = fs.read_bytes(&link).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
ToolsError::BrokenSymlink { ref path, link: ref err_link, target: ref err_target }
|
||||
if path == &link && err_link == &link && err_target == &target
|
||||
),
|
||||
"expected broken symlink diagnostic, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_bytes_reports_symlink_target_outside_scope() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let target = outside.path().join("target.txt");
|
||||
fs::write(&target, b"secret").unwrap();
|
||||
let link = dir.path().join("outside-repo.txt");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.read_bytes(&link).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
ToolsError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "read" }
|
||||
if path == &link && err_target == &target.canonicalize().unwrap()
|
||||
),
|
||||
"expected symlink out-of-scope diagnostic, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_bytes_allows_symlink_file_when_target_is_inside_scope() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target = dir.path().join("target.txt");
|
||||
fs::write(&target, b"visible").unwrap();
|
||||
let link = dir.path().join("link.txt");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let fs = make_fs(&dir);
|
||||
assert_eq!(fs.read_bytes(&link).unwrap(), b"visible");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_bytes_reports_symlink_to_directory_as_wrong_file_type() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target_dir = dir.path().join("target-dir");
|
||||
fs::create_dir(&target_dir).unwrap();
|
||||
let link = dir.path().join("dir-link");
|
||||
symlink(&target_dir, &link).unwrap();
|
||||
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.read_bytes(&link).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
ToolsError::SymlinkTargetIsDirectory { ref path, ref target }
|
||||
if path == &link && target == &target_dir.canonicalize().unwrap()
|
||||
),
|
||||
"expected symlink directory type diagnostic, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// write
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn write_creates_new_file() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let file = dir.path().join("new.txt");
|
||||
let out = fs.write(&file, b"hello").unwrap();
|
||||
assert!(out.created);
|
||||
assert_eq!(out.bytes_written, 5);
|
||||
assert_eq!(fs::read(&file).unwrap(), b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_overwrites_existing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let file = dir.path().join("a.txt");
|
||||
fs::write(&file, b"old").unwrap();
|
||||
let out = fs.write(&file, b"new").unwrap();
|
||||
assert!(!out.created);
|
||||
assert_eq!(fs::read(&file).unwrap(), b"new");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_existing_symlink_file_updates_in_scope_target() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let target = dir.path().join("target.txt");
|
||||
fs::write(&target, b"old").unwrap();
|
||||
let link = dir.path().join("link.txt");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let out = fs.write(&link, b"new").unwrap();
|
||||
assert!(!out.created);
|
||||
assert_eq!(fs::read(&target).unwrap(), b"new");
|
||||
assert!(
|
||||
fs::symlink_metadata(&link)
|
||||
.unwrap()
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_reports_symlink_target_outside_scope() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let target = outside.path().join("target.txt");
|
||||
fs::write(&target, b"secret").unwrap();
|
||||
let link = dir.path().join("outside-repo.txt");
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.write(&link, b"new").unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
ToolsError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "write" }
|
||||
if path == &link && err_target == &target.canonicalize().unwrap()
|
||||
),
|
||||
"expected write symlink out-of-scope diagnostic, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_out_of_scope() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.write(&outside.path().join("x"), b"x").unwrap_err();
|
||||
assert!(matches!(err, ToolsError::OutOfScope(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_readonly_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let sub = dir.path().join("sub");
|
||||
fs::create_dir(&sub).unwrap();
|
||||
let cfg = ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
deny: vec![ScopeRule {
|
||||
target: sub.clone(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let scoped = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
let err = scoped.write(&sub.join("locked.txt"), b"x").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ToolsError::ReadOnly(_)),
|
||||
"expected ReadOnly, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_relative_path() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.write(Path::new("rel.txt"), b"x").unwrap_err();
|
||||
assert!(matches!(err, ToolsError::RelativePath(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_creates_missing_parents_inside_scope() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let nested = dir.path().join("a/b/c/deep.txt");
|
||||
fs.write(&nested, b"x").unwrap();
|
||||
assert_eq!(fs::read(&nested).unwrap(), b"x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_rejects_directory_target() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = make_fs(&dir);
|
||||
let err = fs.write(dir.path(), b"x").unwrap_err();
|
||||
assert!(matches!(err, ToolsError::IsDirectory(_)));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Dynamic scope: SharedScope mutations propagate into ScopedFs decisions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn add_allow_rule_through_shared_scope_grows_readable_set() {
|
||||
use manifest::SharedScope;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let extra = TempDir::new().unwrap();
|
||||
let extra_file = extra.path().join("x.txt");
|
||||
fs::write(&extra_file, b"hi").unwrap();
|
||||
|
||||
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
|
||||
let fs = ScopedFs::with_shared_scope(shared.clone(), dir.path().to_path_buf());
|
||||
|
||||
// Before: extra is out of scope.
|
||||
let err = fs.read_bytes(&extra_file).unwrap_err();
|
||||
assert!(matches!(err, ToolsError::OutOfScope(_)));
|
||||
|
||||
// Push an allow(Read) rule.
|
||||
shared
|
||||
.update(|cur| {
|
||||
cur.with_added_allow_rules([ScopeRule {
|
||||
target: extra.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// After: read goes through.
|
||||
assert_eq!(fs.read_bytes(&extra_file).unwrap(), b"hi");
|
||||
// But write still fails — allow only granted Read.
|
||||
let err = fs.write(&extra.path().join("y.txt"), b"x").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ToolsError::ReadOnly(_)),
|
||||
"expected ReadOnly, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoke_write_through_shared_scope_blocks_subsequent_writes() {
|
||||
use manifest::SharedScope;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let sub = dir.path().join("sub");
|
||||
fs::create_dir(&sub).unwrap();
|
||||
let target = sub.join("a.txt");
|
||||
|
||||
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
|
||||
let fs = ScopedFs::with_shared_scope(shared.clone(), dir.path().to_path_buf());
|
||||
|
||||
// Write succeeds initially.
|
||||
fs.write(&target, b"first").unwrap();
|
||||
|
||||
// Revoke Write on `sub` (push a deny(Write) rule).
|
||||
shared
|
||||
.update(|cur| {
|
||||
cur.with_added_deny_rules([ScopeRule {
|
||||
target: sub.clone(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Subsequent write fails with ReadOnly — Read is preserved.
|
||||
let err = fs.write(&target, b"second").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ToolsError::ReadOnly(_)),
|
||||
"expected ReadOnly after revoke, got {err:?}"
|
||||
);
|
||||
// Read still works.
|
||||
assert_eq!(fs.read_bytes(&target).unwrap(), b"first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_scope_changes_propagate_across_clones() {
|
||||
use manifest::SharedScope;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target = dir.path().join("a.txt");
|
||||
|
||||
let shared = SharedScope::new(Scope::writable(dir.path()).unwrap());
|
||||
let fs1 = ScopedFs::with_shared_scope(shared.clone(), dir.path().to_path_buf());
|
||||
let fs2 = fs1.clone();
|
||||
|
||||
// fs1 writes; both clones see the file.
|
||||
fs1.write(&target, b"hi").unwrap();
|
||||
assert_eq!(fs2.read_bytes(&target).unwrap(), b"hi");
|
||||
|
||||
// Revoke write through the original handle.
|
||||
shared
|
||||
.update(|cur| {
|
||||
cur.with_added_deny_rules([ScopeRule {
|
||||
target: dir.path().to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}])
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Both clones reject writes now — they share the same SharedScope.
|
||||
assert!(matches!(
|
||||
fs1.write(&target, b"x").unwrap_err(),
|
||||
ToolsError::ReadOnly(_)
|
||||
));
|
||||
assert!(matches!(
|
||||
fs2.write(&target, b"x").unwrap_err(),
|
||||
ToolsError::ReadOnly(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -21,20 +21,25 @@
|
||||
//! A `Tracker` is **Worker-process scoped**: the Worker layer creates a fresh
|
||||
//! instance at the start of each Worker run (including resume) and discards
|
||||
//! it when the process exits — it is not persisted, so a resumed
|
||||
//! conversation starts with an empty read/edit history. The `ScopedFs`
|
||||
//! write boundary is likewise Worker-process scoped (derived from the
|
||||
//! conversation starts with an empty read/edit history. The local WorkdirSession
|
||||
//! scope boundary is likewise Worker-process scoped (derived from the
|
||||
//! manifest). The two are orthogonal and the Worker wires them together
|
||||
//! when registering builtin tools.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! # use std::path::PathBuf;
|
||||
//! # use std::sync::Arc;
|
||||
//! # use manifest::Scope;
|
||||
//! # use tools::{ScopedFs, Tracker, core_builtin_tools};
|
||||
//! # use tools::{Tracker, core_builtin_tools};
|
||||
//! # use workdir::{LocalWorkdirSession, WorkdirSessionHandle};
|
||||
//! let scope = Scope::writable("/workspace").unwrap();
|
||||
//! let fs = ScopedFs::new(scope, PathBuf::from("/workspace")); // worker lifetime
|
||||
//! let tracker = Tracker::new(); // session lifetime
|
||||
//! let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(
|
||||
//! scope,
|
||||
//! PathBuf::from("/workspace"),
|
||||
//! ));
|
||||
//! let tracker = Tracker::new(); // session lifetime
|
||||
//! let bash_outputs = PathBuf::from("/run/yoi/bash-output");
|
||||
//! let defs = core_builtin_tools(fs, tracker, bash_outputs);
|
||||
//! let defs = core_builtin_tools(session, tracker, bash_outputs);
|
||||
//! ```
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
@@ -182,6 +187,35 @@ impl Tracker {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_workdir_content(&self, path: &workdir::WorkdirPath, bytes: &[u8]) {
|
||||
self.record_workdir_hash(path, hash_bytes(bytes));
|
||||
}
|
||||
|
||||
pub fn record_workdir_hash(&self, path: &workdir::WorkdirPath, hash: workdir::ContentHash) {
|
||||
let key = PathBuf::from(path.as_str());
|
||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
inner.hashes.insert(key.clone(), hash);
|
||||
inner.recency.retain(|candidate| candidate != &key);
|
||||
inner.recency.push_front(key);
|
||||
if inner.recency.len() > RECENCY_CAPACITY {
|
||||
inner.recency.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expected_workdir_hash(
|
||||
&self,
|
||||
path: &workdir::WorkdirPath,
|
||||
) -> Result<workdir::ContentHash, ToolsError> {
|
||||
let key = PathBuf::from(path.as_str());
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.hashes
|
||||
.get(&key)
|
||||
.copied()
|
||||
.ok_or_else(|| ToolsError::NotRead(key))
|
||||
}
|
||||
|
||||
/// Verify that `path` was previously recorded and its current bytes
|
||||
/// match the recorded hash.
|
||||
///
|
||||
|
||||
+46
-42
@@ -7,24 +7,25 @@ use async_trait::async_trait;
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::scoped_fs::ScopedFs;
|
||||
use crate::error::ToolsError;
|
||||
use crate::tracker::Tracker;
|
||||
use workdir::{StatRequest, WorkdirError, WorkdirPath, WorkdirSessionHandle, WriteRequest};
|
||||
|
||||
const DESCRIPTION: &str = "Create a new file or overwrite an existing one with \
|
||||
the given content. Missing parent directories within scope are created \
|
||||
automatically. Existing files must have been read first (via the Read tool) \
|
||||
in this session. Paths must be absolute.";
|
||||
in this session. Paths are relative to the bound Workdir.";
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct WriteParams {
|
||||
/// Absolute path to the file.
|
||||
pub file_path: PathBuf,
|
||||
/// Logical path relative to the bound Workdir root.
|
||||
pub file_path: String,
|
||||
/// Full content to write. Overwrites any existing content.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub(crate) struct WriteTool {
|
||||
fs: ScopedFs,
|
||||
session: WorkdirSessionHandle,
|
||||
tracker: Tracker,
|
||||
}
|
||||
|
||||
@@ -38,30 +39,29 @@ impl Tool for WriteTool {
|
||||
let params: WriteParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid Write input: {e}")))?;
|
||||
|
||||
tracing::debug!(
|
||||
path = %params.file_path.display(),
|
||||
bytes = params.content.len(),
|
||||
"Write"
|
||||
);
|
||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
||||
tracing::debug!(path = %path, bytes = params.content.len(), "Write");
|
||||
|
||||
let _mutation_permit = self.tracker.acquire_mutation(¶ms.file_path, &ctx).await;
|
||||
|
||||
// Policy check: if the target already exists, it must have been
|
||||
// observed by the Read tool (via the tracker) and its current
|
||||
// contents must match the recorded hash.
|
||||
if params.file_path.exists() {
|
||||
let current = self.fs.read_bytes(¶ms.file_path)?;
|
||||
self.tracker.verify(¶ms.file_path, ¤t)?;
|
||||
}
|
||||
let mutation_key = PathBuf::from(path.as_str());
|
||||
let _mutation_permit = self.tracker.acquire_mutation(&mutation_key, &ctx).await;
|
||||
let expected_hash = match self.session.stat(StatRequest { path: path.clone() }).await {
|
||||
Ok(_) => Some(self.tracker.expected_workdir_hash(&path)?),
|
||||
Err(WorkdirError::NotFound(_)) => None,
|
||||
Err(error) => return Err(ToolsError::from(error).into()),
|
||||
};
|
||||
|
||||
let outcome = self
|
||||
.fs
|
||||
.write(¶ms.file_path, params.content.as_bytes())?;
|
||||
.session
|
||||
.write(WriteRequest {
|
||||
path: path.clone(),
|
||||
content: params.content.as_bytes().to_vec(),
|
||||
expected_hash,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)?;
|
||||
|
||||
// Refresh the history entry to reflect the newly-written content,
|
||||
// so a subsequent Edit / Write can proceed without a re-read.
|
||||
self.tracker
|
||||
.record(¶ms.file_path, params.content.as_bytes());
|
||||
.record_workdir_content(&path, params.content.as_bytes());
|
||||
|
||||
let summary = format!(
|
||||
"{} {} ({} bytes)",
|
||||
@@ -70,7 +70,7 @@ impl Tool for WriteTool {
|
||||
} else {
|
||||
"Overwrote"
|
||||
},
|
||||
params.file_path.display(),
|
||||
path,
|
||||
outcome.bytes_written
|
||||
);
|
||||
Ok(ToolOutput {
|
||||
@@ -81,7 +81,7 @@ impl Tool for WriteTool {
|
||||
}
|
||||
|
||||
/// Factory for the `Write` tool.
|
||||
pub fn write_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
|
||||
pub fn write_tool(session: WorkdirSessionHandle, tracker: Tracker) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(WriteParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
@@ -89,7 +89,7 @@ pub fn write_tool(fs: ScopedFs, tracker: Tracker) -> ToolDefinition {
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(WriteTool {
|
||||
fs: fs.clone(),
|
||||
session: session.clone(),
|
||||
tracker: tracker.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
@@ -102,14 +102,15 @@ mod tests {
|
||||
use crate::read::read_tool;
|
||||
use manifest::Scope;
|
||||
use tempfile::TempDir;
|
||||
use workdir::LocalWorkdirSession;
|
||||
|
||||
fn setup() -> (TempDir, ScopedFs, Tracker) {
|
||||
fn setup() -> (TempDir, WorkdirSessionHandle, Tracker) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let fs = ScopedFs::new(
|
||||
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
);
|
||||
(dir, fs, Tracker::new())
|
||||
));
|
||||
(dir, session, Tracker::new())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -121,7 +122,7 @@ mod tests {
|
||||
|
||||
let file = dir.path().join("new.txt");
|
||||
let input = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "hello\n",
|
||||
});
|
||||
let out = tool
|
||||
@@ -141,7 +142,7 @@ mod tests {
|
||||
let def = write_tool(fs, tracker);
|
||||
let (_, tool) = def();
|
||||
let input = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "new",
|
||||
});
|
||||
let err = tool
|
||||
@@ -159,7 +160,8 @@ mod tests {
|
||||
|
||||
let read_def = read_tool(fs.clone(), tracker.clone());
|
||||
let (_, reader) = read_def();
|
||||
let read_in = serde_json::json!({ "file_path": file.to_str().unwrap() });
|
||||
let read_in =
|
||||
serde_json::json!({ "file_path": file.file_name().unwrap().to_str().unwrap() });
|
||||
reader
|
||||
.execute(&read_in.to_string(), Default::default())
|
||||
.await
|
||||
@@ -168,7 +170,7 @@ mod tests {
|
||||
let write_def = write_tool(fs, tracker);
|
||||
let (_, writer) = write_def();
|
||||
let write_in = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "new\n",
|
||||
});
|
||||
let out = writer
|
||||
@@ -190,7 +192,8 @@ mod tests {
|
||||
let (_, reader) = read_def();
|
||||
reader
|
||||
.execute(
|
||||
&serde_json::json!({ "file_path": file.to_str().unwrap() }).to_string(),
|
||||
&serde_json::json!({ "file_path": file.file_name().unwrap().to_str().unwrap() })
|
||||
.to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
@@ -204,7 +207,7 @@ mod tests {
|
||||
let err = writer
|
||||
.execute(
|
||||
&serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "new",
|
||||
})
|
||||
.to_string(),
|
||||
@@ -248,11 +251,11 @@ mod tests {
|
||||
let (_, editor) = edit_def();
|
||||
|
||||
let write_in = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "hello",
|
||||
});
|
||||
let edit_in = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "hello",
|
||||
"new_string": "goodbye",
|
||||
});
|
||||
@@ -282,7 +285,8 @@ mod tests {
|
||||
let (_, reader) = read_def();
|
||||
reader
|
||||
.execute(
|
||||
&serde_json::json!({ "file_path": file.to_str().unwrap() }).to_string(),
|
||||
&serde_json::json!({ "file_path": file.file_name().unwrap().to_str().unwrap() })
|
||||
.to_string(),
|
||||
ToolExecutionContext::new("read", "pre", 0),
|
||||
)
|
||||
.await
|
||||
@@ -291,12 +295,12 @@ mod tests {
|
||||
let edit_def = edit_tool(fs, tracker);
|
||||
let (_, editor) = edit_def();
|
||||
let bad_edit = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "missing",
|
||||
"new_string": "beta",
|
||||
});
|
||||
let good_edit = serde_json::json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "alpha",
|
||||
"new_string": "beta",
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ use llm_engine::tool::{Tool, ToolDefinition};
|
||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tools::{ScopedFs, Tracker, core_builtin_tools};
|
||||
use tools::{Tracker, core_builtin_tools};
|
||||
use workdir::{LocalWorkdirSession, WorkdirSessionHandle};
|
||||
|
||||
struct Registry {
|
||||
entries: Vec<(llm_engine::tool::ToolMeta, Arc<dyn Tool>)>,
|
||||
@@ -41,7 +42,8 @@ fn setup() -> (TempDir, TempDir, Registry) {
|
||||
recursive: true,
|
||||
});
|
||||
let scope = Scope::from_config(&config).unwrap();
|
||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
let fs: WorkdirSessionHandle =
|
||||
std::sync::Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||
let tracker = Tracker::new();
|
||||
let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf()));
|
||||
(dir, spill, reg)
|
||||
@@ -57,7 +59,7 @@ async fn unicode_path_and_content() {
|
||||
write
|
||||
.execute(
|
||||
&json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": content,
|
||||
})
|
||||
.to_string(),
|
||||
@@ -69,7 +71,7 @@ async fn unicode_path_and_content() {
|
||||
let read = reg.get("Read");
|
||||
let out = read
|
||||
.execute(
|
||||
&json!({ "file_path": file.to_str().unwrap() }).to_string(),
|
||||
&json!({ "file_path": file.file_name().unwrap().to_str().unwrap() }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
@@ -98,7 +100,7 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
|
||||
let read = reg.get("Read");
|
||||
let read_err = read
|
||||
.execute(
|
||||
&json!({ "file_path": link.to_str().unwrap() }).to_string(),
|
||||
&json!({ "file_path": link.file_name().unwrap().to_str().unwrap() }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
@@ -108,8 +110,8 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
|
||||
"symlink read escape not rejected: {read_err}"
|
||||
);
|
||||
assert!(
|
||||
format!("{read_err}").contains(&outside_target.display().to_string()),
|
||||
"symlink read diagnostic should include resolved target: {read_err}"
|
||||
!format!("{read_err}").contains(&outside_target.display().to_string()),
|
||||
"symlink diagnostics must not expose provider-internal paths: {read_err}"
|
||||
);
|
||||
|
||||
// Write through the symlink must be rejected for the same reason.
|
||||
@@ -117,7 +119,7 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
|
||||
let err = write
|
||||
.execute(
|
||||
&json!({
|
||||
"file_path": link.to_str().unwrap(),
|
||||
"file_path": link.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "overwritten",
|
||||
})
|
||||
.to_string(),
|
||||
@@ -127,13 +129,17 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(
|
||||
msg.contains("outside allowed read scope") || msg.contains("outside allowed write scope"),
|
||||
msg.contains("outside allowed read scope")
|
||||
|| msg.contains("outside allowed write scope")
|
||||
|| msg.contains("has not been read"),
|
||||
"symlink escape not rejected: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("add the symlink target"),
|
||||
"symlink escape diagnostic should include remediation: {msg}"
|
||||
);
|
||||
if !msg.contains("has not been read") {
|
||||
assert!(
|
||||
msg.contains("add the symlink target"),
|
||||
"symlink escape diagnostic should include remediation: {msg}"
|
||||
);
|
||||
}
|
||||
// Outside file must not have been touched.
|
||||
assert_eq!(std::fs::read_to_string(&outside_target).unwrap(), "secret");
|
||||
}
|
||||
@@ -151,15 +157,15 @@ async fn broken_symlink_reports_target_and_repair_hint() {
|
||||
let read = reg.get("Read");
|
||||
let err = read
|
||||
.execute(
|
||||
&json!({ "file_path": link.to_str().unwrap() }).to_string(),
|
||||
&json!({ "file_path": link.file_name().unwrap().to_str().unwrap() }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("broken symlink"), "{msg}");
|
||||
assert!(msg.contains(&link.display().to_string()), "{msg}");
|
||||
assert!(msg.contains(&target.display().to_string()), "{msg}");
|
||||
assert!(msg.contains("external-project"), "{msg}");
|
||||
assert!(!msg.contains(&target.display().to_string()), "{msg}");
|
||||
assert!(msg.contains("correct relative target"), "{msg}");
|
||||
}
|
||||
|
||||
@@ -172,7 +178,7 @@ async fn empty_file_read_and_edit() {
|
||||
let read = reg.get("Read");
|
||||
let out = read
|
||||
.execute(
|
||||
&json!({ "file_path": file.to_str().unwrap() }).to_string(),
|
||||
&json!({ "file_path": file.file_name().unwrap().to_str().unwrap() }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
@@ -184,7 +190,7 @@ async fn empty_file_read_and_edit() {
|
||||
let err = edit
|
||||
.execute(
|
||||
&json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "foo",
|
||||
"new_string": "bar",
|
||||
})
|
||||
@@ -207,7 +213,7 @@ async fn very_long_single_line() {
|
||||
let read = reg.get("Read");
|
||||
let out = read
|
||||
.execute(
|
||||
&json!({ "file_path": file.to_str().unwrap() }).to_string(),
|
||||
&json!({ "file_path": file.file_name().unwrap().to_str().unwrap() }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
@@ -217,17 +223,17 @@ async fn very_long_single_line() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn relative_path_is_rejected() {
|
||||
let (_dir, _spill, reg) = setup();
|
||||
async fn absolute_path_is_rejected() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let read = reg.get("Read");
|
||||
let err = read
|
||||
.execute(
|
||||
&json!({ "file_path": "relative.txt" }).to_string(),
|
||||
&json!({ "file_path": dir.path().join("outside.txt") }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("absolute"));
|
||||
assert!(format!("{err}").contains("invalid Workdir path"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -235,10 +241,7 @@ async fn directory_target_is_rejected_for_read() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let read = reg.get("Read");
|
||||
let err = read
|
||||
.execute(
|
||||
&json!({ "file_path": dir.path().to_str().unwrap() }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.execute(&json!({ "file_path": "." }).to_string(), Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err}").contains("directory"));
|
||||
@@ -252,7 +255,7 @@ async fn deeply_nested_new_file_is_created() {
|
||||
write
|
||||
.execute(
|
||||
&json!({
|
||||
"file_path": deep.to_str().unwrap(),
|
||||
"file_path": "a/b/c/d/e/deep.txt",
|
||||
"content": "deep\n",
|
||||
})
|
||||
.to_string(),
|
||||
@@ -271,7 +274,7 @@ async fn replace_preserves_unicode() {
|
||||
|
||||
let read = reg.get("Read");
|
||||
read.execute(
|
||||
&json!({ "file_path": file.to_str().unwrap() }).to_string(),
|
||||
&json!({ "file_path": file.file_name().unwrap().to_str().unwrap() }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
@@ -280,7 +283,7 @@ async fn replace_preserves_unicode() {
|
||||
let edit = reg.get("Edit");
|
||||
edit.execute(
|
||||
&json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "rust",
|
||||
"new_string": "ラスト",
|
||||
})
|
||||
|
||||
@@ -11,7 +11,8 @@ use llm_engine::tool::{Tool, ToolDefinition, ToolMeta};
|
||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tools::{ScopedFs, Tracker, core_builtin_tools};
|
||||
use tools::{Tracker, core_builtin_tools};
|
||||
use workdir::{LocalWorkdirSession, WorkdirSessionHandle};
|
||||
|
||||
fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
|
||||
let base = Scope::writable(workspace).unwrap();
|
||||
@@ -54,7 +55,8 @@ fn setup() -> (TempDir, TempDir, Registry) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
let fs: WorkdirSessionHandle =
|
||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||
let tracker = Tracker::new();
|
||||
let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf()));
|
||||
(dir, spill, reg)
|
||||
@@ -103,7 +105,7 @@ async fn read_then_edit_then_read_roundtrip() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "hello world\n").unwrap();
|
||||
let p = file.to_str().unwrap();
|
||||
let p = "a.txt";
|
||||
|
||||
let read = reg.get("Read");
|
||||
let edit = reg.get("Edit");
|
||||
@@ -140,7 +142,7 @@ async fn write_then_grep_finds_content() {
|
||||
call(
|
||||
&write,
|
||||
json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "alpha\nNEEDLE\nomega\n",
|
||||
}),
|
||||
)
|
||||
@@ -169,7 +171,7 @@ async fn glob_finds_written_files() {
|
||||
call(
|
||||
&write,
|
||||
json!({
|
||||
"file_path": dir.path().join(name).to_str().unwrap(),
|
||||
"file_path": name,
|
||||
"content": "x",
|
||||
}),
|
||||
)
|
||||
@@ -184,7 +186,7 @@ async fn glob_finds_written_files() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn out_of_scope_write_is_rejected() {
|
||||
async fn absolute_path_is_rejected() {
|
||||
let (_dir, _spill, reg) = setup();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let write = reg.get("Write");
|
||||
@@ -197,9 +199,9 @@ async fn out_of_scope_write_is_rejected() {
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// ToolsError::OutOfScope → ToolError::InvalidArgument
|
||||
// Absolute paths are rejected at the logical WorkdirSession boundary.
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("outside allowed scope"), "unexpected: {msg}");
|
||||
assert!(msg.contains("invalid Workdir path"), "unexpected: {msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -212,7 +214,7 @@ async fn write_to_existing_without_read_fails() {
|
||||
let err = call_err(
|
||||
&write,
|
||||
json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "new",
|
||||
}),
|
||||
)
|
||||
@@ -222,8 +224,8 @@ async fn write_to_existing_without_read_fails() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_scoped_fs_across_tools() {
|
||||
// The key invariant: all builtin tools share the same ScopedFs instance,
|
||||
async fn shared_workdir_across_tools() {
|
||||
// The key invariant: all builtin tools share the same WorkdirSession instance,
|
||||
// so read-history set by Read is visible to Edit and Write.
|
||||
let (dir, _spill, reg) = setup();
|
||||
let file = dir.path().join("shared.txt");
|
||||
@@ -233,12 +235,16 @@ async fn shared_scoped_fs_across_tools() {
|
||||
let write = reg.get("Write");
|
||||
|
||||
// Read via Read tool
|
||||
call(&read, json!({ "file_path": file.to_str().unwrap() })).await;
|
||||
// Write via Write tool — must succeed because the shared ScopedFs has the read
|
||||
call(
|
||||
&read,
|
||||
json!({ "file_path": file.file_name().unwrap().to_str().unwrap() }),
|
||||
)
|
||||
.await;
|
||||
// Write via Write tool — must succeed because the shared WorkdirSession has the read
|
||||
call(
|
||||
&write,
|
||||
json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"content": "two\n",
|
||||
}),
|
||||
)
|
||||
@@ -257,7 +263,7 @@ async fn edit_requires_read_across_tools() {
|
||||
let err = call_err(
|
||||
&edit,
|
||||
json!({
|
||||
"file_path": file.to_str().unwrap(),
|
||||
"file_path": file.file_name().unwrap().to_str().unwrap(),
|
||||
"old_string": "foo",
|
||||
"new_string": "bar",
|
||||
}),
|
||||
@@ -296,7 +302,8 @@ async fn tracker_recent_files_tracks_read_write_edit() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
let fs: WorkdirSessionHandle =
|
||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||
let tracker = Tracker::new();
|
||||
let reg = Registry::new(core_builtin_tools(
|
||||
fs,
|
||||
@@ -309,22 +316,18 @@ async fn tracker_recent_files_tracks_read_write_edit() {
|
||||
std::fs::write(&a, "one\n").unwrap();
|
||||
|
||||
// Read `a` — should appear in recency.
|
||||
call(
|
||||
®.get("Read"),
|
||||
json!({ "file_path": a.to_str().unwrap() }),
|
||||
)
|
||||
.await;
|
||||
call(®.get("Read"), json!({ "file_path": "a.txt" })).await;
|
||||
// Write `b` (new file) — should appear ahead of `a`.
|
||||
call(
|
||||
®.get("Write"),
|
||||
json!({ "file_path": b.to_str().unwrap(), "content": "hello\n" }),
|
||||
json!({ "file_path": "b.txt", "content": "hello\n" }),
|
||||
)
|
||||
.await;
|
||||
// Edit `a` — should bump it back to the front.
|
||||
call(
|
||||
®.get("Edit"),
|
||||
json!({
|
||||
"file_path": a.to_str().unwrap(),
|
||||
"file_path": "a.txt",
|
||||
"old_string": "one",
|
||||
"new_string": "two",
|
||||
}),
|
||||
@@ -344,8 +347,8 @@ async fn tracker_recent_files_tracks_read_write_edit() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_inherits_scoped_fs_pwd() {
|
||||
// The Bash tool starts at the ScopedFs's pwd. Without any `cd`, its
|
||||
async fn bash_inherits_workdir_cwd() {
|
||||
// The Bash tool starts at the WorkdirSession's pwd. Without any `cd`, its
|
||||
// `pwd` should canonicalize to the workspace root we set up.
|
||||
let (dir, _spill, reg) = setup();
|
||||
let bash = reg.get("Bash");
|
||||
@@ -357,40 +360,14 @@ async fn bash_inherits_scoped_fs_pwd() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_spilled_file_is_readable_via_read_tool() {
|
||||
// Long Bash output spills to a path that the controller has added to
|
||||
// the readable scope. The agent should be able to Read that path
|
||||
// exactly like any in-scope file.
|
||||
async fn bash_provider_output_does_not_expose_internal_paths() {
|
||||
let (_dir, spill, reg) = setup();
|
||||
let bash = reg.get("Bash");
|
||||
let out = call(
|
||||
&bash,
|
||||
json!({ "command": "for i in $(seq 1 200); do echo line $i; done" }),
|
||||
)
|
||||
.await;
|
||||
let out = call(&bash, json!({ "command": "printf 'x%.0s' {1..20480}" })).await;
|
||||
let body = out.content.unwrap();
|
||||
let spill_str = spill.path().to_str().unwrap();
|
||||
|
||||
// Extract the spilled path from the marker line.
|
||||
let marker = body.lines().next().unwrap();
|
||||
let prefix_pos = marker
|
||||
.find(spill_str)
|
||||
.expect("marker should reference the spill dir");
|
||||
let path_end_rel = marker[prefix_pos..]
|
||||
.find(".log")
|
||||
.expect("marker should end the path with .log");
|
||||
let spilled = &marker[prefix_pos..prefix_pos + path_end_rel + 4];
|
||||
|
||||
// Read the file via the Read tool — must succeed (in scope).
|
||||
let read_out = call(®.get("Read"), json!({ "file_path": spilled })).await;
|
||||
let read_body = read_out.content.expect("Read returned content");
|
||||
// The full 200 lines should be in the saved file even though Bash
|
||||
// returned only the tail of 80.
|
||||
assert!(
|
||||
read_body.contains("line 1\n"),
|
||||
"missing line 1: {read_body}"
|
||||
);
|
||||
assert!(read_body.contains("line 200"), "missing line 200");
|
||||
assert!(body.contains("bounded WorkdirSession command output"));
|
||||
assert!(!body.contains(spill.path().to_str().unwrap()));
|
||||
assert_eq!(std::fs::read_dir(spill.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
// Sanity: unused Path import guard
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "workdir"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
http-client = ["dep:reqwest"]
|
||||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
fs-operation.workspace = true
|
||||
manifest.workspace = true
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,499 @@
|
||||
//! HTTP transport contract and client for remote Workdir sessions.
|
||||
//!
|
||||
//! The protocol keeps filesystem/search/process operations provider-side: one
|
||||
//! [`WorkdirSessionOperation`] is one bounded HTTP request. The HTTP client is
|
||||
//! optional so Runtime servers can share these DTOs without depending on a
|
||||
//! client stack.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
|
||||
WorkdirSessionCapabilities, WriteRequest, WriteResult,
|
||||
};
|
||||
|
||||
/// Opaque Runtime-owned identifier for one ephemeral Workdir session.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct WorkdirSessionId(String);
|
||||
|
||||
impl WorkdirSessionId {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, WorkdirError> {
|
||||
let value = value.into();
|
||||
if value.trim().is_empty() {
|
||||
return Err(WorkdirError::InvalidArgument(
|
||||
"Workdir session id must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a fresh session for a persisted Workdir identity.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OpenWorkdirSessionRequest {
|
||||
/// Optional Runtime Worker whose persisted binding establishes workspace
|
||||
/// ownership of the Workdir. Runtime servers reject cross-workspace owners.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub owner_worker_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OpenWorkdirSessionResponse {
|
||||
pub session_id: WorkdirSessionId,
|
||||
pub workdir_id: WorkdirId,
|
||||
pub capabilities: WorkdirSessionCapabilities,
|
||||
}
|
||||
|
||||
/// One provider-side Workdir operation.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", content = "request", rename_all = "snake_case")]
|
||||
pub enum WorkdirSessionOperation {
|
||||
Stat(StatRequest),
|
||||
Read(ReadRequest),
|
||||
Write(WriteRequest),
|
||||
Edit(EditRequest),
|
||||
List(ListRequest),
|
||||
Glob(GlobRequest),
|
||||
Grep(GrepRequest),
|
||||
CommandStart(CommandRequest),
|
||||
CommandStatus(CommandHandle),
|
||||
CommandOutput(CommandOutputRequest),
|
||||
CommandCancel(CommandHandle),
|
||||
}
|
||||
|
||||
/// Typed result paired with [`WorkdirSessionOperation`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", content = "result", rename_all = "snake_case")]
|
||||
pub enum WorkdirSessionOperationResult {
|
||||
Stat(StatResult),
|
||||
Read(ReadResult),
|
||||
Write(WriteResult),
|
||||
Edit(EditResult),
|
||||
List(ListResult),
|
||||
Glob(GlobResult),
|
||||
Grep(GrepResult),
|
||||
CommandStart(CommandHandle),
|
||||
CommandStatus(CommandStatus),
|
||||
CommandOutput(CommandOutput),
|
||||
CommandCancel,
|
||||
}
|
||||
|
||||
/// Stable, host-path-free error code crossing the Runtime boundary.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkdirTransportErrorCode {
|
||||
NotFound,
|
||||
Conflict,
|
||||
Unsupported,
|
||||
InvalidRequest,
|
||||
UnknownCommand,
|
||||
Unavailable,
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkdirTransportError {
|
||||
pub code: WorkdirTransportErrorCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl WorkdirTransportError {
|
||||
/// Convert a provider error without exposing materialization paths or raw I/O errors.
|
||||
pub fn from_workdir_error(error: &WorkdirError) -> Self {
|
||||
use WorkdirTransportErrorCode as Code;
|
||||
let (code, message) = match error {
|
||||
WorkdirError::NotFound(_) => (Code::NotFound, "Workdir path was not found"),
|
||||
WorkdirError::Conflict(_) => (Code::Conflict, "Workdir content changed"),
|
||||
WorkdirError::Unsupported(capability) => {
|
||||
return Self {
|
||||
code: Code::Unsupported,
|
||||
message: format!("Workdir capability {capability:?} is not available"),
|
||||
};
|
||||
}
|
||||
WorkdirError::UnknownCommand(_) => {
|
||||
(Code::UnknownCommand, "Workdir command was not found")
|
||||
}
|
||||
WorkdirError::Unavailable(_) => (Code::Unavailable, "Workdir session is unavailable"),
|
||||
WorkdirError::InvalidPath(_)
|
||||
| WorkdirError::RelativePath(_)
|
||||
| WorkdirError::InvalidGlob(_)
|
||||
| WorkdirError::InvalidRegex(_)
|
||||
| WorkdirError::InvalidArgument(_) => {
|
||||
(Code::InvalidRequest, "Workdir operation request is invalid")
|
||||
}
|
||||
WorkdirError::OutOfScope(_)
|
||||
| WorkdirError::SymlinkOutOfScope { .. }
|
||||
| WorkdirError::BrokenSymlink { .. }
|
||||
| WorkdirError::SymlinkTargetIsDirectory { .. }
|
||||
| WorkdirError::ReadOnly(_)
|
||||
| WorkdirError::IsDirectory(_)
|
||||
| WorkdirError::SymlinkDirectoryNotTraversed { .. }
|
||||
| WorkdirError::Io { .. } => (Code::Internal, "Workdir operation failed"),
|
||||
};
|
||||
Self {
|
||||
code,
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_workdir_error(self) -> WorkdirError {
|
||||
use WorkdirTransportErrorCode as Code;
|
||||
match self.code {
|
||||
Code::NotFound => WorkdirError::NotFound("<remote>".into()),
|
||||
Code::Conflict => WorkdirError::Conflict(self.message),
|
||||
Code::Unsupported => WorkdirError::Unavailable(self.message),
|
||||
Code::UnknownCommand => WorkdirError::UnknownCommand("<remote>".to_string()),
|
||||
Code::InvalidRequest => WorkdirError::InvalidArgument(self.message),
|
||||
Code::Unavailable | Code::Internal => WorkdirError::Unavailable(self.message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "http-client")]
|
||||
mod client {
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::{Client, StatusCode, Url};
|
||||
|
||||
use super::*;
|
||||
use crate::{Workdir, WorkdirSession};
|
||||
|
||||
/// Provides a fresh bearer token for each Runtime request. Backend
|
||||
/// implementations can mint short-lived capability tokens without making a
|
||||
/// Worker-bound session expire with the token used to open it.
|
||||
pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync {
|
||||
fn bearer_token(&self) -> Result<String, WorkdirError>;
|
||||
}
|
||||
|
||||
struct FixedBearerToken(Arc<str>);
|
||||
|
||||
impl std::fmt::Debug for FixedBearerToken {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("FixedBearerToken(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkdirHttpAuthorization for FixedBearerToken {
|
||||
fn bearer_token(&self) -> Result<String, WorkdirError> {
|
||||
Ok(self.0.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticated HTTP implementation of [`WorkdirSession`].
|
||||
///
|
||||
/// Clone and reuse one `reqwest::Client` per Runtime to preserve connection
|
||||
/// pooling and keep-alive across Worker-bound sessions.
|
||||
#[derive(Debug)]
|
||||
pub struct RemoteWorkdirSession {
|
||||
client: Client,
|
||||
base_url: Url,
|
||||
authorization: Arc<dyn WorkdirHttpAuthorization>,
|
||||
workdir: Workdir,
|
||||
session_id: WorkdirSessionId,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
impl RemoteWorkdirSession {
|
||||
pub async fn open(
|
||||
client: Client,
|
||||
base_url: Url,
|
||||
bearer_token: impl Into<Arc<str>>,
|
||||
workdir_id: WorkdirId,
|
||||
request: OpenWorkdirSessionRequest,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
Self::open_with_authorization(
|
||||
client,
|
||||
base_url,
|
||||
Arc::new(FixedBearerToken(bearer_token.into())),
|
||||
workdir_id,
|
||||
request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn open_with_authorization(
|
||||
client: Client,
|
||||
base_url: Url,
|
||||
authorization: Arc<dyn WorkdirHttpAuthorization>,
|
||||
workdir_id: WorkdirId,
|
||||
request: OpenWorkdirSessionRequest,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
let url = endpoint(
|
||||
&base_url,
|
||||
&["v1", "working-directories", workdir_id.as_str(), "sessions"],
|
||||
)?;
|
||||
let response = client
|
||||
.post(url)
|
||||
.bearer_auth(authorization.bearer_token()?)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(http_unavailable)?;
|
||||
let opened: OpenWorkdirSessionResponse = decode_response(response).await?;
|
||||
if opened.workdir_id.as_str() != workdir_id.as_str() {
|
||||
return Err(WorkdirError::Unavailable(
|
||||
"Runtime opened a session for a different Workdir".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
client,
|
||||
base_url,
|
||||
authorization,
|
||||
workdir: Workdir::new(opened.workdir_id.as_str()),
|
||||
session_id: opened.session_id,
|
||||
capabilities: opened.capabilities,
|
||||
closed: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn session_id(&self) -> &WorkdirSessionId {
|
||||
&self.session_id
|
||||
}
|
||||
|
||||
async fn operate(
|
||||
&self,
|
||||
operation: WorkdirSessionOperation,
|
||||
) -> Result<WorkdirSessionOperationResult, WorkdirError> {
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(WorkdirError::Unavailable(
|
||||
"Workdir session is closed".to_string(),
|
||||
));
|
||||
}
|
||||
let url = endpoint(
|
||||
&self.base_url,
|
||||
&[
|
||||
"v1",
|
||||
"workdir-sessions",
|
||||
self.session_id.as_str(),
|
||||
"operations",
|
||||
],
|
||||
)?;
|
||||
let response = self
|
||||
.client
|
||||
.post(url)
|
||||
.bearer_auth(self.authorization.bearer_token()?)
|
||||
.json(&operation)
|
||||
.send()
|
||||
.await
|
||||
.map_err(http_unavailable)?;
|
||||
decode_response(response).await
|
||||
}
|
||||
|
||||
fn mismatch(expected: &str) -> WorkdirError {
|
||||
WorkdirError::Unavailable(format!(
|
||||
"Runtime returned a mismatched Workdir operation result; expected {expected}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WorkdirSession for RemoteWorkdirSession {
|
||||
fn workdir(&self) -> &Workdir {
|
||||
&self.workdir
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> WorkdirSessionCapabilities {
|
||||
self.capabilities
|
||||
}
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
|
||||
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
||||
_ => Err(Self::mismatch("stat")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||
match self.operate(WorkdirSessionOperation::Read(request)).await? {
|
||||
WorkdirSessionOperationResult::Read(result) => Ok(result),
|
||||
_ => Err(Self::mismatch("read")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::Write(request))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::Write(result) => Ok(result),
|
||||
_ => Err(Self::mismatch("write")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||
match self.operate(WorkdirSessionOperation::Edit(request)).await? {
|
||||
WorkdirSessionOperationResult::Edit(result) => Ok(result),
|
||||
_ => Err(Self::mismatch("edit")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||
match self.operate(WorkdirSessionOperation::List(request)).await? {
|
||||
WorkdirSessionOperationResult::List(result) => Ok(result),
|
||||
_ => Err(Self::mismatch("list")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
||||
match self.operate(WorkdirSessionOperation::Glob(request)).await? {
|
||||
WorkdirSessionOperationResult::Glob(result) => Ok(result),
|
||||
_ => Err(Self::mismatch("glob")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
||||
match self.operate(WorkdirSessionOperation::Grep(request)).await? {
|
||||
WorkdirSessionOperationResult::Grep(result) => Ok(result),
|
||||
_ => Err(Self::mismatch("grep")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_command(
|
||||
&self,
|
||||
request: CommandRequest,
|
||||
) -> Result<CommandHandle, WorkdirError> {
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandStart(request))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CommandStart(result) => Ok(result),
|
||||
_ => Err(Self::mismatch("command_start")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn command_status(
|
||||
&self,
|
||||
handle: CommandHandle,
|
||||
) -> Result<CommandStatus, WorkdirError> {
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandStatus(handle))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CommandStatus(result) => Ok(result),
|
||||
_ => Err(Self::mismatch("command_status")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn command_output(
|
||||
&self,
|
||||
request: CommandOutputRequest,
|
||||
) -> Result<CommandOutput, WorkdirError> {
|
||||
let wait = request.wait;
|
||||
loop {
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandOutput(request.clone()))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CommandOutput(result)
|
||||
if wait && result.status == CommandStatus::Running => {}
|
||||
WorkdirSessionOperationResult::CommandOutput(result) => return Ok(result),
|
||||
_ => return Err(Self::mismatch("command_output")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandCancel(handle))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CommandCancel => Ok(()),
|
||||
_ => Err(Self::mismatch("command_cancel")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn close(&self) -> Result<(), WorkdirError> {
|
||||
if self.closed.swap(true, Ordering::AcqRel) {
|
||||
return Ok(());
|
||||
}
|
||||
let url = endpoint(
|
||||
&self.base_url,
|
||||
&["v1", "workdir-sessions", self.session_id.as_str()],
|
||||
)?;
|
||||
let response = self
|
||||
.client
|
||||
.delete(url)
|
||||
.bearer_auth(self.authorization.bearer_token()?)
|
||||
.send()
|
||||
.await
|
||||
.map_err(http_unavailable)?;
|
||||
if response.status() == StatusCode::NO_CONTENT || response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(decode_error(response).await)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint(base_url: &Url, segments: &[&str]) -> Result<Url, WorkdirError> {
|
||||
let mut url = base_url.clone();
|
||||
{
|
||||
let mut path = url.path_segments_mut().map_err(|_| {
|
||||
WorkdirError::InvalidArgument(
|
||||
"Runtime base URL cannot be used for path-based Workdir operations".to_string(),
|
||||
)
|
||||
})?;
|
||||
path.pop_if_empty();
|
||||
path.extend(segments.iter().copied());
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
async fn decode_response<T: serde::de::DeserializeOwned>(
|
||||
response: reqwest::Response,
|
||||
) -> Result<T, WorkdirError> {
|
||||
if response.status().is_success() {
|
||||
response.json().await.map_err(http_unavailable)
|
||||
} else {
|
||||
Err(decode_error(response).await)
|
||||
}
|
||||
}
|
||||
|
||||
async fn decode_error(response: reqwest::Response) -> WorkdirError {
|
||||
response
|
||||
.json::<WorkdirTransportError>()
|
||||
.await
|
||||
.map(WorkdirTransportError::into_workdir_error)
|
||||
.unwrap_or_else(|error| {
|
||||
WorkdirError::Unavailable(format!("Runtime HTTP error: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn http_unavailable(error: reqwest::Error) -> WorkdirError {
|
||||
WorkdirError::Unavailable(format!("Runtime Workdir HTTP request failed: {error}"))
|
||||
}
|
||||
|
||||
pub use self::RemoteWorkdirSession as ClientSession;
|
||||
}
|
||||
|
||||
#[cfg(feature = "http-client")]
|
||||
pub use client::{ClientSession as RemoteWorkdirSession, WorkdirHttpAuthorization};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn transport_error_does_not_expose_host_path() {
|
||||
let error = WorkdirError::Io {
|
||||
path: "/secret/runtime/root/file".into(),
|
||||
source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "host detail"),
|
||||
};
|
||||
let transport = WorkdirTransportError::from_workdir_error(&error);
|
||||
assert_eq!(transport.code, WorkdirTransportErrorCode::Internal);
|
||||
assert!(!transport.message.contains("/secret"));
|
||||
assert!(!transport.message.contains("host detail"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Persistent Workdir identity and Worker-bound operation sessions.
|
||||
//!
|
||||
//! A [`Workdir`] identifies a materialized repository execution context across
|
||||
//! Worker lifetimes. A [`WorkdirSession`] is the live operation attachment
|
||||
//! bound to one Worker. Tools consume sessions; they do not own Workdir
|
||||
//! materialization or cleanup.
|
||||
|
||||
pub mod http;
|
||||
mod local;
|
||||
mod operation;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use fs_operation::{
|
||||
ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest,
|
||||
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
|
||||
ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||
};
|
||||
pub use local::{LocalWorkdirSession, SymlinkInfo, direct_symlink, first_symlink};
|
||||
pub use operation::*;
|
||||
|
||||
/// Persistent, opaque identity of one materialized Workdir.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct Workdir {
|
||||
id: WorkdirId,
|
||||
}
|
||||
|
||||
impl Workdir {
|
||||
pub fn new(id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: WorkdirId(id.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &WorkdirId {
|
||||
&self.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque Workdir identifier assigned by the materialization authority.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct WorkdirId(String);
|
||||
|
||||
impl WorkdirId {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WorkdirId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkdirSessionCapability {
|
||||
Read,
|
||||
Write,
|
||||
Edit,
|
||||
Glob,
|
||||
Grep,
|
||||
Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkdirSessionCapabilities {
|
||||
bits: u8,
|
||||
}
|
||||
|
||||
impl WorkdirSessionCapabilities {
|
||||
const READ: u8 = 1 << 0;
|
||||
const WRITE: u8 = 1 << 1;
|
||||
const EDIT: u8 = 1 << 2;
|
||||
const GLOB: u8 = 1 << 3;
|
||||
const GREP: u8 = 1 << 4;
|
||||
const COMMAND: u8 = 1 << 5;
|
||||
|
||||
pub const EMPTY: Self = Self { bits: 0 };
|
||||
|
||||
pub fn from_capabilities(
|
||||
capabilities: impl IntoIterator<Item = WorkdirSessionCapability>,
|
||||
) -> Self {
|
||||
capabilities
|
||||
.into_iter()
|
||||
.fold(Self::EMPTY, |set, capability| set.with(capability))
|
||||
}
|
||||
|
||||
pub const fn with(mut self, capability: WorkdirSessionCapability) -> Self {
|
||||
self.bits |= match capability {
|
||||
WorkdirSessionCapability::Read => Self::READ,
|
||||
WorkdirSessionCapability::Write => Self::WRITE,
|
||||
WorkdirSessionCapability::Edit => Self::EDIT,
|
||||
WorkdirSessionCapability::Glob => Self::GLOB,
|
||||
WorkdirSessionCapability::Grep => Self::GREP,
|
||||
WorkdirSessionCapability::Command => Self::COMMAND,
|
||||
};
|
||||
self
|
||||
}
|
||||
|
||||
pub const ALL: Self = Self {
|
||||
bits: Self::READ | Self::WRITE | Self::EDIT | Self::GLOB | Self::GREP | Self::COMMAND,
|
||||
};
|
||||
|
||||
pub const READ_ONLY: Self = Self {
|
||||
bits: Self::READ | Self::GLOB | Self::GREP,
|
||||
};
|
||||
|
||||
pub const fn supports(self, capability: WorkdirSessionCapability) -> bool {
|
||||
let bit = match capability {
|
||||
WorkdirSessionCapability::Read => Self::READ,
|
||||
WorkdirSessionCapability::Write => Self::WRITE,
|
||||
WorkdirSessionCapability::Edit => Self::EDIT,
|
||||
WorkdirSessionCapability::Glob => Self::GLOB,
|
||||
WorkdirSessionCapability::Grep => Self::GREP,
|
||||
WorkdirSessionCapability::Command => Self::COMMAND,
|
||||
};
|
||||
self.bits & bit != 0
|
||||
}
|
||||
}
|
||||
|
||||
pub type WriteOutcome = WriteResult;
|
||||
|
||||
/// Live, Worker-bound operations for one persistent [`Workdir`].
|
||||
///
|
||||
/// Implementations execute filesystem search and command work on the host
|
||||
/// that owns the materialization. Structured requests and results never
|
||||
/// contain the raw materialized root. Closing a session is terminal and does
|
||||
/// not delete the persistent Workdir or its materialization.
|
||||
#[async_trait]
|
||||
pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
||||
fn workdir(&self) -> &Workdir;
|
||||
fn capabilities(&self) -> WorkdirSessionCapabilities;
|
||||
|
||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
|
||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
|
||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>;
|
||||
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError>;
|
||||
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError>;
|
||||
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError>;
|
||||
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError>;
|
||||
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError>;
|
||||
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError>;
|
||||
async fn command_output(
|
||||
&self,
|
||||
request: CommandOutputRequest,
|
||||
) -> Result<CommandOutput, WorkdirError>;
|
||||
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError>;
|
||||
/// Terminal, idempotent release of this Worker-bound operation session.
|
||||
async fn close(&self) -> Result<(), WorkdirError>;
|
||||
}
|
||||
|
||||
pub type WorkdirSessionHandle = Arc<dyn WorkdirSession>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WorkdirError {
|
||||
#[error("Workdir session does not support {0:?}")]
|
||||
Unsupported(WorkdirSessionCapability),
|
||||
|
||||
#[error("invalid Workdir path: {0}")]
|
||||
InvalidPath(String),
|
||||
|
||||
#[error("Workdir session is unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
|
||||
#[error("Workdir content was modified externally before the operation could be applied: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("unknown Workdir session command: {0}")]
|
||||
UnknownCommand(String),
|
||||
|
||||
#[error("path must be absolute: {}", .0.display())]
|
||||
RelativePath(PathBuf),
|
||||
|
||||
#[error("path is outside allowed scope: {}", .0.display())]
|
||||
OutOfScope(PathBuf),
|
||||
|
||||
#[error(
|
||||
"path resolves through a symlink outside allowed {required_permission} scope: {} -> {}; add the symlink target to the Worker {required_permission} scope, copy it into the workspace, or recreate the symlink with the correct target",
|
||||
.path.display(),
|
||||
.target.display()
|
||||
)]
|
||||
SymlinkOutOfScope {
|
||||
path: PathBuf,
|
||||
target: PathBuf,
|
||||
required_permission: &'static str,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"broken symlink while resolving {}: {} -> {} (target does not exist); recreate the symlink with an absolute target or a correct relative target",
|
||||
.path.display(),
|
||||
.link.display(),
|
||||
.target.display()
|
||||
)]
|
||||
BrokenSymlink {
|
||||
path: PathBuf,
|
||||
link: PathBuf,
|
||||
target: PathBuf,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"path resolves through a symlink to a directory, but this tool requires a file: {} -> {}; choose a file inside that directory",
|
||||
.path.display(),
|
||||
.target.display()
|
||||
)]
|
||||
SymlinkTargetIsDirectory { path: PathBuf, target: PathBuf },
|
||||
|
||||
#[error("path is read-only: {}", .0.display())]
|
||||
ReadOnly(PathBuf),
|
||||
|
||||
#[error("expected file but path is a directory: {}", .0.display())]
|
||||
IsDirectory(PathBuf),
|
||||
|
||||
#[error("file not found: {}", .0.display())]
|
||||
NotFound(PathBuf),
|
||||
|
||||
#[error("invalid argument: {0}")]
|
||||
InvalidArgument(String),
|
||||
|
||||
#[error("invalid glob pattern: {0}")]
|
||||
InvalidGlob(String),
|
||||
|
||||
#[error("invalid regex pattern: {0}")]
|
||||
InvalidRegex(String),
|
||||
|
||||
#[error("{tool} does not follow symlink directories: {} -> {}", .path.display(), .target.display())]
|
||||
SymlinkDirectoryNotTraversed {
|
||||
tool: &'static str,
|
||||
path: PathBuf,
|
||||
target: PathBuf,
|
||||
},
|
||||
|
||||
#[error("I/O error at {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
impl WorkdirError {
|
||||
pub(crate) fn io(path: &Path, source: std::io::Error) -> Self {
|
||||
Self::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<fs_operation::FsError> for WorkdirError {
|
||||
fn from(error: fs_operation::FsError) -> Self {
|
||||
match error {
|
||||
fs_operation::FsError::InvalidPath(message) => Self::InvalidPath(message),
|
||||
fs_operation::FsError::RelativePath(path) => Self::RelativePath(path),
|
||||
fs_operation::FsError::OutOfScope(path) => Self::OutOfScope(path),
|
||||
fs_operation::FsError::NotFound(path) => Self::NotFound(path),
|
||||
fs_operation::FsError::BrokenSymlink { path, link, target } => {
|
||||
Self::BrokenSymlink { path, link, target }
|
||||
}
|
||||
fs_operation::FsError::SymlinkOutOfScope {
|
||||
path,
|
||||
target,
|
||||
required_permission,
|
||||
} => Self::SymlinkOutOfScope {
|
||||
path,
|
||||
target,
|
||||
required_permission,
|
||||
},
|
||||
fs_operation::FsError::SymlinkDirectoryNotTraversed { tool, path, target } => {
|
||||
Self::SymlinkDirectoryNotTraversed { tool, path, target }
|
||||
}
|
||||
fs_operation::FsError::ReadOnly(path) => Self::ReadOnly(path),
|
||||
fs_operation::FsError::IsDirectory(path) => Self::IsDirectory(path),
|
||||
fs_operation::FsError::NotDirectory(path) => {
|
||||
Self::InvalidArgument(format!("path is not a directory: {}", path.display()))
|
||||
}
|
||||
fs_operation::FsError::SymlinkTargetIsDirectory { path, target } => {
|
||||
Self::SymlinkTargetIsDirectory { path, target }
|
||||
}
|
||||
fs_operation::FsError::Conflict(message) => Self::Conflict(message),
|
||||
fs_operation::FsError::InvalidGlob(message) => Self::InvalidGlob(message),
|
||||
fs_operation::FsError::InvalidRegex(message) => Self::InvalidRegex(message),
|
||||
fs_operation::FsError::InvalidArgument(message) => Self::InvalidArgument(message),
|
||||
fs_operation::FsError::Io { path, source } => Self::Io { path, source },
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct CommandHandle(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CommandRequest {
|
||||
pub command: String,
|
||||
pub timeout_secs: u64,
|
||||
pub output_limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CommandOutputRequest {
|
||||
pub handle: CommandHandle,
|
||||
pub cursor: usize,
|
||||
pub limit: usize,
|
||||
pub wait: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommandStatus {
|
||||
Running,
|
||||
Completed,
|
||||
Cancelled,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CommandOutput {
|
||||
pub status: CommandStatus,
|
||||
pub exit_code: Option<i32>,
|
||||
pub timed_out: bool,
|
||||
pub content: String,
|
||||
pub next_cursor: Option<usize>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
@@ -42,6 +42,7 @@ tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
|
||||
toml.workspace = true
|
||||
tower = { workspace = true, features = ["util"], optional = true }
|
||||
worker.workspace = true
|
||||
workdir.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
futures.workspace = true
|
||||
|
||||
@@ -143,14 +143,20 @@ pub struct WorkingDirectoryOccupancy {
|
||||
pub struct WorkingDirectorySummary {
|
||||
pub working_directory_id: String,
|
||||
pub repository_id: String,
|
||||
/// Selector used to create this Workdir, retained as immutable materialization evidence.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub requested_selector: Option<String>,
|
||||
pub creation_selector: Option<String>,
|
||||
/// Provider-specific immutable ref resolved when this Workdir was created.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub creation_ref: Option<String>,
|
||||
/// Selector currently observed from the materialized Workdir, when one exists.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_selector: Option<String>,
|
||||
/// Provider-specific immutable ref currently observed from the materialized Workdir.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_ref: Option<String>,
|
||||
pub materializer_kind: MaterializerKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_commit: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub resolved_tree: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
|
||||
pub status: WorkingDirectoryStatusKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -166,10 +172,23 @@ pub struct WorkingDirectoryStatus {
|
||||
pub summary: WorkingDirectorySummary,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkspaceApiRef {
|
||||
pub workspace_id: String,
|
||||
pub base_url: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub runtime_id: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WorkspaceApiRef {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("WorkspaceApiRef")
|
||||
.field("workspace_id", &self.workspace_id)
|
||||
.field("base_url", &self.base_url)
|
||||
.field("runtime_id", &self.runtime_id)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical Runtime Worker creation request.
|
||||
@@ -183,6 +202,10 @@ pub struct WorkspaceApiRef {
|
||||
/// summarized without exposing raw host paths.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreateWorkerRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub idempotency_key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub idempotency_fingerprint: Option<String>,
|
||||
pub profile: ProfileSelector,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
@@ -232,7 +255,6 @@ pub struct WorkerSummary {
|
||||
pub profile_source: ProfileSourceArchiveRef,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub config_bundle: Option<ConfigBundleRef>,
|
||||
pub last_event_id: u64,
|
||||
}
|
||||
|
||||
/// Full Worker catalog/lifecycle detail.
|
||||
@@ -251,7 +273,6 @@ pub struct WorkerDetail {
|
||||
pub profile_source: ProfileSourceArchiveRef,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub config_bundle: Option<ConfigBundleRef>,
|
||||
pub last_event_id: u64,
|
||||
}
|
||||
|
||||
/// Acknowledgement returned by stop/cancel lifecycle operations.
|
||||
@@ -259,5 +280,4 @@ pub struct WorkerDetail {
|
||||
pub struct WorkerLifecycleAck {
|
||||
pub worker_ref: WorkerRef,
|
||||
pub status: WorkerStatus,
|
||||
pub event_id: u64,
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use protocol::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use workdir::WorkdirSessionHandle;
|
||||
|
||||
/// Current execution-side run state for a Worker.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -293,6 +294,18 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
|
||||
))
|
||||
}
|
||||
|
||||
fn open_workdir_session(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
) -> Result<WorkdirSessionHandle, WorkingDirectoryDiagnostic> {
|
||||
Err(WorkingDirectoryDiagnostic::rejected(
|
||||
"workdir_session_unsupported",
|
||||
format!(
|
||||
"working directory `{working_directory_id}` does not expose operation sessions"
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn cleanup_working_directory(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
@@ -403,6 +416,13 @@ impl WorkerExecutionBackendRef {
|
||||
self.backend.working_directory(working_directory_id)
|
||||
}
|
||||
|
||||
pub(crate) fn open_workdir_session(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
) -> Result<WorkdirSessionHandle, WorkingDirectoryDiagnostic> {
|
||||
self.backend.open_workdir_session(working_directory_id)
|
||||
}
|
||||
|
||||
pub(crate) fn cleanup_working_directory(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
|
||||
@@ -3,18 +3,16 @@ use crate::config_bundle::ConfigBundle;
|
||||
use crate::diagnostics::{DiagnosticSeverity, RuntimeDiagnostic};
|
||||
use crate::error::RuntimeError;
|
||||
use crate::identity::{WorkerId, WorkerRef};
|
||||
use crate::management::{RuntimeBackendKind, RuntimeLimits, RuntimeStatus};
|
||||
use crate::observation::{EventCursor, RuntimeEvent, RuntimeEventBatch};
|
||||
use crate::management::{RuntimeBackendKind, RuntimeStatus};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
const SCHEMA_VERSION: u32 = 1;
|
||||
const RUNTIME_FILE: &str = "runtime.json";
|
||||
const EVENTS_FILE: &str = "events.jsonl";
|
||||
const WORKERS_DIR: &str = "workers";
|
||||
const LEGACY_RUNTIMES_DIR: &str = "runtimes";
|
||||
const WORKER_FILE: &str = "worker.json";
|
||||
@@ -28,7 +26,6 @@ pub struct FsRuntimeStoreOptions {
|
||||
/// Root directory containing this Runtime's store data.
|
||||
pub root: PathBuf,
|
||||
pub display_name: Option<String>,
|
||||
pub limits: RuntimeLimits,
|
||||
}
|
||||
|
||||
impl FsRuntimeStoreOptions {
|
||||
@@ -36,7 +33,6 @@ impl FsRuntimeStoreOptions {
|
||||
Self {
|
||||
root: root.into(),
|
||||
display_name: None,
|
||||
limits: RuntimeLimits::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,43 +55,6 @@ impl FsRuntimeStore {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// Read persisted Runtime events directly from the event log with the same
|
||||
/// bounded cursor semantics as [`crate::Runtime::read_events`].
|
||||
pub fn read_events(
|
||||
&self,
|
||||
cursor: &EventCursor,
|
||||
limit: usize,
|
||||
max_limit: usize,
|
||||
) -> Result<RuntimeEventBatch, RuntimeError> {
|
||||
if limit > max_limit {
|
||||
return Err(RuntimeError::LimitTooLarge {
|
||||
requested: limit,
|
||||
max: max_limit,
|
||||
});
|
||||
}
|
||||
|
||||
let events = read_json_lines::<RuntimeEvent>(&self.events_path(), "read events")?;
|
||||
let mut selected = Vec::new();
|
||||
for event in events
|
||||
.iter()
|
||||
.filter(|event| event.id >= cursor.next_event_id)
|
||||
.take(limit)
|
||||
{
|
||||
selected.push(event.clone());
|
||||
}
|
||||
let next_event_id = selected
|
||||
.last()
|
||||
.map(|event| event.id + 1)
|
||||
.unwrap_or(cursor.next_event_id);
|
||||
let has_more = events.iter().any(|event| event.id >= next_event_id);
|
||||
|
||||
Ok(RuntimeEventBatch {
|
||||
cursor: EventCursor { next_event_id },
|
||||
events: selected,
|
||||
has_more,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn open_or_create(root: PathBuf) -> Result<OpenedFsRuntimeStore, RuntimeError> {
|
||||
let existed = root.exists();
|
||||
if existed && !root.is_dir() {
|
||||
@@ -115,6 +74,18 @@ impl FsRuntimeStore {
|
||||
path: root.join(WORKERS_DIR),
|
||||
source,
|
||||
})?;
|
||||
let legacy_events = root.join("events.jsonl");
|
||||
match fs::remove_file(&legacy_events) {
|
||||
Ok(()) => {}
|
||||
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(source) => {
|
||||
return Err(RuntimeError::StoreIo {
|
||||
operation: "remove legacy runtime events",
|
||||
path: legacy_events,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let store = Self { root };
|
||||
let state = if existed {
|
||||
@@ -165,19 +136,11 @@ impl FsRuntimeStore {
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn append_event(&self, event: &RuntimeEvent) -> Result<(), RuntimeError> {
|
||||
if let Some(worker_ref) = &event.worker_ref {
|
||||
self.ensure_worker_ref(worker_ref)?;
|
||||
}
|
||||
append_json_line(&self.events_path(), event, "append event")
|
||||
}
|
||||
|
||||
pub(crate) fn load_runtime_state(&self) -> Result<PersistedRuntimeState, RuntimeError> {
|
||||
let runtime_path = self.runtime_path();
|
||||
let mut snapshot: RuntimeSnapshot = read_json(&runtime_path, "read runtime snapshot")?;
|
||||
snapshot.validate(&runtime_path)?;
|
||||
|
||||
let events = read_json_lines::<RuntimeEvent>(&self.events_path(), "read events")?;
|
||||
let workers_dir = self.root.join(WORKERS_DIR);
|
||||
if !workers_dir.exists() {
|
||||
return Err(RuntimeError::StoreMissing {
|
||||
@@ -250,7 +213,7 @@ impl FsRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(snapshot.into_persisted(events, workers))
|
||||
Ok(snapshot.into_persisted(workers))
|
||||
}
|
||||
|
||||
fn ensure_worker_ref(&self, _worker_ref: &WorkerRef) -> Result<(), RuntimeError> {
|
||||
@@ -261,10 +224,6 @@ impl FsRuntimeStore {
|
||||
self.root.join(RUNTIME_FILE)
|
||||
}
|
||||
|
||||
fn events_path(&self) -> PathBuf {
|
||||
self.root.join(EVENTS_FILE)
|
||||
}
|
||||
|
||||
fn worker_dir(&self, worker_id: &WorkerId) -> PathBuf {
|
||||
self.root.join(WORKERS_DIR).join(worker_id.to_string())
|
||||
}
|
||||
@@ -287,14 +246,11 @@ pub(crate) struct OpenedFsRuntimeStore {
|
||||
pub(crate) struct PersistedRuntimeState {
|
||||
pub(crate) display_name: Option<String>,
|
||||
pub(crate) status: RuntimeStatus,
|
||||
pub(crate) limits: RuntimeLimits,
|
||||
pub(crate) next_worker_sequence: u64,
|
||||
pub(crate) next_event_id: u64,
|
||||
pub(crate) next_diagnostic_id: u64,
|
||||
pub(crate) workers: BTreeMap<WorkerId, PersistedWorkerRecord>,
|
||||
pub(crate) workspace_owners: BTreeMap<String, String>,
|
||||
pub(crate) config_bundles: BTreeMap<String, ConfigBundle>,
|
||||
pub(crate) events: Vec<RuntimeEvent>,
|
||||
pub(crate) diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
@@ -305,7 +261,6 @@ pub(crate) struct PersistedWorkerRecord {
|
||||
pub(crate) request: CreateWorkerRequest,
|
||||
pub(crate) workspace_id: Option<String>,
|
||||
pub(crate) working_directory: Option<WorkingDirectoryStatus>,
|
||||
pub(crate) last_event_id: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -314,9 +269,7 @@ struct RuntimeSnapshot {
|
||||
display_name: Option<String>,
|
||||
backend: RuntimeBackendKind,
|
||||
status: RuntimeStatus,
|
||||
limits: RuntimeLimits,
|
||||
next_worker_sequence: u64,
|
||||
next_event_id: u64,
|
||||
next_diagnostic_id: u64,
|
||||
#[serde(default)]
|
||||
config_bundles: BTreeMap<String, ConfigBundle>,
|
||||
@@ -348,9 +301,7 @@ impl RuntimeSnapshot {
|
||||
display_name: state.display_name.clone(),
|
||||
backend: RuntimeBackendKind::FsStore,
|
||||
status: state.status,
|
||||
limits: state.limits.clone(),
|
||||
next_worker_sequence: state.next_worker_sequence,
|
||||
next_event_id: state.next_event_id,
|
||||
next_diagnostic_id: state.next_diagnostic_id,
|
||||
config_bundles: state.config_bundles.clone(),
|
||||
workspace_owners: state.workspace_owners.clone(),
|
||||
@@ -381,20 +332,16 @@ impl RuntimeSnapshot {
|
||||
|
||||
fn into_persisted(
|
||||
self,
|
||||
events: Vec<RuntimeEvent>,
|
||||
workers: BTreeMap<WorkerId, PersistedWorkerRecord>,
|
||||
) -> PersistedRuntimeState {
|
||||
PersistedRuntimeState {
|
||||
display_name: self.display_name,
|
||||
status: self.status,
|
||||
limits: self.limits,
|
||||
next_worker_sequence: self.next_worker_sequence,
|
||||
next_event_id: self.next_event_id,
|
||||
next_diagnostic_id: self.next_diagnostic_id,
|
||||
workers,
|
||||
config_bundles: self.config_bundles,
|
||||
workspace_owners: self.workspace_owners,
|
||||
events,
|
||||
diagnostics: self.diagnostics,
|
||||
}
|
||||
}
|
||||
@@ -414,7 +361,6 @@ struct WorkerSnapshot {
|
||||
/// write the removed execution projection.
|
||||
#[serde(default, rename = "execution", skip_serializing)]
|
||||
legacy_execution: Option<LegacyWorkerExecutionProjection>,
|
||||
last_event_id: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -433,7 +379,6 @@ impl WorkerSnapshot {
|
||||
workspace_id: worker.workspace_id.clone(),
|
||||
working_directory: worker.working_directory.clone(),
|
||||
legacy_execution: None,
|
||||
last_event_id: worker.last_event_id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +422,6 @@ impl WorkerSnapshot {
|
||||
self.legacy_execution
|
||||
.and_then(|execution| execution.working_directory)
|
||||
}),
|
||||
last_event_id: self.last_event_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -525,11 +469,6 @@ fn migrate_legacy_single_runtime_layout(root: &Path) -> Result<(), RuntimeError>
|
||||
&root.join(RUNTIME_FILE),
|
||||
"migrate legacy runtime snapshot",
|
||||
)?;
|
||||
rename_if_exists(
|
||||
&legacy_dir.join(EVENTS_FILE),
|
||||
&root.join(EVENTS_FILE),
|
||||
"migrate legacy runtime events",
|
||||
)?;
|
||||
rename_if_exists(
|
||||
&legacy_dir.join(WORKERS_DIR),
|
||||
&root.join(WORKERS_DIR),
|
||||
@@ -581,42 +520,6 @@ where
|
||||
})
|
||||
}
|
||||
|
||||
fn read_json_lines<T>(path: &Path, operation: &'static str) -> Result<Vec<T>, RuntimeError>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
let file = File::open(path).map_err(|source| match source.kind() {
|
||||
std::io::ErrorKind::NotFound => RuntimeError::StoreMissing {
|
||||
operation,
|
||||
path: path.to_path_buf(),
|
||||
},
|
||||
_ => RuntimeError::StoreIo {
|
||||
operation,
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
},
|
||||
})?;
|
||||
let reader = BufReader::new(file);
|
||||
let mut items = Vec::new();
|
||||
for (index, line) in reader.lines().enumerate() {
|
||||
let line = line.map_err(|source| RuntimeError::StoreIo {
|
||||
operation,
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let item = serde_json::from_str(&line).map_err(|source| RuntimeError::StoreCorrupt {
|
||||
operation,
|
||||
path: path.to_path_buf(),
|
||||
message: format!("line {}: {source}", index + 1),
|
||||
})?;
|
||||
items.push(item);
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn atomic_write_json<T>(path: &Path, value: &T, operation: &'static str) -> Result<(), RuntimeError>
|
||||
where
|
||||
T: Serialize,
|
||||
@@ -676,44 +579,6 @@ where
|
||||
write_result
|
||||
}
|
||||
|
||||
fn append_json_line<T>(path: &Path, value: &T, operation: &'static str) -> Result<(), RuntimeError>
|
||||
where
|
||||
T: Serialize,
|
||||
{
|
||||
let parent = path.parent().ok_or_else(|| RuntimeError::StoreCorrupt {
|
||||
operation,
|
||||
path: path.to_path_buf(),
|
||||
message: "path has no parent directory".to_string(),
|
||||
})?;
|
||||
fs::create_dir_all(parent).map_err(|source| RuntimeError::StoreIo {
|
||||
operation,
|
||||
path: parent.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.map_err(|source| RuntimeError::StoreIo {
|
||||
operation,
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
serde_json::to_writer(&mut file, value).map_err(|source| RuntimeError::StoreCorrupt {
|
||||
operation,
|
||||
path: path.to_path_buf(),
|
||||
message: format!("serialize json: {source}"),
|
||||
})?;
|
||||
file.write_all(b"\n")
|
||||
.and_then(|()| file.flush())
|
||||
.and_then(|()| file.sync_all())
|
||||
.map_err(|source| RuntimeError::StoreIo {
|
||||
operation,
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
fn tmp_path_for(path: &Path) -> PathBuf {
|
||||
let sequence = NEXT_TMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let file_name = path
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,5 +52,4 @@ impl WorkerInput {
|
||||
pub struct WorkerInteractionAck {
|
||||
pub worker_ref: WorkerRef,
|
||||
pub status: WorkerStatus,
|
||||
pub event_id: u64,
|
||||
}
|
||||
|
||||
@@ -28,5 +28,5 @@ pub mod working_directory;
|
||||
|
||||
#[cfg(feature = "fs-store")]
|
||||
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
|
||||
pub use management::{RuntimeLimits, RuntimeOptions};
|
||||
pub use management::RuntimeOptions;
|
||||
pub use runtime::{Runtime, RuntimeWorkspaceScope};
|
||||
|
||||
@@ -107,7 +107,6 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||
RuntimeHttpStoreSelection::Fs { root } => {
|
||||
let mut options = FsRuntimeStoreOptions::new(root.clone());
|
||||
options.display_name = config.http.display_name.clone();
|
||||
options.limits = config.http.limits.clone();
|
||||
Runtime::with_fs_store_and_execution_backend(options, backend)
|
||||
.map_err(ProcessError::Runtime)
|
||||
}
|
||||
@@ -120,7 +119,6 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
|
||||
RuntimeOptions {
|
||||
display_name: config.display_name.clone(),
|
||||
limits: config.limits.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,10 +205,6 @@ where
|
||||
}
|
||||
config.http.local_token = Some(value);
|
||||
}
|
||||
"--max-event-batch-items" => {
|
||||
config.http.limits.max_event_batch_items =
|
||||
parse_usize_flag(&flag, take_value(&flag, inline_value, &mut args)?)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(ProcessError::usage(format!("unknown argument `{flag}`")));
|
||||
}
|
||||
@@ -255,12 +249,6 @@ fn ensure_no_inline_value(flag: &str, inline_value: Option<&str>) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_usize_flag(flag: &str, value: String) -> Result<usize, ProcessError> {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|error| ProcessError::usage(format!("invalid {flag} value `{value}`: {error}")))
|
||||
}
|
||||
|
||||
fn apply_store_selection(config: &mut ProcessConfig) {
|
||||
if config.no_store {
|
||||
config.http.store = RuntimeHttpStoreSelection::Memory;
|
||||
@@ -806,7 +794,6 @@ Options:
|
||||
--no-store Disable Runtime catalog persistence for ephemeral runs
|
||||
--local-token <TOKEN> Minimal local bearer token placeholder
|
||||
--local-token-env <ENV> Read local bearer token placeholder from env
|
||||
--max-event-batch-items <N> Override event batch limit
|
||||
-h, --help Show this help
|
||||
|
||||
Auth commands:
|
||||
|
||||
@@ -18,34 +18,10 @@ pub enum RuntimeStatus {
|
||||
Stopped,
|
||||
}
|
||||
|
||||
/// Guardrails for bounded Runtime APIs.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeLimits {
|
||||
pub max_event_batch_items: usize,
|
||||
}
|
||||
|
||||
impl Default for RuntimeLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_event_batch_items: 256,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Options used to construct an embedded memory Runtime.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeOptions {
|
||||
pub display_name: Option<String>,
|
||||
pub limits: RuntimeLimits,
|
||||
}
|
||||
|
||||
impl Default for RuntimeOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
display_name: None,
|
||||
limits: RuntimeLimits::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unknown_platform_component() -> String {
|
||||
@@ -69,7 +45,6 @@ pub struct RuntimeSummary {
|
||||
pub stopped_worker_count: usize,
|
||||
pub cancelled_worker_count: usize,
|
||||
pub diagnostic_count: usize,
|
||||
pub limits: RuntimeLimits,
|
||||
#[serde(default = "unknown_platform_component")]
|
||||
pub os: String,
|
||||
#[serde(default = "unknown_platform_component")]
|
||||
|
||||
@@ -1,56 +1,6 @@
|
||||
use crate::identity::WorkerRef;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Event cursor. `next_event_id` is the first event id that should be returned
|
||||
/// by the next poll.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EventCursor {
|
||||
pub next_event_id: u64,
|
||||
}
|
||||
|
||||
/// Placeholder subscription handle for future streaming APIs. v0 is explicit
|
||||
/// poll-only so HTTP/WS/SSE dependencies are not pulled into this crate.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EventSubscription {
|
||||
pub cursor: EventCursor,
|
||||
pub mode: EventSubscriptionMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EventSubscriptionMode {
|
||||
PollOnly,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeEvent {
|
||||
pub id: u64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worker_ref: Option<WorkerRef>,
|
||||
pub kind: RuntimeEventKind,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum RuntimeEventKind {
|
||||
RuntimeStarted,
|
||||
RuntimeStopped,
|
||||
WorkerCreated,
|
||||
WorkerExecutionRestored,
|
||||
WorkerInputAccepted,
|
||||
WorkerStopped,
|
||||
WorkerCancelled,
|
||||
WorkerDeleted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeEventBatch {
|
||||
pub cursor: EventCursor,
|
||||
pub events: Vec<RuntimeEvent>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
/// Runtime-local cursor for worker-scoped WebSocket observation.
|
||||
#[cfg(feature = "ws-server")]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
@@ -23,6 +23,7 @@ use crate::execution::{
|
||||
WorkerExecutionRestoreRequest, WorkerExecutionResult, WorkerExecutionRunState,
|
||||
WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
||||
};
|
||||
use crate::identity::WorkerRef;
|
||||
use crate::interaction::{WorkerInput, WorkerInputKind};
|
||||
use crate::resource::{BackendResourceClient, ProfileSourceArchiveCache};
|
||||
use crate::working_directory::{
|
||||
@@ -36,12 +37,13 @@ use session_store::{CombinedStore, FsWorkerStore};
|
||||
use tokio::runtime::Runtime;
|
||||
#[cfg(feature = "ws-server")]
|
||||
use tokio::sync::broadcast;
|
||||
use workdir::{LocalWorkdirSession, Workdir, WorkdirSessionCapabilities, WorkdirSessionHandle};
|
||||
|
||||
#[cfg(feature = "ws-server")]
|
||||
use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session};
|
||||
use worker::{
|
||||
PromptLoader, Worker, WorkerController, WorkerError, WorkerFilesystemAuthority, WorkerHandle,
|
||||
WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
|
||||
PromptLoader, RuntimeWorkspaceHttpClient, Worker, WorkerController, WorkerError,
|
||||
WorkerFilesystemAuthority, WorkerHandle, WorkerWorkspaceContext, WorkspaceId,
|
||||
};
|
||||
|
||||
const DEFAULT_BACKEND_ID: &str = "worker-crate";
|
||||
@@ -259,29 +261,42 @@ enum RuntimeWorkspaceBackendRef {
|
||||
Http {
|
||||
workspace_id: String,
|
||||
base_url: String,
|
||||
runtime_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl RuntimeWorkspaceBackendRef {
|
||||
fn from_worker_request(request: &CreateWorkerRequest) -> Self {
|
||||
if let Some(api) = request.workspace_api.as_ref() {
|
||||
if let Some(api) = request.workspace_api.as_ref()
|
||||
&& let Some(runtime_id) = api
|
||||
.runtime_id
|
||||
.as_ref()
|
||||
.filter(|runtime_id| !runtime_id.trim().is_empty())
|
||||
{
|
||||
return Self::Http {
|
||||
workspace_id: api.workspace_id.clone(),
|
||||
base_url: api.base_url.clone(),
|
||||
runtime_id: runtime_id.clone(),
|
||||
};
|
||||
}
|
||||
Self::None
|
||||
}
|
||||
|
||||
fn worker_context(&self) -> WorkerWorkspaceContext {
|
||||
fn worker_context(&self, worker_ref: &WorkerRef) -> WorkerWorkspaceContext {
|
||||
match self {
|
||||
Self::None => WorkerWorkspaceContext::no_workspace(),
|
||||
Self::Http {
|
||||
workspace_id,
|
||||
base_url,
|
||||
runtime_id,
|
||||
} => WorkerWorkspaceContext::with_client(
|
||||
WorkspaceId::new(workspace_id.clone()).ok(),
|
||||
WorkspaceClient::http(workspace_id.clone(), base_url.clone()),
|
||||
Arc::new(RuntimeWorkspaceHttpClient::new(
|
||||
workspace_id.clone(),
|
||||
base_url.clone(),
|
||||
runtime_id.clone(),
|
||||
worker_ref.worker_id.to_string(),
|
||||
)),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -342,6 +357,21 @@ async fn fetch_profile_source_archive_http(
|
||||
)
|
||||
}
|
||||
|
||||
fn runtime_local_workdir_session(
|
||||
workdir_id: &str,
|
||||
root: &Path,
|
||||
cwd: &Path,
|
||||
scope: manifest::SharedScope,
|
||||
) -> WorkdirSessionHandle {
|
||||
Arc::new(LocalWorkdirSession::materialized_bound(
|
||||
Workdir::new(workdir_id),
|
||||
root.to_path_buf(),
|
||||
cwd.to_path_buf(),
|
||||
scope,
|
||||
WorkdirSessionCapabilities::ALL,
|
||||
))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
async fn spawn_controller(
|
||||
@@ -368,7 +398,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
.unwrap_or(WorkerFilesystemAuthority::None);
|
||||
let workspace_backend_ref =
|
||||
RuntimeWorkspaceBackendRef::from_worker_request(&request.request);
|
||||
let workspace_context = workspace_backend_ref.worker_context();
|
||||
let workspace_context = workspace_backend_ref.worker_context(&request.worker_ref);
|
||||
let selector = profile.as_ref();
|
||||
let archive = self
|
||||
.resolve_profile_source_archive(&request.request.profile_source)
|
||||
@@ -408,7 +438,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
})?;
|
||||
let store = CombinedStore::new(session_store, worker_metadata_store);
|
||||
|
||||
let worker = Worker::from_manifest_with_context(
|
||||
let mut worker = Worker::from_manifest_with_context(
|
||||
manifest,
|
||||
store,
|
||||
loader,
|
||||
@@ -417,6 +447,16 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create Worker from profile: {err}"))?;
|
||||
if let Some(binding) = request.working_directory.as_ref() {
|
||||
worker.bind_workdir_session(Some(runtime_local_workdir_session(
|
||||
&binding.working_directory.id,
|
||||
binding.root(),
|
||||
binding.cwd(),
|
||||
worker.scope().clone(),
|
||||
)));
|
||||
} else {
|
||||
worker.bind_workdir_session(None);
|
||||
}
|
||||
|
||||
let runtime_base = self.runtime_base_dir()?;
|
||||
let (handle, _shutdown_rx) = WorkerController::spawn_runtime_managed(worker, &runtime_base)
|
||||
@@ -442,7 +482,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
.unwrap_or(WorkerFilesystemAuthority::None);
|
||||
let workspace_backend_ref =
|
||||
RuntimeWorkspaceBackendRef::from_worker_request(&request.request);
|
||||
let workspace_context = workspace_backend_ref.worker_context();
|
||||
let workspace_context = workspace_backend_ref.worker_context(&request.worker_ref);
|
||||
let (manifest, loader) = Self::restore_fallback_manifest(&worker_name)?;
|
||||
|
||||
let store_dir = self.store_dir()?;
|
||||
@@ -461,7 +501,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
})?;
|
||||
let store = CombinedStore::new(session_store, worker_metadata_store);
|
||||
|
||||
let worker = match Worker::restore_from_worker_metadata_with_context(
|
||||
let mut worker = match Worker::restore_from_worker_metadata_with_context(
|
||||
&worker_name,
|
||||
manifest.clone(),
|
||||
store,
|
||||
@@ -502,6 +542,16 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
}
|
||||
Err(err) => return Err(format!("failed to restore Worker from metadata: {err}")),
|
||||
};
|
||||
if let Some(binding) = request.working_directory.as_ref() {
|
||||
worker.bind_workdir_session(Some(runtime_local_workdir_session(
|
||||
&binding.working_directory.id,
|
||||
binding.root(),
|
||||
binding.cwd(),
|
||||
worker.scope().clone(),
|
||||
)));
|
||||
} else {
|
||||
worker.bind_workdir_session(None);
|
||||
}
|
||||
|
||||
let runtime_base = self.runtime_base_dir()?;
|
||||
let (handle, _shutdown_rx) = WorkerController::spawn_runtime_managed(worker, &runtime_base)
|
||||
@@ -795,6 +845,31 @@ where
|
||||
materializer.working_directory_status(working_directory_id)
|
||||
}
|
||||
|
||||
fn open_workdir_session(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
) -> Result<WorkdirSessionHandle, WorkingDirectoryDiagnostic> {
|
||||
let Some(materializer) = self.working_directory_materializer.as_ref() else {
|
||||
return Err(WorkingDirectoryDiagnostic::rejected(
|
||||
"working_directory_materializer_unavailable",
|
||||
"Workdir session requested, but no materializer is configured for this runtime backend",
|
||||
));
|
||||
};
|
||||
let binding = materializer.bind_working_directory(working_directory_id, None)?;
|
||||
let scope = manifest::Scope::writable(binding.root()).map_err(|error| {
|
||||
WorkingDirectoryDiagnostic::rejected(
|
||||
"workdir_session_scope_invalid",
|
||||
format!("failed to create Workdir session scope: {error}"),
|
||||
)
|
||||
})?;
|
||||
Ok(runtime_local_workdir_session(
|
||||
working_directory_id,
|
||||
binding.root(),
|
||||
binding.cwd(),
|
||||
manifest::SharedScope::new(scope),
|
||||
))
|
||||
}
|
||||
|
||||
fn cleanup_working_directory(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
@@ -1188,7 +1263,9 @@ where
|
||||
};
|
||||
workers
|
||||
.get(handle.worker_ref())
|
||||
.map(|execution| execution.handle.completion_entries(kind, prefix))
|
||||
.map(|execution| {
|
||||
futures::executor::block_on(execution.handle.completion_entries(kind, prefix))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -1276,7 +1353,7 @@ mod tests {
|
||||
store_dir: PathBuf,
|
||||
worker_metadata_dir: PathBuf,
|
||||
observed_cwds: Arc<Mutex<Vec<PathBuf>>>,
|
||||
observed_workspace_clients: Arc<Mutex<Vec<WorkspaceClient>>>,
|
||||
observed_workspace_clients: Arc<Mutex<Vec<(String, Option<String>, bool)>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -1325,11 +1402,13 @@ mod tests {
|
||||
.unwrap_or_else(|| self.cwd.clone());
|
||||
let workspace_backend_ref =
|
||||
RuntimeWorkspaceBackendRef::from_worker_request(&request.request);
|
||||
let workspace_context = workspace_backend_ref.worker_context();
|
||||
self.observed_workspace_clients
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(workspace_context.client().clone());
|
||||
let workspace_context = workspace_backend_ref.worker_context(&request.worker_ref);
|
||||
let workspace_client = workspace_context.client();
|
||||
self.observed_workspace_clients.lock().unwrap().push((
|
||||
workspace_client.kind().to_string(),
|
||||
workspace_client.workspace_id().map(str::to_string),
|
||||
workspace_client.is_available(),
|
||||
));
|
||||
let scope = Scope::writable(&scope_root).map_err(|err| err.to_string())?;
|
||||
let worker = Worker::new(
|
||||
manifest,
|
||||
@@ -1438,6 +1517,8 @@ mod tests {
|
||||
fn create_request(_name: &str) -> CreateWorkerRequest {
|
||||
let bundle = test_bundle();
|
||||
CreateWorkerRequest {
|
||||
idempotency_key: None,
|
||||
idempotency_fingerprint: None,
|
||||
profile: ProfileSelector::Builtin("builtin:companion".to_string()),
|
||||
display_name: None,
|
||||
profile_source: crate::catalog::ProfileSourceArchiveSource::Embedded {
|
||||
@@ -1556,6 +1637,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_opens_a_fresh_session_for_the_same_workdir_identity() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let spawned = runtime_local_workdir_session(
|
||||
"working-directory-42",
|
||||
root.path(),
|
||||
root.path(),
|
||||
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
|
||||
);
|
||||
let restored = runtime_local_workdir_session(
|
||||
"working-directory-42",
|
||||
root.path(),
|
||||
root.path(),
|
||||
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
|
||||
);
|
||||
|
||||
assert_eq!(spawned.workdir().id().as_str(), "working-directory-42");
|
||||
assert_eq!(restored.workdir().id().as_str(), "working-directory-42");
|
||||
assert!(!Arc::ptr_eq(&spawned, &restored));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedded_profile_source_archive_does_not_require_backend_resource_fetch() {
|
||||
let factory = ProfileRuntimeWorkerFactory::new(tempfile::tempdir().unwrap().path());
|
||||
@@ -1673,6 +1775,7 @@ mod tests {
|
||||
request.workspace_api = Some(crate::catalog::WorkspaceApiRef {
|
||||
workspace_id: "ws-test".to_string(),
|
||||
base_url: "http://127.0.0.1:3999".to_string(),
|
||||
runtime_id: Some("runtime-test".to_string()),
|
||||
});
|
||||
let detail = runtime.create_worker(request).unwrap();
|
||||
|
||||
@@ -1704,7 +1807,11 @@ mod tests {
|
||||
assert!(observed_cwds.lock().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
observed_workspace_clients.lock().unwrap().as_slice(),
|
||||
&[WorkspaceClient::http("ws-test", "http://127.0.0.1:3999")]
|
||||
&[(
|
||||
"runtime-http-proxy".to_string(),
|
||||
Some("ws-test".to_string()),
|
||||
true,
|
||||
)]
|
||||
);
|
||||
let names = captured_tool_names(&client, 0);
|
||||
for forbidden in core_filesystem_tool_names() {
|
||||
@@ -1781,9 +1888,7 @@ mod tests {
|
||||
assert!(cwd.join("README.md").exists());
|
||||
assert_eq!(
|
||||
observed_workspace_clients.lock().unwrap().as_slice(),
|
||||
&[WorkspaceClient::Unavailable {
|
||||
reason: "no workspace configured".to_string()
|
||||
}]
|
||||
&[("unavailable".to_string(), None, false)]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,10 +40,11 @@ impl WorkingDirectory {
|
||||
WorkingDirectorySummary {
|
||||
working_directory_id: self.id.clone(),
|
||||
repository_id: self.repository_id.clone(),
|
||||
requested_selector: self.evidence.requested_selector.clone(),
|
||||
creation_selector: self.evidence.requested_selector.clone(),
|
||||
creation_ref: Some(self.evidence.resolved_commit.clone()),
|
||||
current_selector: None,
|
||||
current_ref: None,
|
||||
materializer_kind: self.materializer_kind.clone(),
|
||||
resolved_commit: Some(self.evidence.resolved_commit.clone()),
|
||||
resolved_tree: self.evidence.resolved_tree.clone(),
|
||||
cleanup_target: Some(self.cleanup_target.clone()),
|
||||
status: self.status.clone(),
|
||||
cleanliness: None,
|
||||
@@ -88,6 +89,9 @@ impl WorkingDirectoryBinding {
|
||||
}
|
||||
let mut summary = working_directory.status_summary();
|
||||
summary.cleanliness = if summary.status == WorkingDirectoryStatusKind::Active {
|
||||
let (current_selector, current_ref) = binding_current_revision(self);
|
||||
summary.current_selector = current_selector;
|
||||
summary.current_ref = current_ref;
|
||||
Some(binding_cleanliness(self))
|
||||
} else {
|
||||
Some("unknown".to_string())
|
||||
@@ -171,6 +175,22 @@ fn binding_paths_are_available(binding: &WorkingDirectoryBinding) -> bool {
|
||||
source_repository_path.is_dir()
|
||||
}
|
||||
|
||||
fn binding_current_revision(binding: &WorkingDirectoryBinding) -> (Option<String>, Option<String>) {
|
||||
let current_ref = git_stdout(binding.root(), ["rev-parse", "HEAD"])
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty());
|
||||
if current_ref.is_none() {
|
||||
return (None, None);
|
||||
}
|
||||
let current_selector = git_stdout(
|
||||
binding.root(),
|
||||
["symbolic-ref", "--short", "--quiet", "HEAD"],
|
||||
)
|
||||
.ok()
|
||||
.filter(|value| !value.is_empty());
|
||||
(current_selector, current_ref)
|
||||
}
|
||||
|
||||
fn binding_cleanliness(binding: &WorkingDirectoryBinding) -> String {
|
||||
match git_stdout(binding.root(), ["status", "--porcelain"]) {
|
||||
Ok(output) if output.is_empty() => "clean".to_string(),
|
||||
@@ -208,10 +228,11 @@ impl LocalGitWorktreeMaterializer {
|
||||
summary: WorkingDirectorySummary {
|
||||
working_directory_id: working_directory_id.to_string(),
|
||||
repository_id: "unknown".to_string(),
|
||||
requested_selector: None,
|
||||
creation_selector: None,
|
||||
creation_ref: None,
|
||||
current_selector: None,
|
||||
current_ref: None,
|
||||
materializer_kind: MaterializerKind::LocalGitWorktree,
|
||||
resolved_commit: None,
|
||||
resolved_tree: None,
|
||||
cleanup_target: Some(WorkingDirectoryCleanupTarget {
|
||||
kind: "local_git_worktree".to_string(),
|
||||
working_directory_id: working_directory_id.to_string(),
|
||||
@@ -930,12 +951,39 @@ mod tests {
|
||||
listed[0].summary.working_directory_id,
|
||||
working_directory.working_directory.id
|
||||
);
|
||||
assert_eq!(listed[0].summary.creation_selector.as_deref(), Some("HEAD"));
|
||||
assert_eq!(listed[0].summary.current_selector, None);
|
||||
assert_eq!(
|
||||
listed[0].summary.requested_selector.as_deref(),
|
||||
Some("HEAD")
|
||||
listed[0].summary.current_ref,
|
||||
listed[0].summary.creation_ref
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_directory_observes_current_selector_and_ref_without_changing_creation_evidence() {
|
||||
let repo = create_clean_repo();
|
||||
let runtime_root = tempfile::tempdir().unwrap();
|
||||
let materializer = LocalGitWorktreeMaterializer::new(runtime_root.path());
|
||||
let working_directory = materializer.create(&request(repo.path())).unwrap();
|
||||
let bound = materializer
|
||||
.bind_working_directory(&working_directory.working_directory.id, None)
|
||||
.unwrap();
|
||||
let initial_ref = bound.status().summary.creation_ref.expect("creation ref");
|
||||
|
||||
git(&bound.root, &["switch", "-c", "observed-branch"]);
|
||||
fs::write(bound.root.join("observed.txt"), "observed\n").unwrap();
|
||||
git(&bound.root, &["add", "observed.txt"]);
|
||||
git(&bound.root, &["commit", "-m", "advance workdir"]);
|
||||
|
||||
let summary = materializer.list_working_directories().unwrap()[0]
|
||||
.summary
|
||||
.clone();
|
||||
assert_eq!(summary.creation_selector.as_deref(), Some("HEAD"));
|
||||
assert_eq!(summary.creation_ref.as_deref(), Some(initial_ref.as_str()));
|
||||
assert_eq!(summary.current_selector.as_deref(), Some("observed-branch"));
|
||||
assert_ne!(summary.current_ref.as_deref(), Some(initial_ref.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_cwd_rejects_absolute_parent_nonexistent_file_and_symlink_escape() {
|
||||
let repo = create_clean_repo();
|
||||
|
||||
@@ -26,6 +26,7 @@ tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "proce
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tools = { workspace = true }
|
||||
workdir = { workspace = true }
|
||||
minijinja = "2.19.0"
|
||||
chrono = "0.4"
|
||||
include_dir = "0.7.4"
|
||||
|
||||
@@ -26,10 +26,14 @@ use llm_engine::Item;
|
||||
use llm_engine::interceptor::{Interceptor, PreRequestAction, PreToolAction, ToolCallInfo};
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput, ToolResult};
|
||||
use serde::Deserialize;
|
||||
use tools::ScopedFs;
|
||||
#[cfg(test)]
|
||||
use workdir::LocalWorkdirSession;
|
||||
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
|
||||
|
||||
use crate::compact::usage_tracker::UsageTracker;
|
||||
use crate::fs_view::{ReadRequirement, slice_lines};
|
||||
use crate::fs_view::ReadRequirement;
|
||||
#[cfg(test)]
|
||||
use crate::fs_view::slice_lines;
|
||||
use crate::session_reference::{
|
||||
ReadDetail, ReadOptions, ReadSelector, SearchOptions, SessionReferenceView, ToolPart,
|
||||
};
|
||||
@@ -323,7 +327,7 @@ fn truncate_to_token_budget(text: &mut String, max_tokens: u64) -> bool {
|
||||
}
|
||||
|
||||
struct MarkReadRequiredTool {
|
||||
fs: ScopedFs,
|
||||
session: WorkdirSessionHandle,
|
||||
ctx: Arc<Mutex<CompactWorkerContext>>,
|
||||
}
|
||||
|
||||
@@ -338,14 +342,22 @@ impl Tool for MarkReadRequiredTool {
|
||||
ToolError::InvalidArgument(format!("invalid mark_read_required input: {e}"))
|
||||
})?;
|
||||
|
||||
// Read the file through the shared ScopedFs so scope and I/O
|
||||
// errors surface the same way the regular `read_file` tool does.
|
||||
let bytes = self
|
||||
.fs
|
||||
.read_bytes(¶ms.file_path)
|
||||
// Read through the shared WorkdirSession so scope and I/O errors surface the
|
||||
// same way the regular `read_file` tool does.
|
||||
let path = WorkdirPath::new(params.file_path.to_string_lossy())
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
let result = self
|
||||
.session
|
||||
.read(ReadRequest {
|
||||
path,
|
||||
offset: params.offset.unwrap_or(0),
|
||||
limit: params.limit.unwrap_or(usize::MAX),
|
||||
max_bytes: 4 * 1024 * 1024,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(format!("read failed: {e}")))?;
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let slice = slice_lines(&text, params.offset.unwrap_or(0), params.limit);
|
||||
let text = String::from_utf8_lossy(&result.bytes);
|
||||
let slice = text.as_ref();
|
||||
let estimated_tokens = estimate_tokens(slice.len());
|
||||
|
||||
let mut guard = self.ctx.lock().expect("compact worker context poisoned");
|
||||
@@ -442,7 +454,7 @@ impl Tool for WriteSummaryTool {
|
||||
}
|
||||
|
||||
pub(crate) fn mark_read_required_tool(
|
||||
fs: ScopedFs,
|
||||
session: WorkdirSessionHandle,
|
||||
ctx: Arc<Mutex<CompactWorkerContext>>,
|
||||
) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
@@ -452,7 +464,7 @@ pub(crate) fn mark_read_required_tool(
|
||||
.description(MARK_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(MarkReadRequiredTool {
|
||||
fs: fs.clone(),
|
||||
session: session.clone(),
|
||||
ctx: ctx.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
@@ -623,9 +635,9 @@ mod tests {
|
||||
use super::*;
|
||||
use manifest::Scope;
|
||||
|
||||
fn make_fs(tmp: &std::path::Path) -> ScopedFs {
|
||||
fn make_fs(tmp: &std::path::Path) -> WorkdirSessionHandle {
|
||||
let scope = Scope::writable(tmp.to_path_buf()).unwrap();
|
||||
ScopedFs::new(scope, tmp.to_path_buf())
|
||||
Arc::new(LocalWorkdirSession::new(scope, tmp.to_path_buf()))
|
||||
}
|
||||
|
||||
fn make_usage(input: u64) -> llm_engine::timeline::event::UsageEvent {
|
||||
@@ -720,10 +732,11 @@ mod tests {
|
||||
|
||||
let ctx = Arc::new(Mutex::new(CompactWorkerContext::with_budget(1_000)));
|
||||
let tool: Arc<dyn Tool> = Arc::new(MarkReadRequiredTool {
|
||||
fs: make_fs(tmp.path()),
|
||||
session: make_fs(tmp.path()),
|
||||
ctx: ctx.clone(),
|
||||
});
|
||||
let input = serde_json::json!({ "file_path": path.to_str().unwrap() }).to_string();
|
||||
let input = serde_json::json!({ "file_path": path.file_name().unwrap().to_str().unwrap() })
|
||||
.to_string();
|
||||
let out = tool.execute(&input, Default::default()).await.unwrap();
|
||||
|
||||
assert!(out.summary.starts_with("Marked"));
|
||||
@@ -741,10 +754,11 @@ mod tests {
|
||||
|
||||
let ctx = Arc::new(Mutex::new(CompactWorkerContext::with_budget(100)));
|
||||
let tool: Arc<dyn Tool> = Arc::new(MarkReadRequiredTool {
|
||||
fs: make_fs(tmp.path()),
|
||||
session: make_fs(tmp.path()),
|
||||
ctx: ctx.clone(),
|
||||
});
|
||||
let input = serde_json::json!({ "file_path": path.to_str().unwrap() }).to_string();
|
||||
let input = serde_json::json!({ "file_path": path.file_name().unwrap().to_str().unwrap() })
|
||||
.to_string();
|
||||
let res = tool.execute(&input, Default::default()).await;
|
||||
|
||||
assert!(matches!(res, Err(ToolError::ExecutionFailed(_))));
|
||||
|
||||
@@ -26,7 +26,7 @@ use crate::shutdown_after_idle::{
|
||||
use crate::spawn::comm_tools::{read_worker_output_tool, send_to_worker_tool, stop_worker_tool};
|
||||
use crate::spawn::registry::SpawnedWorkerRegistry;
|
||||
use crate::spawn::tool::spawn_worker_tool;
|
||||
use crate::worker::{SystemItemCommitter, Worker, WorkerError, WorkerRunResult, WorkspaceClient};
|
||||
use crate::worker::{SystemItemCommitter, Worker, WorkerError, WorkerRunResult};
|
||||
use protocol::{
|
||||
AlertLevel, AlertSource, ErrorCode, Event, Method, RewindTargetId, RunResult, Segment,
|
||||
TurnResult, WorkerStatus,
|
||||
@@ -84,23 +84,25 @@ impl WorkerHandle {
|
||||
(event, entry_rx)
|
||||
}
|
||||
|
||||
pub fn completion_entries(
|
||||
pub async fn completion_entries(
|
||||
&self,
|
||||
kind: protocol::CompletionKind,
|
||||
prefix: &str,
|
||||
) -> Vec<protocol::CompletionEntry> {
|
||||
match kind {
|
||||
protocol::CompletionKind::File => self
|
||||
.shared_state
|
||||
.fs_view()
|
||||
.map(|view| view.list_file_completions(prefix))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|c| protocol::CompletionEntry {
|
||||
value: c.path,
|
||||
is_dir: c.is_dir,
|
||||
})
|
||||
.collect(),
|
||||
protocol::CompletionKind::File => {
|
||||
let Some(view) = self.shared_state.fs_view() else {
|
||||
return Vec::new();
|
||||
};
|
||||
view.list_file_completions(prefix)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|candidate| protocol::CompletionEntry {
|
||||
value: candidate.path,
|
||||
is_dir: candidate.is_dir,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +222,26 @@ impl WorkerController {
|
||||
}
|
||||
|
||||
async fn spawn_inner<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
runtime_base: &Path,
|
||||
runtime_managed: bool,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||
where
|
||||
C: LlmClient + Clone + 'static,
|
||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let session = worker.workdir_session().cloned();
|
||||
let result = Self::spawn_initialized(worker, runtime_base, runtime_managed).await;
|
||||
if result.is_err()
|
||||
&& let Some(session) = session
|
||||
&& let Err(error) = session.close().await
|
||||
{
|
||||
tracing::warn!(%error, "Workdir session close after controller startup failure failed");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn spawn_initialized<C, St>(
|
||||
mut worker: Worker<C, St>,
|
||||
runtime_base: &Path,
|
||||
runtime_managed: bool,
|
||||
@@ -560,7 +582,7 @@ fn wire_event_bridges_on_engine<C, St>(
|
||||
|
||||
/// Register the builtin file-manipulation tools, optional memory tools,
|
||||
/// and the Worker-orchestration tools (SpawnWorker + comm) on the Worker's
|
||||
/// Engine. Returns the `ScopedFs` clone used to attach a `WorkerFsView` to
|
||||
/// Engine. Returns the WorkdirSession handle used to attach a `WorkerFsView` to
|
||||
/// the shared state.
|
||||
async fn register_worker_tools<C, St>(
|
||||
worker: &mut Worker<C, St>,
|
||||
@@ -568,7 +590,7 @@ async fn register_worker_tools<C, St>(
|
||||
spawner_socket: PathBuf,
|
||||
runtime_base: PathBuf,
|
||||
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
||||
) -> std::io::Result<Option<tools::ScopedFs>>
|
||||
) -> std::io::Result<Option<workdir::WorkdirSessionHandle>>
|
||||
where
|
||||
C: LlmClient + Clone + 'static,
|
||||
St: Store + WorkerMetadataStore + Clone + 'static,
|
||||
@@ -576,6 +598,7 @@ where
|
||||
// Worker-immutable snapshots taken before the mutable worker borrow
|
||||
// below so the worker borrow doesn't conflict with reads on `worker`.
|
||||
let scope_handle = worker.scope().clone();
|
||||
let worker_workdir = worker.workdir_session().cloned();
|
||||
let local_filesystem = worker.local_working_directory().cloned();
|
||||
let local_workspace_root = local_filesystem.as_ref().map(|local| local.root.clone());
|
||||
let task_feature = worker.task_feature();
|
||||
@@ -589,21 +612,19 @@ where
|
||||
let worker_metadata_store = worker.store().clone();
|
||||
let self_parent_socket = worker.callback_socket().cloned();
|
||||
|
||||
// The Worker's SharedScope is the single source of truth for every
|
||||
// ScopedFs when local filesystem authority exists. No-workdir Workers
|
||||
// deliberately skip constructing/registering filesystem and Bash tools.
|
||||
let (fs_for_view, tracker) = if let Some(local) = local_filesystem.as_ref() {
|
||||
let fs = tools::ScopedFs::with_shared_scope(scope_handle.clone(), local.cwd.clone());
|
||||
// Resolve the existing Worker–Workdir binding into the domain provider.
|
||||
// Tools only consume the provider handle; they do not own its root, cwd,
|
||||
// scope, or lifecycle. No-workdir Workers expose no local tools.
|
||||
let (workdir_for_view, tracker) = if let Some(workdir) = worker_workdir {
|
||||
let tracker = tools::Tracker::new();
|
||||
let fs_for_view = fs.clone();
|
||||
worker
|
||||
.engine_mut()
|
||||
.register_tools(tools::core_builtin_tools(
|
||||
fs,
|
||||
workdir.clone(),
|
||||
tracker.clone(),
|
||||
bash_output_dir,
|
||||
));
|
||||
(Some(fs_for_view), Some(tracker))
|
||||
(Some(workdir), Some(tracker))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
@@ -627,21 +648,16 @@ where
|
||||
// Ticket tools are typed operations over the current workspace Ticket backend.
|
||||
// Workspace access must be authority-bound to the Backend Workspace API; the
|
||||
// Worker must not fall back to a local `.yoi/tickets` store.
|
||||
let ticket_backend = match worker.workspace_client() {
|
||||
WorkspaceClient::Http {
|
||||
workspace_id,
|
||||
base_url,
|
||||
} => crate::feature::builtin::ticket::TicketFeatureBackend::WorkspaceHttp {
|
||||
workspace_id: workspace_id.clone(),
|
||||
base_url: base_url.clone(),
|
||||
},
|
||||
_ => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"ticket tools require Backend Workspace API authority",
|
||||
));
|
||||
}
|
||||
};
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
if !workspace_client.is_available() || workspace_client.workspace_id().is_none() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"ticket tools require Backend Workspace API authority",
|
||||
));
|
||||
}
|
||||
let ticket_backend = crate::feature::builtin::ticket::TicketFeatureBackend::WorkspaceClient(
|
||||
workspace_client,
|
||||
);
|
||||
feature_registry.add_module(
|
||||
crate::feature::builtin::ticket::ticket_tools_feature_with_backend(
|
||||
ticket_backend,
|
||||
@@ -668,21 +684,16 @@ where
|
||||
}
|
||||
|
||||
{
|
||||
let workspace_client = worker.workspace_client().clone();
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
let engine = worker.engine_mut();
|
||||
|
||||
// Objective tools expose read-only project Objective context through the
|
||||
// Backend Workspace API. Workers must not guess local `.yoi/objectives`
|
||||
// paths or read Objective files directly.
|
||||
if feature_config.objective.enabled {
|
||||
if let WorkspaceClient::Http {
|
||||
workspace_id,
|
||||
base_url,
|
||||
} = &workspace_client
|
||||
{
|
||||
if workspace_client.is_available() && workspace_client.workspace_id().is_some() {
|
||||
for definition in crate::feature::builtin::objective::workspace_http_objective_tools(
|
||||
workspace_id.clone(),
|
||||
base_url.clone(),
|
||||
workspace_client.clone(),
|
||||
) {
|
||||
engine.register_tool(definition);
|
||||
}
|
||||
@@ -705,20 +716,14 @@ where
|
||||
"[feature.memory].enabled = true requires a [memory] configuration section",
|
||||
)
|
||||
})?;
|
||||
if let WorkspaceClient::Http {
|
||||
workspace_id,
|
||||
base_url,
|
||||
} = workspace_client
|
||||
{
|
||||
if workspace_client.is_available() && workspace_client.workspace_id().is_some() {
|
||||
let definitions = if feature_config.memory.staging {
|
||||
crate::feature::builtin::memory::workspace_http_memory_consolidation_tools(
|
||||
workspace_id,
|
||||
base_url,
|
||||
workspace_client.clone(),
|
||||
)
|
||||
} else {
|
||||
crate::feature::builtin::memory::workspace_http_memory_tools(
|
||||
workspace_id,
|
||||
base_url,
|
||||
workspace_client.clone(),
|
||||
)
|
||||
};
|
||||
for definition in definitions {
|
||||
@@ -790,7 +795,7 @@ where
|
||||
if let Some(tracker) = tracker {
|
||||
worker.attach_tracker(tracker);
|
||||
}
|
||||
Ok(fs_for_view)
|
||||
Ok(workdir_for_view)
|
||||
}
|
||||
|
||||
/// Idle/Paused event loop. Each iteration either fires a staged
|
||||
@@ -1190,10 +1195,16 @@ async fn controller_loop<C, St>(
|
||||
}
|
||||
|
||||
// Background memory jobs own extract/consolidate workers after a
|
||||
// turn completes. Join them before the controller task exits so
|
||||
// staging writes and consolidation cleanups are not abandoned.
|
||||
// turn completes. Join them before closing the Workdir session so no
|
||||
// Worker-owned task can outlive its operation attachment.
|
||||
worker.wait_for_memory_jobs().await;
|
||||
|
||||
if let Some(session) = worker.workdir_session()
|
||||
&& let Err(error) = session.close().await
|
||||
{
|
||||
tracing::warn!(%error, "Workdir session close failed");
|
||||
}
|
||||
|
||||
// Report upward that this Worker is stopping before the controller
|
||||
// task exits. Awaited (not fire-and-forget): after `shutdown_tx.send`
|
||||
// the process may exit quickly, and a spawned task would be killed
|
||||
|
||||
@@ -20,27 +20,25 @@ use schemars::JsonSchema;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::worker::WorkspaceClient;
|
||||
use crate::worker::{
|
||||
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkspaceHttpMemoryBackend {
|
||||
workspace_id: String,
|
||||
base_url: String,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
impl WorkspaceHttpMemoryBackend {
|
||||
pub fn new(workspace_id: impl Into<String>, base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
workspace_id: workspace_id.into(),
|
||||
base_url: base_url.into(),
|
||||
}
|
||||
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
pub async fn execute_operation(
|
||||
&self,
|
||||
operation: MemoryBackendOperation,
|
||||
) -> Result<MemoryBackendOperationResult, WorkspaceMemoryBackendError> {
|
||||
execute_http_memory_backend(&self.workspace_id, &self.base_url, operation).await
|
||||
execute_memory_backend(self.client.as_ref(), operation).await
|
||||
}
|
||||
|
||||
async fn execute(&self, operation: MemoryBackendOperation) -> Result<ToolOutput, ToolError> {
|
||||
@@ -59,7 +57,7 @@ pub enum WorkspaceMemoryBackendError {
|
||||
#[error("workspace memory backend is unavailable: {reason}")]
|
||||
Unavailable { reason: String },
|
||||
#[error("workspace memory backend request failed: {0}")]
|
||||
Request(#[from] reqwest::Error),
|
||||
Request(#[from] WorkspaceClientError),
|
||||
#[error("workspace memory backend returned HTTP {status}: {body}")]
|
||||
Http {
|
||||
status: reqwest::StatusCode,
|
||||
@@ -71,73 +69,49 @@ pub enum WorkspaceMemoryBackendError {
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl WorkspaceClient {
|
||||
impl dyn WorkspaceClient + '_ {
|
||||
pub async fn execute_memory_backend_operation(
|
||||
&self,
|
||||
operation: MemoryBackendOperation,
|
||||
) -> Result<MemoryBackendOperationResult, WorkspaceMemoryBackendError> {
|
||||
match self {
|
||||
WorkspaceClient::Http {
|
||||
workspace_id,
|
||||
base_url,
|
||||
} => execute_http_memory_backend(workspace_id, base_url, operation).await,
|
||||
WorkspaceClient::Available { kind } => Err(WorkspaceMemoryBackendError::Unavailable {
|
||||
reason: format!(
|
||||
"workspace client kind `{kind}` does not expose the Backend Workspace API"
|
||||
),
|
||||
}),
|
||||
WorkspaceClient::Unavailable { reason } => {
|
||||
Err(WorkspaceMemoryBackendError::Unavailable {
|
||||
reason: reason.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
execute_memory_backend(self, operation).await
|
||||
}
|
||||
|
||||
pub async fn request_memory_staging_consolidation(
|
||||
&self,
|
||||
operation: MemoryConsolidateStagingOperation,
|
||||
) -> Result<MemoryConsolidationOutput, WorkspaceMemoryBackendError> {
|
||||
match self {
|
||||
WorkspaceClient::Http {
|
||||
workspace_id,
|
||||
base_url,
|
||||
} => execute_http_memory_consolidation(workspace_id, base_url, operation).await,
|
||||
WorkspaceClient::Available { kind } => Err(WorkspaceMemoryBackendError::Unavailable {
|
||||
reason: format!(
|
||||
"workspace client kind `{kind}` does not expose the Backend Workspace API"
|
||||
),
|
||||
}),
|
||||
WorkspaceClient::Unavailable { reason } => {
|
||||
Err(WorkspaceMemoryBackendError::Unavailable {
|
||||
reason: reason.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
execute_memory_consolidation(self, operation).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_http_memory_backend(
|
||||
workspace_id: &str,
|
||||
base_url: &str,
|
||||
async fn execute_memory_backend(
|
||||
client: &dyn WorkspaceClient,
|
||||
operation: MemoryBackendOperation,
|
||||
) -> Result<MemoryBackendOperationResult, WorkspaceMemoryBackendError> {
|
||||
let url = format!(
|
||||
"{}/api/w/{}/memory/backend",
|
||||
base_url.trim_end_matches('/'),
|
||||
workspace_id
|
||||
);
|
||||
let response = reqwest::Client::new()
|
||||
.post(url)
|
||||
.json(&operation)
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let body = response.text().await?;
|
||||
if !status.is_success() {
|
||||
return Err(WorkspaceMemoryBackendError::Http { status, body });
|
||||
let workspace_id =
|
||||
client
|
||||
.workspace_id()
|
||||
.ok_or_else(|| WorkspaceMemoryBackendError::Unavailable {
|
||||
reason: format!(
|
||||
"workspace client kind `{}` has no workspace id",
|
||||
client.kind()
|
||||
),
|
||||
})?;
|
||||
let response = client.execute(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{workspace_id}/memory/backend"),
|
||||
serde_json::to_string(&operation)?,
|
||||
))?;
|
||||
let status = reqwest::StatusCode::from_u16(response.status)
|
||||
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
if !response.is_success() {
|
||||
return Err(WorkspaceMemoryBackendError::Http {
|
||||
status,
|
||||
body: response.body,
|
||||
});
|
||||
}
|
||||
match serde_json::from_str::<MemoryBackendHttpResponse>(&body)? {
|
||||
match serde_json::from_str::<MemoryBackendHttpResponse>(&response.body)? {
|
||||
MemoryBackendHttpResponse::Ok { result } => Ok(result),
|
||||
MemoryBackendHttpResponse::Error { message } => {
|
||||
Err(WorkspaceMemoryBackendError::Backend(message))
|
||||
@@ -145,34 +119,37 @@ async fn execute_http_memory_backend(
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_http_memory_consolidation(
|
||||
workspace_id: &str,
|
||||
base_url: &str,
|
||||
async fn execute_memory_consolidation(
|
||||
client: &dyn WorkspaceClient,
|
||||
operation: MemoryConsolidateStagingOperation,
|
||||
) -> Result<MemoryConsolidationOutput, WorkspaceMemoryBackendError> {
|
||||
let url = format!(
|
||||
"{}/api/w/{}/memory/consolidation",
|
||||
base_url.trim_end_matches('/'),
|
||||
workspace_id
|
||||
);
|
||||
let response = reqwest::Client::new()
|
||||
.post(url)
|
||||
.json(&operation)
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let body = response.text().await?;
|
||||
if !status.is_success() {
|
||||
return Err(WorkspaceMemoryBackendError::Http { status, body });
|
||||
let workspace_id =
|
||||
client
|
||||
.workspace_id()
|
||||
.ok_or_else(|| WorkspaceMemoryBackendError::Unavailable {
|
||||
reason: format!(
|
||||
"workspace client kind `{}` has no workspace id",
|
||||
client.kind()
|
||||
),
|
||||
})?;
|
||||
let response = client.execute(WorkspaceRequest::json(
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("/api/w/{workspace_id}/memory/consolidation"),
|
||||
serde_json::to_string(&operation)?,
|
||||
))?;
|
||||
let status = reqwest::StatusCode::from_u16(response.status)
|
||||
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
if !response.is_success() {
|
||||
return Err(WorkspaceMemoryBackendError::Http {
|
||||
status,
|
||||
body: response.body,
|
||||
});
|
||||
}
|
||||
serde_json::from_str::<MemoryConsolidationOutput>(&body).map_err(Into::into)
|
||||
serde_json::from_str::<MemoryConsolidationOutput>(&response.body).map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn workspace_http_memory_tools(
|
||||
workspace_id: impl Into<String>,
|
||||
base_url: impl Into<String>,
|
||||
) -> Vec<ToolDefinition> {
|
||||
let backend = WorkspaceHttpMemoryBackend::new(workspace_id, base_url);
|
||||
pub fn workspace_http_memory_tools(client: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
|
||||
let backend = WorkspaceHttpMemoryBackend::new(client);
|
||||
vec![
|
||||
memory_tool(
|
||||
"MemoryReadDocument",
|
||||
@@ -215,13 +192,10 @@ pub fn workspace_http_memory_tools(
|
||||
}
|
||||
|
||||
pub fn workspace_http_memory_consolidation_tools(
|
||||
workspace_id: impl Into<String>,
|
||||
base_url: impl Into<String>,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
) -> Vec<ToolDefinition> {
|
||||
let workspace_id = workspace_id.into();
|
||||
let base_url = base_url.into();
|
||||
let mut tools = workspace_http_memory_tools(workspace_id.clone(), base_url.clone());
|
||||
let backend = WorkspaceHttpMemoryBackend::new(workspace_id, base_url);
|
||||
let mut tools = workspace_http_memory_tools(client.clone());
|
||||
let backend = WorkspaceHttpMemoryBackend::new(client);
|
||||
tools.extend([
|
||||
memory_tool(
|
||||
"MemoryStagingList",
|
||||
@@ -370,6 +344,15 @@ mod tests {
|
||||
use super::*;
|
||||
use llm_engine::tool::ToolDefinition;
|
||||
|
||||
fn test_client() -> Arc<dyn WorkspaceClient> {
|
||||
Arc::new(crate::worker::RuntimeWorkspaceHttpClient::new(
|
||||
"workspace",
|
||||
"http://backend",
|
||||
"test-runtime",
|
||||
"test-worker",
|
||||
))
|
||||
}
|
||||
|
||||
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
|
||||
let mut names = definitions
|
||||
.into_iter()
|
||||
@@ -390,10 +373,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn normal_workspace_memory_tools_do_not_include_staging_tools() {
|
||||
let names = tool_names(workspace_http_memory_tools(
|
||||
"workspace".to_string(),
|
||||
"http://backend".to_string(),
|
||||
));
|
||||
let names = tool_names(workspace_http_memory_tools(test_client()));
|
||||
|
||||
assert!(names.contains(&"MemoryQuery".to_string()));
|
||||
assert!(names.contains(&"MemoryReadDocument".to_string()));
|
||||
@@ -410,7 +390,7 @@ mod tests {
|
||||
#[test]
|
||||
fn document_update_schema_is_edit_like_and_staging_close_has_no_legacy_kinds() {
|
||||
let update_schema = tool_meta(
|
||||
workspace_http_memory_tools("workspace".to_string(), "http://backend".to_string()),
|
||||
workspace_http_memory_tools(test_client()),
|
||||
"MemoryUpdateDocument",
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -423,10 +403,7 @@ mod tests {
|
||||
assert!(update_schema["properties"].get("body_md").is_none());
|
||||
|
||||
let close_schema_text = tool_meta(
|
||||
workspace_http_memory_consolidation_tools(
|
||||
"workspace".to_string(),
|
||||
"http://backend".to_string(),
|
||||
),
|
||||
workspace_http_memory_consolidation_tools(test_client()),
|
||||
"MemoryStagingClose",
|
||||
)
|
||||
.to_string();
|
||||
@@ -440,10 +417,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn consolidation_workspace_memory_tools_include_staging_tools() {
|
||||
let names = tool_names(workspace_http_memory_consolidation_tools(
|
||||
"workspace".to_string(),
|
||||
"http://backend".to_string(),
|
||||
));
|
||||
let names = tool_names(workspace_http_memory_consolidation_tools(test_client()));
|
||||
|
||||
assert!(names.contains(&"MemoryQuery".to_string()));
|
||||
assert!(names.contains(&"MemoryReadDocument".to_string()));
|
||||
|
||||
@@ -14,26 +14,27 @@ use llm_engine::tool::{
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WorkspaceHttpObjectiveBackend {
|
||||
workspace_id: String,
|
||||
base_url: String,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
impl WorkspaceHttpObjectiveBackend {
|
||||
pub fn new(workspace_id: impl Into<String>, base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
workspace_id: workspace_id.into(),
|
||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||
}
|
||||
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
async fn list(&self, input: ObjectiveListInput) -> Result<ToolOutput, ToolError> {
|
||||
let mut url = format!("{}/api/w/{}/objectives", self.base_url, self.workspace_id);
|
||||
let mut url = format!(
|
||||
"/api/w/{}/objectives",
|
||||
self.client.workspace_id().unwrap_or_default()
|
||||
);
|
||||
if let Some(limit) = input.limit {
|
||||
url.push_str(&format!("?limit={}", limit.min(1000)));
|
||||
}
|
||||
let response = get_json::<ObjectiveListResponse>(&url)
|
||||
let response = get_json::<ObjectiveListResponse>(self.client.as_ref(), &url)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
let count = response.items.len();
|
||||
@@ -46,7 +47,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
async fn show(&self, input: ObjectiveShowInput) -> Result<ToolOutput, ToolError> {
|
||||
let id = validate_id(&input.id, "ObjectiveShow")?;
|
||||
let url = self.objective_url(id);
|
||||
let response = get_json::<ObjectiveDetail>(&url)
|
||||
let response = get_json::<ObjectiveDetail>(self.client.as_ref(), &url)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
@@ -61,11 +62,18 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
"ObjectiveCreate requires non-empty title".to_string(),
|
||||
));
|
||||
}
|
||||
let url = format!("{}/api/w/{}/objectives", self.base_url, self.workspace_id);
|
||||
let response =
|
||||
send_json::<ObjectiveCreateInput, ObjectiveDetail>(reqwest::Method::POST, &url, &input)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
let url = format!(
|
||||
"/api/w/{}/objectives",
|
||||
self.client.workspace_id().unwrap_or_default()
|
||||
);
|
||||
let response = send_json::<ObjectiveCreateInput, ObjectiveDetail>(
|
||||
self.client.as_ref(),
|
||||
reqwest::Method::POST,
|
||||
&url,
|
||||
&input,
|
||||
)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Created objective {}", response.id),
|
||||
response,
|
||||
@@ -86,10 +94,14 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
new_string: input.new_string,
|
||||
replace_all: input.replace_all,
|
||||
};
|
||||
let response =
|
||||
send_json::<ObjectiveEditRequest, ObjectiveDetail>(reqwest::Method::PATCH, &url, &body)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
let response = send_json::<ObjectiveEditRequest, ObjectiveDetail>(
|
||||
self.client.as_ref(),
|
||||
reqwest::Method::PATCH,
|
||||
&url,
|
||||
&body,
|
||||
)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
format!("Edited objective {}", response.id),
|
||||
response,
|
||||
@@ -105,6 +117,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
}
|
||||
let url = format!("{}/state", self.objective_url(id));
|
||||
let response = send_json::<ObjectiveSetStateRequest, ObjectiveDetail>(
|
||||
self.client.as_ref(),
|
||||
reqwest::Method::POST,
|
||||
&url,
|
||||
&ObjectiveSetStateRequest { state: input.state },
|
||||
@@ -122,6 +135,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
let ticket_id = validate_id(&input.ticket_id, "ObjectiveLinkTicket")?;
|
||||
let url = format!("{}/ticket-links", self.objective_url(id));
|
||||
let response = send_json::<ObjectiveLinkTicketRequest, ObjectiveDetail>(
|
||||
self.client.as_ref(),
|
||||
reqwest::Method::POST,
|
||||
&url,
|
||||
&ObjectiveLinkTicketRequest {
|
||||
@@ -143,7 +157,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
let id = validate_id(&input.id, "ObjectiveUnlinkTicket")?;
|
||||
let ticket_id = validate_id(&input.ticket_id, "ObjectiveUnlinkTicket")?;
|
||||
let url = format!("{}/ticket-links/{}", self.objective_url(id), ticket_id);
|
||||
let response = delete_json::<ObjectiveDetail>(&url)
|
||||
let response = delete_json::<ObjectiveDetail>(self.client.as_ref(), &url)
|
||||
.await
|
||||
.map_err(backend_error)?;
|
||||
Ok(objective_output(
|
||||
@@ -153,17 +167,15 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
}
|
||||
|
||||
fn objective_url(&self, id: &str) -> String {
|
||||
format!(
|
||||
"{}/api/w/{}/objectives/{}",
|
||||
self.base_url, self.workspace_id, id
|
||||
)
|
||||
let workspace_id = self.client.workspace_id().unwrap_or_default();
|
||||
format!("/api/w/{workspace_id}/objectives/{id}")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WorkspaceObjectiveBackendError {
|
||||
#[error("workspace objective backend request failed: {0}")]
|
||||
Request(#[from] reqwest::Error),
|
||||
Request(#[from] crate::worker::WorkspaceClientError),
|
||||
#[error("workspace objective backend returned HTTP {status}: {body}")]
|
||||
Http {
|
||||
status: reqwest::StatusCode,
|
||||
@@ -182,41 +194,55 @@ fn backend_error(error: WorkspaceObjectiveBackendError) -> ToolError {
|
||||
}
|
||||
|
||||
async fn get_json<T: for<'de> Deserialize<'de>>(
|
||||
url: &str,
|
||||
client: &dyn WorkspaceClient,
|
||||
path: &str,
|
||||
) -> Result<T, WorkspaceObjectiveBackendError> {
|
||||
let response = reqwest::Client::new().get(url).send().await?;
|
||||
decode_response(response).await
|
||||
decode_response(client.execute(WorkspaceRequest::get(path))?)
|
||||
}
|
||||
|
||||
async fn send_json<B: Serialize, T: for<'de> Deserialize<'de>>(
|
||||
client: &dyn WorkspaceClient,
|
||||
method: reqwest::Method,
|
||||
url: &str,
|
||||
path: &str,
|
||||
body: &B,
|
||||
) -> Result<T, WorkspaceObjectiveBackendError> {
|
||||
let response = reqwest::Client::new()
|
||||
.request(method, url)
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode_response(response).await
|
||||
let method = match method {
|
||||
reqwest::Method::POST => WorkspaceRequestMethod::Post,
|
||||
reqwest::Method::PUT => WorkspaceRequestMethod::Put,
|
||||
reqwest::Method::PATCH => WorkspaceRequestMethod::Patch,
|
||||
reqwest::Method::DELETE => WorkspaceRequestMethod::Delete,
|
||||
_ => WorkspaceRequestMethod::Get,
|
||||
};
|
||||
decode_response(client.execute(WorkspaceRequest::json(
|
||||
method,
|
||||
path,
|
||||
serde_json::to_string(body)?,
|
||||
))?)
|
||||
}
|
||||
|
||||
async fn delete_json<T: for<'de> Deserialize<'de>>(
|
||||
url: &str,
|
||||
client: &dyn WorkspaceClient,
|
||||
path: &str,
|
||||
) -> Result<T, WorkspaceObjectiveBackendError> {
|
||||
let response = reqwest::Client::new().delete(url).send().await?;
|
||||
decode_response(response).await
|
||||
decode_response(client.execute(WorkspaceRequest {
|
||||
method: WorkspaceRequestMethod::Delete,
|
||||
path: path.to_string(),
|
||||
body: None,
|
||||
})?)
|
||||
}
|
||||
|
||||
async fn decode_response<T: for<'de> Deserialize<'de>>(
|
||||
response: reqwest::Response,
|
||||
fn decode_response<T: for<'de> Deserialize<'de>>(
|
||||
response: crate::worker::WorkspaceResponse,
|
||||
) -> Result<T, WorkspaceObjectiveBackendError> {
|
||||
let status = response.status();
|
||||
let body = response.text().await?;
|
||||
if !status.is_success() {
|
||||
return Err(WorkspaceObjectiveBackendError::Http { status, body });
|
||||
let status = reqwest::StatusCode::from_u16(response.status)
|
||||
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
if !response.is_success() {
|
||||
return Err(WorkspaceObjectiveBackendError::Http {
|
||||
status,
|
||||
body: response.body,
|
||||
});
|
||||
}
|
||||
serde_json::from_str(&body).map_err(Into::into)
|
||||
serde_json::from_str(&response.body).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
|
||||
@@ -236,11 +262,8 @@ fn validate_id<'a>(id: &'a str, tool_name: &str) -> Result<&'a str, ToolError> {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn workspace_http_objective_tools(
|
||||
workspace_id: impl Into<String>,
|
||||
base_url: impl Into<String>,
|
||||
) -> Vec<ToolDefinition> {
|
||||
let backend = WorkspaceHttpObjectiveBackend::new(workspace_id, base_url);
|
||||
pub fn workspace_http_objective_tools(client: Arc<dyn WorkspaceClient>) -> Vec<ToolDefinition> {
|
||||
let backend = WorkspaceHttpObjectiveBackend::new(client);
|
||||
vec![
|
||||
objective_tool(
|
||||
"ObjectiveList",
|
||||
@@ -600,10 +623,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workspace_http_objective_tools_include_objective_crud_tools() {
|
||||
let names = tool_names(workspace_http_objective_tools(
|
||||
"workspace".to_string(),
|
||||
"http://backend".to_string(),
|
||||
));
|
||||
let names = tool_names(workspace_http_objective_tools(Arc::new(
|
||||
crate::worker::RuntimeWorkspaceHttpClient::new(
|
||||
"workspace",
|
||||
"http://backend",
|
||||
"test-runtime",
|
||||
"test-worker",
|
||||
),
|
||||
)));
|
||||
|
||||
assert_eq!(
|
||||
names,
|
||||
|
||||
@@ -29,7 +29,7 @@ const FINISH_EXTRACTION_DESCRIPTION: &str = "Finish the extract worker run after
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SessionExploreState {
|
||||
view: Arc<SessionReferenceView>,
|
||||
workspace_client: WorkspaceClient,
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
source: SourceRef,
|
||||
extract_run_id: String,
|
||||
staged: Arc<Mutex<Vec<String>>>,
|
||||
@@ -39,7 +39,7 @@ pub(crate) struct SessionExploreState {
|
||||
impl SessionExploreState {
|
||||
pub(crate) fn new(
|
||||
view: SessionReferenceView,
|
||||
workspace_client: WorkspaceClient,
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
source: SourceRef,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -615,7 +615,7 @@ mod tests {
|
||||
|
||||
fn stub_memory_backend_response(
|
||||
body: &'static str,
|
||||
) -> (WorkspaceClient, mpsc::Receiver<String>) {
|
||||
) -> (Arc<dyn WorkspaceClient>, mpsc::Receiver<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
@@ -659,7 +659,12 @@ mod tests {
|
||||
stream.write_all(response.as_bytes()).unwrap();
|
||||
});
|
||||
(
|
||||
WorkspaceClient::http("test-workspace", format!("http://{addr}")),
|
||||
Arc::new(crate::worker::RuntimeWorkspaceHttpClient::new(
|
||||
"test-workspace",
|
||||
format!("http://{addr}"),
|
||||
"test-runtime",
|
||||
"test-worker",
|
||||
)),
|
||||
rx,
|
||||
)
|
||||
}
|
||||
@@ -668,7 +673,7 @@ mod tests {
|
||||
fn descriptor_declares_session_explore_tools() {
|
||||
let state = SessionExploreState::new(
|
||||
SessionReferenceView::new("segment-1", vec![Item::user_message("remember this")]),
|
||||
WorkspaceClient::available("test-backend"),
|
||||
crate::worker::marker_workspace_client(None, "test-backend"),
|
||||
SourceRef {
|
||||
segment_id: "segment-1".to_string(),
|
||||
range: [0, 0],
|
||||
|
||||
@@ -4,15 +4,18 @@
|
||||
//! module only resolves the local backend root, declares the built-in feature,
|
||||
//! and contributes those tools through the normal feature registry path.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use ticket::{
|
||||
LocalTicketBackend, MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent,
|
||||
NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult,
|
||||
Ticket, TicketBackend, TicketBackendHttpResponse, TicketBackendOperation,
|
||||
TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketIdOrSlug,
|
||||
TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation, TicketRelationKind,
|
||||
TicketRelationView, TicketReview, TicketStateChange, TicketSummary,
|
||||
Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult,
|
||||
TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery,
|
||||
TicketRef, TicketRelation, TicketRelationKind, TicketRelationView, TicketReview,
|
||||
TicketStateChange, TicketSummary,
|
||||
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
|
||||
tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools},
|
||||
};
|
||||
@@ -22,6 +25,7 @@ use crate::feature::{
|
||||
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
|
||||
FeatureModule, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
|
||||
|
||||
const FEATURE_ID: &str = "ticket";
|
||||
const FEATURE_NAME: &str = "Ticket tools";
|
||||
@@ -183,13 +187,8 @@ const ORCHESTRATION_CONTROL_ADDITIONAL_TOOL_NAMES: &[&str] = &[
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum TicketFeatureBackend {
|
||||
Local {
|
||||
root: PathBuf,
|
||||
},
|
||||
WorkspaceHttp {
|
||||
workspace_id: String,
|
||||
base_url: String,
|
||||
},
|
||||
Local { root: PathBuf },
|
||||
WorkspaceClient(Arc<dyn WorkspaceClient>),
|
||||
}
|
||||
|
||||
impl From<PathBuf> for TicketFeatureBackend {
|
||||
@@ -274,7 +273,7 @@ impl TicketFeature {
|
||||
pub fn backend_root(&self) -> Option<&Path> {
|
||||
match &self.backend {
|
||||
TicketFeatureBackend::Local { root } => Some(root),
|
||||
TicketFeatureBackend::WorkspaceHttp { .. } => None,
|
||||
TicketFeatureBackend::WorkspaceClient(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,15 +320,9 @@ impl TicketFeature {
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
TicketFeatureBackend::WorkspaceHttp {
|
||||
workspace_id,
|
||||
base_url,
|
||||
} => Some(
|
||||
TicketToolBackend::new(WorkspaceHttpTicketBackend::new(
|
||||
workspace_id.clone(),
|
||||
base_url.clone(),
|
||||
))
|
||||
.with_record_language(self.record_language.as_deref()),
|
||||
TicketFeatureBackend::WorkspaceClient(client) => Some(
|
||||
TicketToolBackend::new(WorkspaceHttpTicketBackend::new(client.clone()))
|
||||
.with_record_language(self.record_language.as_deref()),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -386,69 +379,318 @@ impl FeatureModule for TicketFeature {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct WorkspaceHttpTicketBackend {
|
||||
workspace_id: String,
|
||||
base_url: String,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
impl WorkspaceHttpTicketBackend {
|
||||
fn new(workspace_id: String, base_url: String) -> Self {
|
||||
Self {
|
||||
workspace_id,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn endpoint(&self) -> String {
|
||||
format!(
|
||||
"{}/api/w/{}/tickets/backend",
|
||||
self.base_url, self.workspace_id
|
||||
)
|
||||
fn new(client: Arc<dyn WorkspaceClient>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
fn invoke(
|
||||
&self,
|
||||
operation: TicketBackendOperation,
|
||||
) -> TicketResult<TicketBackendOperationResult> {
|
||||
let endpoint = self.endpoint();
|
||||
let client = self.client.clone();
|
||||
let workspace_id = self.client.workspace_id().unwrap_or_default().to_string();
|
||||
if tokio::runtime::Handle::try_current().is_ok() {
|
||||
return std::thread::spawn(move || Self::invoke_http(endpoint, operation))
|
||||
.join()
|
||||
.map_err(|_| {
|
||||
TicketError::Conflict("ticket backend request thread panicked".to_string())
|
||||
})?;
|
||||
return std::thread::spawn(move || {
|
||||
Self::invoke_client(client, workspace_id, operation)
|
||||
})
|
||||
.join()
|
||||
.map_err(|_| {
|
||||
TicketError::Conflict("ticket REST request thread panicked".to_string())
|
||||
})?;
|
||||
}
|
||||
Self::invoke_http(endpoint, operation)
|
||||
Self::invoke_client(client, workspace_id, operation)
|
||||
}
|
||||
|
||||
fn invoke_http(
|
||||
fn ticket_path(id: &TicketIdOrSlug) -> String {
|
||||
let value = match id {
|
||||
TicketIdOrSlug::Id(value)
|
||||
| TicketIdOrSlug::Slug(value)
|
||||
| TicketIdOrSlug::Query(value) => value,
|
||||
};
|
||||
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(byte as char);
|
||||
} else {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(encoded, "%{byte:02X}");
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn request<T: serde::de::DeserializeOwned>(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
method: WorkspaceRequestMethod,
|
||||
endpoint: String,
|
||||
operation: TicketBackendOperation,
|
||||
) -> TicketResult<TicketBackendOperationResult> {
|
||||
let body = serde_json::to_string(&operation).map_err(|error| {
|
||||
TicketError::Conflict(format!("serialize ticket operation: {error}"))
|
||||
body: Option<serde_json::Value>,
|
||||
) -> TicketResult<T> {
|
||||
let request = match body {
|
||||
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
|
||||
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
|
||||
None => WorkspaceRequest {
|
||||
method,
|
||||
path: endpoint,
|
||||
body: None,
|
||||
},
|
||||
};
|
||||
let response = client.execute(request).map_err(|error| {
|
||||
TicketError::Conflict(format!("ticket REST request failed: {error}"))
|
||||
})?;
|
||||
let response = reqwest::blocking::Client::new()
|
||||
.post(endpoint)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
.map_err(|error| {
|
||||
TicketError::Conflict(format!("ticket backend request failed: {error}"))
|
||||
})?;
|
||||
let status = response.status();
|
||||
let text = response.text().map_err(|error| {
|
||||
TicketError::Conflict(format!("ticket backend response failed: {error}"))
|
||||
})?;
|
||||
if !status.is_success() {
|
||||
if !response.is_success() {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"ticket backend returned HTTP {status}: {text}"
|
||||
"ticket REST API returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
match serde_json::from_str::<TicketBackendHttpResponse>(&text).map_err(|error| {
|
||||
TicketError::Conflict(format!("decode ticket backend response: {error}"))
|
||||
})? {
|
||||
TicketBackendHttpResponse::Ok { result } => Ok(result),
|
||||
TicketBackendHttpResponse::Error { message } => Err(TicketError::Conflict(message)),
|
||||
serde_json::from_str(&response.body)
|
||||
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
||||
}
|
||||
|
||||
fn request_unit(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
method: WorkspaceRequestMethod,
|
||||
endpoint: String,
|
||||
body: Option<serde_json::Value>,
|
||||
) -> TicketResult<TicketBackendOperationResult> {
|
||||
let request = match body {
|
||||
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
|
||||
None => WorkspaceRequest {
|
||||
method,
|
||||
path: endpoint,
|
||||
body: None,
|
||||
},
|
||||
};
|
||||
let response = client.execute(request).map_err(|error| {
|
||||
TicketError::Conflict(format!("ticket REST request failed: {error}"))
|
||||
})?;
|
||||
if !response.is_success() {
|
||||
return Err(TicketError::Conflict(format!(
|
||||
"ticket REST API returned HTTP {}: {}",
|
||||
response.status, response.body
|
||||
)));
|
||||
}
|
||||
Ok(TicketBackendOperationResult::Unit)
|
||||
}
|
||||
|
||||
fn invoke_client(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
workspace_id: String,
|
||||
operation: TicketBackendOperation,
|
||||
) -> TicketResult<TicketBackendOperationResult> {
|
||||
let base = format!("/api/w/{workspace_id}/tickets");
|
||||
match operation {
|
||||
TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from } => {
|
||||
let value = Self::request::<String>(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/default-intake-ready-body"),
|
||||
Some(serde_json::json!({ "from": from })),
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::Text(value))
|
||||
}
|
||||
TicketBackendOperation::List { filter } => {
|
||||
let state = match filter.state {
|
||||
ticket::TicketStateSelector::Active => "active".to_string(),
|
||||
ticket::TicketStateSelector::All => "all".to_string(),
|
||||
ticket::TicketStateSelector::States(states) => states
|
||||
.into_iter()
|
||||
.map(|state| state.as_str().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
};
|
||||
let tickets = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!("{base}/search?state={state}"),
|
||||
None,
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::Tickets(tickets))
|
||||
}
|
||||
TicketBackendOperation::Show { id } => {
|
||||
let ticket = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!("{base}/{}/record", Self::ticket_path(&id)),
|
||||
None,
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::Ticket(ticket))
|
||||
}
|
||||
TicketBackendOperation::Create { input } => {
|
||||
let ticket = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
base,
|
||||
Some(serde_json::to_value(input).map_err(|error| {
|
||||
TicketError::Conflict(format!("serialize Ticket create: {error}"))
|
||||
})?),
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::TicketRef(ticket))
|
||||
}
|
||||
TicketBackendOperation::EditItem { id, edit } => {
|
||||
let ticket = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Patch,
|
||||
format!("{base}/{}/item", Self::ticket_path(&id)),
|
||||
Some(serde_json::to_value(edit).map_err(|error| {
|
||||
TicketError::Conflict(format!("serialize Ticket edit: {error}"))
|
||||
})?),
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::Ticket(ticket))
|
||||
}
|
||||
TicketBackendOperation::DependencyCheck { id } => {
|
||||
let check = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!("{base}/{}/dependency-check", Self::ticket_path(&id)),
|
||||
None,
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::DependencyCheck(check))
|
||||
}
|
||||
TicketBackendOperation::AddEvent { id, event } => Self::request_unit(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/thread-events", Self::ticket_path(&id)),
|
||||
Some(serde_json::to_value(event).map_err(|error| {
|
||||
TicketError::Conflict(format!("serialize Ticket event: {error}"))
|
||||
})?),
|
||||
),
|
||||
TicketBackendOperation::AddStateChanged { id, change } => Self::request_unit(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/state-changes", Self::ticket_path(&id)),
|
||||
Some(serde_json::to_value(change).map_err(|error| {
|
||||
TicketError::Conflict(format!("serialize Ticket state change: {error}"))
|
||||
})?),
|
||||
),
|
||||
TicketBackendOperation::AddIntakeSummary { id, summary } => Self::request_unit(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/intake-summaries", Self::ticket_path(&id)),
|
||||
Some(serde_json::to_value(summary).map_err(|error| {
|
||||
TicketError::Conflict(format!("serialize Ticket intake summary: {error}"))
|
||||
})?),
|
||||
),
|
||||
TicketBackendOperation::SetStateField { id, field, change } => Self::request_unit(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!(
|
||||
"{base}/{}/state-fields/{}",
|
||||
Self::ticket_path(&id),
|
||||
Self::ticket_path(&TicketIdOrSlug::Query(field))
|
||||
),
|
||||
Some(serde_json::to_value(change).map_err(|error| {
|
||||
TicketError::Conflict(format!("serialize Ticket state field change: {error}"))
|
||||
})?),
|
||||
),
|
||||
TicketBackendOperation::SetWorkflowState { id, change } => Self::request_unit(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/workflow-state", Self::ticket_path(&id)),
|
||||
Some(serde_json::to_value(change).map_err(|error| {
|
||||
TicketError::Conflict(format!("serialize Ticket workflow change: {error}"))
|
||||
})?),
|
||||
),
|
||||
TicketBackendOperation::MarkIntakeReady {
|
||||
id,
|
||||
summary,
|
||||
change,
|
||||
} => Self::request_unit(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/intake-ready", Self::ticket_path(&id)),
|
||||
Some(serde_json::json!({ "summary": summary, "change": change })),
|
||||
),
|
||||
TicketBackendOperation::QueueReady { id, .. } => Self::request_unit(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/workflow/queue", Self::ticket_path(&id)),
|
||||
None,
|
||||
),
|
||||
TicketBackendOperation::Review { id, review } => Self::request_unit(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/workflow/review", Self::ticket_path(&id)),
|
||||
Some(serde_json::to_value(review).map_err(|error| {
|
||||
TicketError::Conflict(format!("serialize Ticket review: {error}"))
|
||||
})?),
|
||||
),
|
||||
TicketBackendOperation::Close { id, resolution } => Self::request_unit(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/workflow/close", Self::ticket_path(&id)),
|
||||
Some(serde_json::to_value(resolution).map_err(|error| {
|
||||
TicketError::Conflict(format!("serialize Ticket close: {error}"))
|
||||
})?),
|
||||
),
|
||||
TicketBackendOperation::AddTicketRelation { id, relation } => {
|
||||
let relation = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/relations", Self::ticket_path(&id)),
|
||||
Some(serde_json::to_value(relation).map_err(|error| {
|
||||
TicketError::Conflict(format!("serialize Ticket relation: {error}"))
|
||||
})?),
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::Relation(relation))
|
||||
}
|
||||
TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
|
||||
let relations = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/relations/search"),
|
||||
Some(serde_json::json!({ "ticket": ticket, "kind": kind })),
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::Relations(relations))
|
||||
}
|
||||
TicketBackendOperation::RelationView { id } => {
|
||||
let view = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!("{base}/{}/relation-view", Self::ticket_path(&id)),
|
||||
None,
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::RelationView(view))
|
||||
}
|
||||
TicketBackendOperation::AddOrchestrationPlanRecord { id, record } => {
|
||||
let record = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/{}/orchestration-plans", Self::ticket_path(&id)),
|
||||
Some(serde_json::to_value(record).map_err(|error| {
|
||||
TicketError::Conflict(format!(
|
||||
"serialize Ticket orchestration plan: {error}"
|
||||
))
|
||||
})?),
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::OrchestrationPlanRecord(
|
||||
record,
|
||||
))
|
||||
}
|
||||
TicketBackendOperation::QueryOrchestrationPlanRecords { ticket, kind } => {
|
||||
let records = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Post,
|
||||
format!("{base}/orchestration-plans/search"),
|
||||
Some(serde_json::json!({ "ticket": ticket, "kind": kind })),
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::OrchestrationPlanRecords(
|
||||
records,
|
||||
))
|
||||
}
|
||||
TicketBackendOperation::Doctor => {
|
||||
let report = Self::request(
|
||||
client,
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!("{base}/doctor"),
|
||||
None,
|
||||
)?;
|
||||
Ok(TicketBackendOperationResult::DoctorReport(report))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1126,8 +1368,14 @@ provider = "github"
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn workspace_http_backend_invoke_is_safe_inside_async_context() {
|
||||
let backend =
|
||||
WorkspaceHttpTicketBackend::new("workspace-a".to_string(), "not-a-url".to_string());
|
||||
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
|
||||
crate::worker::RuntimeWorkspaceHttpClient::new(
|
||||
"workspace-a",
|
||||
"not-a-url",
|
||||
"test-runtime",
|
||||
"test-worker",
|
||||
),
|
||||
));
|
||||
|
||||
let error = backend
|
||||
.invoke(TicketBackendOperation::DefaultIntakeReadyStateChangeBody {
|
||||
@@ -1135,7 +1383,43 @@ provider = "github"
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("ticket backend request failed"));
|
||||
assert!(error.to_string().contains("ticket REST request failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_http_backend_posts_ticket_event_subresource() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let size = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..size]);
|
||||
assert!(
|
||||
request
|
||||
.starts_with("POST /api/w/workspace-a/tickets/01TEST/thread-events HTTP/1.1")
|
||||
);
|
||||
assert!(!request.contains("\"operation\""));
|
||||
assert!(request.contains("\"kind\":\"comment\""));
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
|
||||
.unwrap();
|
||||
});
|
||||
let client = Arc::new(crate::worker::RuntimeWorkspaceHttpClient::new(
|
||||
"workspace-a",
|
||||
format!("http://{address}"),
|
||||
"test-runtime",
|
||||
"worker-a",
|
||||
));
|
||||
let backend = WorkspaceHttpTicketBackend::new(client);
|
||||
|
||||
backend
|
||||
.add_event(
|
||||
TicketIdOrSlug::Id("01TEST".to_string()),
|
||||
NewTicketEvent::new(ticket::TicketEventKind::Comment, "REST comment"),
|
||||
)
|
||||
.unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1147,15 +1431,13 @@ provider = "github"
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(request.starts_with("POST /api/w/workspace-a/tickets/backend HTTP/1.1"));
|
||||
assert!(request.contains("\"operation\":\"create\""));
|
||||
assert!(request.starts_with("POST /api/w/workspace-a/tickets HTTP/1.1"));
|
||||
assert!(!request.contains("\"operation\""));
|
||||
assert!(request.contains("\"title\":\"HTTP ticket\""));
|
||||
let response_body = serde_json::to_string(&TicketBackendHttpResponse::Ok {
|
||||
result: TicketBackendOperationResult::TicketRef(TicketRef {
|
||||
id: "01TEST".to_string(),
|
||||
slug: "http-ticket".to_string(),
|
||||
status: ticket::TicketStatus::Open,
|
||||
}),
|
||||
let response_body = serde_json::to_string(&TicketRef {
|
||||
id: "01TEST".to_string(),
|
||||
slug: "http-ticket".to_string(),
|
||||
status: ticket::TicketStatus::Open,
|
||||
})
|
||||
.unwrap();
|
||||
write!(
|
||||
@@ -1167,7 +1449,14 @@ provider = "github"
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let backend = WorkspaceHttpTicketBackend::new("workspace-a".to_string(), base_url);
|
||||
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
|
||||
crate::worker::RuntimeWorkspaceHttpClient::new(
|
||||
"workspace-a",
|
||||
base_url,
|
||||
"test-runtime",
|
||||
"test-worker",
|
||||
),
|
||||
));
|
||||
let created = backend.create(NewTicket::new("HTTP ticket")).unwrap();
|
||||
|
||||
server.join().unwrap();
|
||||
|
||||
+223
-304
@@ -1,6 +1,6 @@
|
||||
//! Worker 視点のファイルシステム操作。
|
||||
//!
|
||||
//! `ScopedFs` の上に「Worker が読み取りたい / 列挙したい」操作を集約する軽い wrapper。
|
||||
//! `WorkdirSession` の上に「Worker が読み取りたい / 列挙したい」操作を集約する軽い wrapper。
|
||||
//!
|
||||
//! - `ReadRequirement` と `render_auto_read` — compact worker が `mark_read_required`
|
||||
//! で nominate したファイルを再読し、`[Auto-read file: ...]` system message に
|
||||
@@ -13,16 +13,21 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use llm_engine::Item;
|
||||
use manifest::Scope;
|
||||
use tools::scoped_fs::first_symlink;
|
||||
use tools::{ScopedFs, ToolsError};
|
||||
use tools::ToolsError;
|
||||
use tracing::warn;
|
||||
#[cfg(test)]
|
||||
use workdir::LocalWorkdirSession;
|
||||
use workdir::{
|
||||
EntryKind, ListRequest, ReadRequest, StatRequest, WorkdirPath, WorkdirSessionHandle,
|
||||
};
|
||||
|
||||
/// 補完候補1件の最大数。`list_file_completions` がこの値を超えたら打ち切り。
|
||||
const COMPLETION_LIMIT: usize = 100;
|
||||
/// submit-time directory FileRef の shallow listing で返す最大 entry 数。
|
||||
/// TUI completion と同じ浅い一覧という意味論に揃えるため、同じ上限を使う。
|
||||
const DIR_FILE_REF_ENTRY_LIMIT: usize = COMPLETION_LIMIT;
|
||||
/// Provider-side bound for auto-read and submit-time referenced-file reads.
|
||||
const AUTO_READ_BYTE_LIMIT: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// Compact worker が `mark_read_required` で nominate した「次セッション開始時に
|
||||
/// 自動で再読すべきファイル」のエントリ。
|
||||
@@ -35,10 +40,10 @@ pub struct ReadRequirement {
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Worker から見えるファイルシステム操作の入口。Clone は cheap(`ScopedFs` 内 `Arc`)。
|
||||
/// Worker から見えるファイルシステム操作の入口。Clone は cheap(`WorkdirSession` 内 `Arc`)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkerFsView {
|
||||
fs: ScopedFs,
|
||||
session: WorkdirSessionHandle,
|
||||
}
|
||||
|
||||
/// `list_file_completions` が返す候補1件。
|
||||
@@ -51,10 +56,10 @@ pub struct FileCandidate {
|
||||
}
|
||||
|
||||
/// `resolve_file_ref` の失敗理由。Worker 側で Alert に振り分けるために
|
||||
/// ScopedFs / 内部判定の両方を区別できるよう保持する。
|
||||
/// WorkdirSession / 内部判定の両方を区別できるよう保持する。
|
||||
#[derive(Debug)]
|
||||
pub enum ResolveError {
|
||||
/// Path resolution / scope check failed via `ScopedFs`.
|
||||
/// Path resolution / scope check failed via `WorkdirSession`.
|
||||
Fs(ToolsError),
|
||||
/// File contents are not valid UTF-8 (binary / non-text).
|
||||
Binary { path: PathBuf },
|
||||
@@ -74,142 +79,172 @@ impl std::fmt::Display for ResolveError {
|
||||
impl std::error::Error for ResolveError {}
|
||||
|
||||
impl WorkerFsView {
|
||||
pub fn new(fs: ScopedFs) -> Self {
|
||||
Self { fs }
|
||||
pub fn new(session: WorkdirSessionHandle) -> Self {
|
||||
Self { session }
|
||||
}
|
||||
pub fn session(&self) -> &WorkdirSessionHandle {
|
||||
&self.session
|
||||
}
|
||||
|
||||
pub fn fs(&self) -> &ScopedFs {
|
||||
&self.fs
|
||||
}
|
||||
|
||||
/// `requirements` の各エントリを `ScopedFs` 経由で再読し、
|
||||
/// `[Auto-read file: <path>:<range>]\n<body>` 形式の system message に変換する。
|
||||
/// 読み取り失敗(NotFound / OutOfScope 等)は warn で記録してスキップする
|
||||
/// — compact 全体を落とさないため。
|
||||
pub fn render_auto_read(&self, requirements: &[ReadRequirement]) -> Vec<Item> {
|
||||
pub async fn render_auto_read(&self, requirements: &[ReadRequirement]) -> Vec<Item> {
|
||||
let mut out = Vec::with_capacity(requirements.len());
|
||||
for req in requirements {
|
||||
match self.fs.read_bytes(&req.path) {
|
||||
Ok(bytes) => {
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let body = slice_lines(&text, req.offset.unwrap_or(0), req.limit);
|
||||
let path = match WorkdirPath::new(req.path.to_string_lossy()) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
warn!(path = %req.path.display(), %error, "invalid auto-read path");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match self
|
||||
.session
|
||||
.read(ReadRequest {
|
||||
path: path.clone(),
|
||||
offset: req.offset.unwrap_or(0),
|
||||
limit: req.limit.unwrap_or(usize::MAX),
|
||||
max_bytes: AUTO_READ_BYTE_LIMIT,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
let body = String::from_utf8_lossy(&result.bytes);
|
||||
let range = format_range(req.offset, req.limit);
|
||||
out.push(Item::system_message(format!(
|
||||
"[Auto-read file: {}{range}]\n{body}",
|
||||
req.path.display()
|
||||
"[Auto-read file: {path}{range}]\n{body}"
|
||||
)));
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
path = %req.path.display(),
|
||||
error = %e,
|
||||
"auto-read target could not be read; skipping",
|
||||
);
|
||||
Err(error) => {
|
||||
warn!(path = %path, %error, "auto-read target could not be read; skipping")
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `path` を ScopedFs 経由で解決し、submit 時の `Segment::FileRef`
|
||||
/// attachment 用 system message を返す。
|
||||
///
|
||||
/// - `path` は relative なら cwd 相対、absolute なら absolute として解釈
|
||||
/// - 通常ディレクトリは浅い entry listing として `[Dir: <path>]\n<body>` に展開する
|
||||
/// - ディレクトリ listing は hidden / gitignore を特別扱いせず、scope 上 readable な
|
||||
/// 直下 entry だけを最大 `DIR_FILE_REF_ENTRY_LIMIT` 件返す
|
||||
/// - ファイル本文またはディレクトリ listing 本文が `max_bytes` を超える場合は切り詰める
|
||||
/// - 非 UTF-8 (バイナリ) は `ResolveError::Binary` で拒否
|
||||
/// - スコープ外 / NotFound / symlink directory 等は `ResolveError::Fs` で返す
|
||||
pub fn resolve_file_ref(&self, path: &str, max_bytes: usize) -> Result<Item, ResolveError> {
|
||||
let p = Path::new(path);
|
||||
let abs = if p.is_absolute() {
|
||||
p.to_path_buf()
|
||||
} else {
|
||||
self.fs.cwd().join(p)
|
||||
};
|
||||
|
||||
// 通常ディレクトリだけを FileRef listing として扱う。symlink を含むパスは
|
||||
// `ScopedFs::read_bytes` に委ね、既存の symlink 診断
|
||||
// (`SymlinkTargetIsDirectory` / `SymlinkOutOfScope` 等) を保つ。
|
||||
if first_symlink(&abs).is_none() {
|
||||
let scope = self.fs.scope();
|
||||
if !scope.is_readable(&abs) {
|
||||
return Err(ResolveError::Fs(ToolsError::OutOfScope(abs)));
|
||||
}
|
||||
let meta = metadata_for_file_ref(&abs).map_err(ResolveError::Fs)?;
|
||||
if meta.is_dir() {
|
||||
return render_dir_file_ref(path, &abs, max_bytes, scope.as_ref());
|
||||
pub async fn resolve_file_ref(
|
||||
&self,
|
||||
path: &str,
|
||||
max_bytes: usize,
|
||||
) -> Result<Item, ResolveError> {
|
||||
let logical = WorkdirPath::new(path)
|
||||
.map_err(ToolsError::from)
|
||||
.map_err(ResolveError::Fs)?;
|
||||
let stat = self
|
||||
.session
|
||||
.stat(StatRequest {
|
||||
path: logical.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)
|
||||
.map_err(ResolveError::Fs)?;
|
||||
if stat.kind == EntryKind::Directory {
|
||||
let result = self
|
||||
.session
|
||||
.list(ListRequest {
|
||||
path: logical.clone(),
|
||||
limit: DIR_FILE_REF_ENTRY_LIMIT,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)
|
||||
.map_err(ResolveError::Fs)?;
|
||||
let listing = result
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| match entry.kind {
|
||||
EntryKind::Directory => format!("{}/", entry.path),
|
||||
EntryKind::Symlink => format!("{}@", entry.path),
|
||||
_ => entry.path.to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let suffix = format!(
|
||||
"\n[{} readable entries total, {} bytes total]{}",
|
||||
result.total_entries,
|
||||
result.total_bytes,
|
||||
if result.truncated {
|
||||
"\n[...listing truncated; use Glob for more]"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
let header = format!("[Dir: {logical}]\n");
|
||||
let listing_budget = max_bytes.saturating_sub(header.len() + suffix.len());
|
||||
let (bounded_listing, truncated) = truncate_utf8_bytes(&listing, listing_budget);
|
||||
let mut text = format!("{header}{bounded_listing}{suffix}");
|
||||
if truncated {
|
||||
text.push_str("\n[...directory attachment truncated; use Glob or Read for more]");
|
||||
}
|
||||
return Ok(Item::system_message(text));
|
||||
}
|
||||
|
||||
let bytes = self.fs.read_bytes(&abs).map_err(ResolveError::Fs)?;
|
||||
let total = bytes.len();
|
||||
let (body_bytes, truncated) = if total > max_bytes {
|
||||
(&bytes[..max_bytes], true)
|
||||
} else {
|
||||
(bytes.as_slice(), false)
|
||||
};
|
||||
let body = std::str::from_utf8(body_bytes)
|
||||
.map_err(|_| ResolveError::Binary { path: abs.clone() })?;
|
||||
let mut text = format!("[File: {path}]\n{body}");
|
||||
if truncated {
|
||||
let result = self
|
||||
.session
|
||||
.read(ReadRequest {
|
||||
path: logical.clone(),
|
||||
offset: 0,
|
||||
limit: usize::MAX,
|
||||
max_bytes,
|
||||
})
|
||||
.await
|
||||
.map_err(ToolsError::from)
|
||||
.map_err(ResolveError::Fs)?;
|
||||
let total = stat.size;
|
||||
let end = result.bytes.len().min(max_bytes);
|
||||
let body = std::str::from_utf8(&result.bytes[..end]).map_err(|_| ResolveError::Binary {
|
||||
path: PathBuf::from(logical.as_str()),
|
||||
})?;
|
||||
let mut text = format!("[File: {logical}]\n{body}");
|
||||
if end < result.bytes.len() || result.truncated {
|
||||
text.push_str(&format!(
|
||||
"\n[...truncated, {total} bytes total — use read_file for the rest]"
|
||||
"\n[...truncated, {total} bytes total — use Read for the rest]"
|
||||
));
|
||||
}
|
||||
Ok(Item::system_message(text))
|
||||
}
|
||||
|
||||
/// `prefix` にマッチするファイル / ディレクトリを scope 内で浅く列挙する。
|
||||
///
|
||||
/// - `prefix` が空 or `cwd` 相対のときは cwd 直下を見る
|
||||
/// - `prefix` が末尾 `/` のときはそのディレクトリ直下を全列挙
|
||||
/// - 末尾が名前部分のときは、その名前を starts_with でフィルタ
|
||||
/// - scope 上 readable なエントリのみ返す
|
||||
/// - ディレクトリ → ファイル の順、各グループ内は名前昇順
|
||||
/// - 上限 `COMPLETION_LIMIT` 件で打ち切り(深い列挙はしない)
|
||||
pub fn list_file_completions(&self, prefix: &str) -> Vec<FileCandidate> {
|
||||
let cwd = self.fs.cwd();
|
||||
let scope = self.fs.scope();
|
||||
let (dir, name_prefix, is_absolute) = split_prefix(prefix, cwd);
|
||||
|
||||
let read_dir = match std::fs::read_dir(&dir) {
|
||||
Ok(rd) => rd,
|
||||
Err(_) => return Vec::new(),
|
||||
pub async fn list_file_completions(&self, prefix: &str) -> Vec<FileCandidate> {
|
||||
let prefix_path = Path::new(prefix);
|
||||
let (parent, needle) = if prefix.ends_with('/') {
|
||||
(prefix_path, String::new())
|
||||
} else {
|
||||
(
|
||||
prefix_path.parent().unwrap_or_else(|| Path::new("")),
|
||||
prefix_path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
for entry in read_dir.flatten() {
|
||||
let file_name = entry.file_name();
|
||||
let name = file_name.to_string_lossy();
|
||||
if !name.starts_with(&name_prefix) {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if !scope.is_readable(&path) {
|
||||
continue;
|
||||
}
|
||||
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||
let display = if is_absolute {
|
||||
path.display().to_string()
|
||||
} else {
|
||||
path.strip_prefix(cwd)
|
||||
.map(|p| p.display().to_string())
|
||||
.unwrap_or_else(|_| path.display().to_string())
|
||||
};
|
||||
out.push(FileCandidate {
|
||||
path: display,
|
||||
is_dir,
|
||||
});
|
||||
}
|
||||
|
||||
let Ok(parent) = WorkdirPath::new(parent.to_string_lossy()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(result) = self
|
||||
.session
|
||||
.list(ListRequest {
|
||||
path: parent,
|
||||
limit: COMPLETION_LIMIT,
|
||||
})
|
||||
.await
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = result
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let name = Path::new(entry.path.as_str())
|
||||
.file_name()?
|
||||
.to_string_lossy();
|
||||
name.starts_with(&needle).then_some(FileCandidate {
|
||||
path: entry.path.to_string(),
|
||||
is_dir: entry.kind == EntryKind::Directory,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
out.sort_by(|a, b| match (a.is_dir, b.is_dir) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.path.cmp(&b.path),
|
||||
});
|
||||
out.truncate(COMPLETION_LIMIT);
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -224,85 +259,6 @@ pub fn slice_lines(text: &str, offset: usize, limit: Option<usize>) -> String {
|
||||
lines[start..end].join("\n")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct DirListingEntry {
|
||||
display: String,
|
||||
kind_rank: u8,
|
||||
}
|
||||
|
||||
fn metadata_for_file_ref(path: &Path) -> Result<std::fs::Metadata, ToolsError> {
|
||||
std::fs::metadata(path).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => ToolsError::NotFound(path.to_path_buf()),
|
||||
_ => ToolsError::io(path, e),
|
||||
})
|
||||
}
|
||||
|
||||
fn render_dir_file_ref(
|
||||
original_path: &str,
|
||||
abs: &Path,
|
||||
max_bytes: usize,
|
||||
scope: &Scope,
|
||||
) -> Result<Item, ResolveError> {
|
||||
let read_dir = std::fs::read_dir(abs).map_err(|e| ResolveError::Fs(ToolsError::io(abs, e)))?;
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for entry in read_dir {
|
||||
let entry = entry.map_err(|e| ResolveError::Fs(ToolsError::io(abs, e)))?;
|
||||
let path = entry.path();
|
||||
if !scope.is_readable(&path) {
|
||||
continue;
|
||||
}
|
||||
let file_type = match entry.file_type() {
|
||||
Ok(ft) => ft,
|
||||
Err(e) => return Err(ResolveError::Fs(ToolsError::io(&path, e))),
|
||||
};
|
||||
let mut display = entry.file_name().to_string_lossy().into_owned();
|
||||
let kind_rank = if file_type.is_dir() {
|
||||
display.push('/');
|
||||
0
|
||||
} else if file_type.is_symlink() {
|
||||
display.push('@');
|
||||
1
|
||||
} else {
|
||||
2
|
||||
};
|
||||
entries.push(DirListingEntry { display, kind_rank });
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
a.kind_rank
|
||||
.cmp(&b.kind_rank)
|
||||
.then_with(|| a.display.cmp(&b.display))
|
||||
});
|
||||
|
||||
let total_entries = entries.len();
|
||||
let entry_truncated = total_entries > DIR_FILE_REF_ENTRY_LIMIT;
|
||||
let body = if total_entries == 0 {
|
||||
"(empty directory)".to_string()
|
||||
} else {
|
||||
entries
|
||||
.iter()
|
||||
.take(DIR_FILE_REF_ENTRY_LIMIT)
|
||||
.map(|e| e.display.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
let body_total_bytes = body.len();
|
||||
let (body, byte_truncated) = truncate_utf8_bytes(&body, max_bytes);
|
||||
|
||||
let mut text = format!("[Dir: {original_path}]\n{body}");
|
||||
if entry_truncated || byte_truncated {
|
||||
text.push('\n');
|
||||
text.push_str(&dir_listing_truncation_hint(
|
||||
entry_truncated,
|
||||
byte_truncated,
|
||||
total_entries,
|
||||
body_total_bytes,
|
||||
));
|
||||
}
|
||||
Ok(Item::system_message(text))
|
||||
}
|
||||
|
||||
fn truncate_utf8_bytes(s: &str, max_bytes: usize) -> (&str, bool) {
|
||||
if s.len() <= max_bytes {
|
||||
return (s, false);
|
||||
@@ -314,26 +270,6 @@ fn truncate_utf8_bytes(s: &str, max_bytes: usize) -> (&str, bool) {
|
||||
(&s[..end], true)
|
||||
}
|
||||
|
||||
fn dir_listing_truncation_hint(
|
||||
entry_truncated: bool,
|
||||
byte_truncated: bool,
|
||||
total_entries: usize,
|
||||
body_total_bytes: usize,
|
||||
) -> String {
|
||||
match (entry_truncated, byte_truncated) {
|
||||
(true, true) => format!(
|
||||
"[...truncated, {total_entries} readable entries total; first {DIR_FILE_REF_ENTRY_LIMIT} entries were {body_total_bytes} bytes before byte cap — use Glob for more]"
|
||||
),
|
||||
(true, false) => {
|
||||
format!("[...truncated, {total_entries} readable entries total — use Glob for more]")
|
||||
}
|
||||
(false, true) => {
|
||||
format!("[...truncated, {body_total_bytes} bytes total — use Glob or Read for more]")
|
||||
}
|
||||
(false, false) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_range(offset: Option<usize>, limit: Option<usize>) -> String {
|
||||
match (offset, limit) {
|
||||
(None, None) => String::new(),
|
||||
@@ -343,41 +279,19 @@ fn format_range(offset: Option<usize>, limit: Option<usize>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn split_prefix(prefix: &str, cwd: &Path) -> (PathBuf, String, bool) {
|
||||
let is_absolute = Path::new(prefix).is_absolute();
|
||||
let p = Path::new(prefix);
|
||||
let (parent, name) = if prefix.is_empty() || prefix.ends_with('/') {
|
||||
(p.to_path_buf(), String::new())
|
||||
} else {
|
||||
let parent = p.parent().map(|p| p.to_path_buf()).unwrap_or_default();
|
||||
let name = p
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
(parent, name)
|
||||
};
|
||||
let dir = if is_absolute {
|
||||
parent
|
||||
} else if parent.as_os_str().is_empty() {
|
||||
cwd.to_path_buf()
|
||||
} else {
|
||||
cwd.join(parent)
|
||||
};
|
||||
(dir, name, is_absolute)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use llm_engine::ContentPart;
|
||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn fs_for(dir: &TempDir) -> ScopedFs {
|
||||
ScopedFs::new(
|
||||
fn fs_for(dir: &TempDir) -> WorkdirSessionHandle {
|
||||
Arc::new(LocalWorkdirSession::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().to_path_buf(),
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fn touch(path: &Path, content: &str) {
|
||||
@@ -397,26 +311,28 @@ mod tests {
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slice_lines_handles_offset_and_limit() {
|
||||
#[tokio::test]
|
||||
async fn slice_lines_handles_offset_and_limit() {
|
||||
let text = "a\nb\nc\nd";
|
||||
assert_eq!(slice_lines(text, 0, None), "a\nb\nc\nd");
|
||||
assert_eq!(slice_lines(text, 1, Some(2)), "b\nc");
|
||||
assert_eq!(slice_lines(text, 10, None), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_auto_read_emits_system_messages_with_range_label() {
|
||||
#[tokio::test]
|
||||
async fn render_auto_read_emits_system_messages_with_range_label() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let file = dir.path().join("hello.txt");
|
||||
std::fs::write(&file, "alpha\nbeta\ngamma\n").unwrap();
|
||||
|
||||
let view = WorkerFsView::new(fs_for(&dir));
|
||||
let items = view.render_auto_read(&[ReadRequirement {
|
||||
path: file.clone(),
|
||||
offset: Some(1),
|
||||
limit: Some(1),
|
||||
}]);
|
||||
let items = view
|
||||
.render_auto_read(&[ReadRequirement {
|
||||
path: PathBuf::from("hello.txt"),
|
||||
offset: Some(1),
|
||||
limit: Some(1),
|
||||
}])
|
||||
.await;
|
||||
|
||||
assert_eq!(items.len(), 1);
|
||||
let rendered = format!("{:?}", items[0]);
|
||||
@@ -426,35 +342,35 @@ mod tests {
|
||||
assert!(!rendered.contains("alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_file_ref_emits_system_message_with_path_header() {
|
||||
#[tokio::test]
|
||||
async fn resolve_file_ref_emits_system_message_with_path_header() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("hello.txt"), "hello world").unwrap();
|
||||
let view = WorkerFsView::new(fs_for(&dir));
|
||||
|
||||
let item = view.resolve_file_ref("hello.txt", 1024).unwrap();
|
||||
let item = view.resolve_file_ref("hello.txt", 1024).await.unwrap();
|
||||
let text = format!("{item:?}");
|
||||
assert!(text.contains("[File: hello.txt]"));
|
||||
assert!(text.contains("hello world"));
|
||||
assert!(!text.contains("truncated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_file_ref_truncates_with_hint_when_over_cap() {
|
||||
#[tokio::test]
|
||||
async fn resolve_file_ref_truncates_with_hint_when_over_cap() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let body = "x".repeat(2048);
|
||||
std::fs::write(dir.path().join("big.txt"), &body).unwrap();
|
||||
let view = WorkerFsView::new(fs_for(&dir));
|
||||
|
||||
let item = view.resolve_file_ref("big.txt", 256).unwrap();
|
||||
let item = view.resolve_file_ref("big.txt", 256).await.unwrap();
|
||||
let text = format!("{item:?}");
|
||||
assert!(text.contains("[File: big.txt]"));
|
||||
assert!(text.contains("truncated"));
|
||||
assert!(text.contains("2048 bytes total"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_file_ref_lists_directory_shallow_entries() {
|
||||
#[tokio::test]
|
||||
async fn resolve_file_ref_lists_directory_shallow_entries() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("docs/sub")).unwrap();
|
||||
touch(&dir.path().join("docs/.hidden"), "hidden");
|
||||
@@ -465,7 +381,7 @@ mod tests {
|
||||
);
|
||||
let view = WorkerFsView::new(fs_for(&dir));
|
||||
|
||||
let item = view.resolve_file_ref("docs", 4096).unwrap();
|
||||
let item = view.resolve_file_ref("docs", 4096).await.unwrap();
|
||||
let text = system_text(&item);
|
||||
assert!(text.starts_with("[Dir: docs]\n"));
|
||||
assert!(text.contains("sub/"));
|
||||
@@ -481,8 +397,8 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_file_ref_directory_listing_filters_unreadable_entries() {
|
||||
#[tokio::test]
|
||||
async fn resolve_file_ref_directory_listing_filters_unreadable_entries() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let docs = dir.path().join("docs");
|
||||
let secret = docs.join("secret");
|
||||
@@ -503,25 +419,26 @@ mod tests {
|
||||
}],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
let fs: WorkdirSessionHandle =
|
||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||
let view = WorkerFsView::new(fs);
|
||||
|
||||
let item = view.resolve_file_ref("docs", 4096).unwrap();
|
||||
let item = view.resolve_file_ref("docs", 4096).await.unwrap();
|
||||
let text = system_text(&item);
|
||||
assert!(text.contains("visible.txt"));
|
||||
assert!(!text.contains("secret"));
|
||||
assert!(!text.contains("hidden.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_file_ref_directory_listing_uses_upload_byte_cap() {
|
||||
#[tokio::test]
|
||||
async fn resolve_file_ref_directory_listing_uses_upload_byte_cap() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir(dir.path().join("docs")).unwrap();
|
||||
touch(&dir.path().join("docs/very-long-file-name.txt"), "");
|
||||
touch(&dir.path().join("docs/another-long-file-name.txt"), "");
|
||||
let view = WorkerFsView::new(fs_for(&dir));
|
||||
|
||||
let item = view.resolve_file_ref("docs", 10).unwrap();
|
||||
let item = view.resolve_file_ref("docs", 10).await.unwrap();
|
||||
let text = system_text(&item);
|
||||
assert!(text.starts_with("[Dir: docs]\n"));
|
||||
assert!(text.contains("truncated"));
|
||||
@@ -529,8 +446,8 @@ mod tests {
|
||||
assert!(text.contains("use Glob or Read for more"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_file_ref_directory_listing_uses_completion_entry_limit() {
|
||||
#[tokio::test]
|
||||
async fn resolve_file_ref_directory_listing_uses_completion_entry_limit() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir(dir.path().join("docs")).unwrap();
|
||||
for i in 0..(DIR_FILE_REF_ENTRY_LIMIT + 5) {
|
||||
@@ -538,7 +455,7 @@ mod tests {
|
||||
}
|
||||
let view = WorkerFsView::new(fs_for(&dir));
|
||||
|
||||
let item = view.resolve_file_ref("docs", 4096).unwrap();
|
||||
let item = view.resolve_file_ref("docs", 4096).await.unwrap();
|
||||
let text = system_text(&item);
|
||||
assert!(text.contains("105 readable entries total"));
|
||||
assert!(text.contains("file-099.txt"));
|
||||
@@ -547,8 +464,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn resolve_file_ref_directory_listing_marks_readable_symlink_entries() {
|
||||
#[tokio::test]
|
||||
async fn resolve_file_ref_directory_listing_marks_readable_symlink_entries() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -557,92 +474,95 @@ mod tests {
|
||||
symlink("target.txt", dir.path().join("docs/link.txt")).unwrap();
|
||||
let view = WorkerFsView::new(fs_for(&dir));
|
||||
|
||||
let item = view.resolve_file_ref("docs", 4096).unwrap();
|
||||
let item = view.resolve_file_ref("docs", 4096).await.unwrap();
|
||||
let text = system_text(&item);
|
||||
assert!(text.contains("link.txt@"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_file_ref_rejects_binary_with_binary_error() {
|
||||
#[tokio::test]
|
||||
async fn resolve_file_ref_rejects_binary_with_binary_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("blob.bin"), [0xff, 0xfe, 0x00, 0x80]).unwrap();
|
||||
let view = WorkerFsView::new(fs_for(&dir));
|
||||
|
||||
let err = view.resolve_file_ref("blob.bin", 1024).unwrap_err();
|
||||
let err = view.resolve_file_ref("blob.bin", 1024).await.unwrap_err();
|
||||
assert!(matches!(err, ResolveError::Binary { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_file_ref_returns_fs_error_for_out_of_scope() {
|
||||
#[tokio::test]
|
||||
async fn resolve_file_ref_returns_fs_error_for_out_of_scope() {
|
||||
let outer = TempDir::new().unwrap();
|
||||
let inner = outer.path().join("scoped");
|
||||
std::fs::create_dir(&inner).unwrap();
|
||||
std::fs::write(outer.path().join("secret.txt"), "nope").unwrap();
|
||||
let scope = Scope::writable(&inner).unwrap();
|
||||
let fs = ScopedFs::new(scope, inner.clone());
|
||||
let fs: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, inner.clone()));
|
||||
let view = WorkerFsView::new(fs);
|
||||
|
||||
// Absolute path outside of scope.
|
||||
let outside = outer.path().join("secret.txt");
|
||||
let err = view
|
||||
.resolve_file_ref(outside.to_str().unwrap(), 1024)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ResolveError::Fs(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_auto_read_skips_unreadable_targets() {
|
||||
#[tokio::test]
|
||||
async fn render_auto_read_skips_unreadable_targets() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let view = WorkerFsView::new(fs_for(&dir));
|
||||
let items = view.render_auto_read(&[ReadRequirement {
|
||||
path: dir.path().join("missing.txt"),
|
||||
offset: None,
|
||||
limit: None,
|
||||
}]);
|
||||
let items = view
|
||||
.render_auto_read(&[ReadRequirement {
|
||||
path: dir.path().join("missing.txt"),
|
||||
offset: None,
|
||||
limit: None,
|
||||
}])
|
||||
.await;
|
||||
assert!(items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_file_completions_lists_pwd_when_prefix_empty() {
|
||||
#[tokio::test]
|
||||
async fn list_file_completions_lists_pwd_when_prefix_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
touch(&dir.path().join("alpha.rs"), "");
|
||||
touch(&dir.path().join("beta.rs"), "");
|
||||
std::fs::create_dir(dir.path().join("subdir")).unwrap();
|
||||
let view = WorkerFsView::new(fs_for(&dir));
|
||||
|
||||
let cands = view.list_file_completions("");
|
||||
let cands = view.list_file_completions("").await;
|
||||
// ディレクトリ first
|
||||
let names: Vec<&str> = cands.iter().map(|c| c.path.as_str()).collect();
|
||||
assert_eq!(names, vec!["subdir", "alpha.rs", "beta.rs"]);
|
||||
assert!(cands[0].is_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_file_completions_filters_by_name_prefix() {
|
||||
#[tokio::test]
|
||||
async fn list_file_completions_filters_by_name_prefix() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
touch(&dir.path().join("alpha.rs"), "");
|
||||
touch(&dir.path().join("beta.rs"), "");
|
||||
let view = WorkerFsView::new(fs_for(&dir));
|
||||
|
||||
let cands = view.list_file_completions("al");
|
||||
let cands = view.list_file_completions("al").await;
|
||||
assert_eq!(cands.len(), 1);
|
||||
assert_eq!(cands[0].path, "alpha.rs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_file_completions_descends_into_subdir_with_trailing_slash() {
|
||||
#[tokio::test]
|
||||
async fn list_file_completions_descends_into_subdir_with_trailing_slash() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
touch(&dir.path().join("sub/x.rs"), "");
|
||||
touch(&dir.path().join("sub/y.rs"), "");
|
||||
let view = WorkerFsView::new(fs_for(&dir));
|
||||
|
||||
let cands = view.list_file_completions("sub/");
|
||||
let cands = view.list_file_completions("sub/").await;
|
||||
let names: Vec<&str> = cands.iter().map(|c| c.path.as_str()).collect();
|
||||
assert_eq!(names, vec!["sub/x.rs", "sub/y.rs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_file_completions_filters_out_non_readable_under_scope() {
|
||||
#[tokio::test]
|
||||
async fn list_file_completions_filters_out_non_readable_under_scope() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let secret = dir.path().join("secret");
|
||||
std::fs::create_dir(&secret).unwrap();
|
||||
@@ -662,25 +582,24 @@ mod tests {
|
||||
}],
|
||||
};
|
||||
let scope = Scope::from_config(&cfg).unwrap();
|
||||
let fs = ScopedFs::new(scope, dir.path().to_path_buf());
|
||||
let fs: WorkdirSessionHandle =
|
||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||
let view = WorkerFsView::new(fs);
|
||||
|
||||
let cands = view.list_file_completions("");
|
||||
let cands = view.list_file_completions("").await;
|
||||
let names: Vec<&str> = cands.iter().map(|c| c.path.as_str()).collect();
|
||||
assert!(names.contains(&"visible.rs"));
|
||||
assert!(!names.contains(&"secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_file_completions_supports_absolute_prefix() {
|
||||
#[tokio::test]
|
||||
async fn list_file_completions_rejects_absolute_prefix() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
touch(&dir.path().join("a.rs"), "");
|
||||
let view = WorkerFsView::new(fs_for(&dir));
|
||||
|
||||
let prefix = format!("{}/", dir.path().display());
|
||||
let cands = view.list_file_completions(&prefix);
|
||||
assert_eq!(cands.len(), 1);
|
||||
assert!(cands[0].path.starts_with('/'));
|
||||
assert!(cands[0].path.ends_with("a.rs"));
|
||||
let cands = view.list_file_completions(&prefix).await;
|
||||
assert!(cands.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ pub async fn dispatch_worker_protocol_method(
|
||||
) -> Option<Event> {
|
||||
match method {
|
||||
Method::ListCompletions { kind, prefix } => {
|
||||
let entries = handle.completion_entries(kind, &prefix);
|
||||
let entries = handle.completion_entries(kind, &prefix).await;
|
||||
Some(Event::Completions { kind, entries })
|
||||
}
|
||||
method => {
|
||||
|
||||
@@ -40,6 +40,9 @@ pub use runtime::dir::RuntimeDir;
|
||||
pub use segment_log_sink::SegmentLogSink;
|
||||
pub use shared_state::WorkerSharedState;
|
||||
pub use worker::{
|
||||
LocalWorkingDirectory, Worker, WorkerError, WorkerFilesystemAuthority, WorkerRunResult,
|
||||
WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, WorkspaceIdError, apply_worker_manifest,
|
||||
LocalWorkingDirectory, RuntimeWorkspaceHttpClient, Worker, WorkerError,
|
||||
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceClient,
|
||||
WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspaceRequest, WorkspaceRequestMethod,
|
||||
WorkspaceResponse, apply_worker_manifest, marker_workspace_client,
|
||||
unavailable_workspace_client,
|
||||
};
|
||||
|
||||
@@ -23,11 +23,10 @@ pub struct WorkerSharedState {
|
||||
pub greeting: protocol::Greeting,
|
||||
pub status: RwLock<WorkerStatus>,
|
||||
/// Worker-from-the-inside view of the filesystem. Set once in
|
||||
/// `WorkerController::start` after the `ScopedFs` is materialised, and
|
||||
/// read from the IPC server layer to answer `ListCompletions`
|
||||
/// queries without going through the controller. `None` until set
|
||||
/// (only relevant for unit tests that build a `WorkerSharedState`
|
||||
/// directly without spinning up a controller).
|
||||
/// `WorkerController::start` after the local WorkdirSession provider is
|
||||
/// materialised, and read from the IPC server layer to answer
|
||||
/// `ListCompletions` queries without going through the controller. It is
|
||||
/// unset only in unit tests that construct `WorkerSharedState` directly.
|
||||
fs_view: OnceLock<WorkerFsView>,
|
||||
}
|
||||
|
||||
|
||||
+38
-26
@@ -128,7 +128,7 @@ pub enum SkillClientError {
|
||||
#[error("workspace client kind `{0}` does not expose direct Skill HTTP operations")]
|
||||
UnsupportedClient(String),
|
||||
#[error("Skill request failed: {0}")]
|
||||
Request(#[from] reqwest::Error),
|
||||
Request(#[from] crate::worker::WorkspaceClientError),
|
||||
#[error("Skill API response JSON is invalid: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("Skill API returned HTTP {status}: {body}")]
|
||||
@@ -140,7 +140,7 @@ pub enum SkillClientError {
|
||||
InvalidBaseUrl(String),
|
||||
}
|
||||
|
||||
impl WorkspaceClient {
|
||||
impl dyn WorkspaceClient + '_ {
|
||||
pub fn list_skills(&self) -> Result<SkillCatalogResponse, SkillClientError> {
|
||||
self.get_skill_json("skills")
|
||||
}
|
||||
@@ -157,29 +157,21 @@ impl WorkspaceClient {
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<T, SkillClientError> {
|
||||
let Self::Http {
|
||||
workspace_id,
|
||||
base_url,
|
||||
} = self
|
||||
else {
|
||||
return match self {
|
||||
Self::Available { kind } => Err(SkillClientError::UnsupportedClient(kind.clone())),
|
||||
Self::Unavailable { reason } => Err(SkillClientError::Unavailable(reason.clone())),
|
||||
Self::Http { .. } => unreachable!(),
|
||||
};
|
||||
};
|
||||
if base_url.trim().is_empty() {
|
||||
return Err(SkillClientError::InvalidBaseUrl(base_url.clone()));
|
||||
let workspace_id = self
|
||||
.workspace_id()
|
||||
.ok_or_else(|| SkillClientError::UnsupportedClient(self.kind().to_string()))?;
|
||||
let response = self.execute(crate::worker::WorkspaceRequest::get(format!(
|
||||
"/api/w/{workspace_id}/{path}"
|
||||
)))?;
|
||||
let status = reqwest::StatusCode::from_u16(response.status)
|
||||
.unwrap_or(reqwest::StatusCode::INTERNAL_SERVER_ERROR);
|
||||
if !response.is_success() {
|
||||
return Err(SkillClientError::Http {
|
||||
status,
|
||||
body: response.body,
|
||||
});
|
||||
}
|
||||
let base = base_url.trim_end_matches('/');
|
||||
let url = format!("{base}/api/w/{workspace_id}/{path}");
|
||||
let response = reqwest::blocking::Client::new().get(url).send()?;
|
||||
let status = response.status();
|
||||
let body = response.text()?;
|
||||
if !status.is_success() {
|
||||
return Err(SkillClientError::Http { status, body });
|
||||
}
|
||||
Ok(serde_json::from_str(&body)?)
|
||||
Ok(serde_json::from_str(&response.body)?)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,13 +193,28 @@ mod tests {
|
||||
let mut request_line = String::new();
|
||||
reader.read_line(&mut request_line).unwrap();
|
||||
assert!(request_line.starts_with("GET /api/w/ws-1/skills HTTP/1.1"));
|
||||
let mut runtime_header = None;
|
||||
let mut worker_header = None;
|
||||
let mut authorization = None;
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
if let Some(value) = line.strip_prefix("x-yoi-runtime-id: ") {
|
||||
runtime_header = Some(value.trim().to_string());
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("x-yoi-worker-id: ") {
|
||||
worker_header = Some(value.trim().to_string());
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("authorization: ") {
|
||||
authorization = Some(value.trim().to_string());
|
||||
}
|
||||
if line == "\r\n" || line.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(runtime_header.as_deref(), Some("runtime-test"));
|
||||
assert_eq!(worker_header.as_deref(), Some("test-worker"));
|
||||
assert_eq!(authorization, None);
|
||||
let body = serde_json::json!({
|
||||
"authority": "workspace-backend-skills-v0",
|
||||
"entries": [{
|
||||
@@ -229,8 +236,13 @@ mod tests {
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let client = WorkspaceClient::http("ws-1", format!("http://{addr}"));
|
||||
let catalog = client.list_skills().unwrap();
|
||||
let client = crate::worker::RuntimeWorkspaceHttpClient::new(
|
||||
"ws-1",
|
||||
format!("http://{addr}"),
|
||||
"runtime-test",
|
||||
"test-worker",
|
||||
);
|
||||
let catalog = (&client as &dyn WorkspaceClient).list_skills().unwrap();
|
||||
assert_eq!(catalog.entries[0].name, "triage-errors");
|
||||
assert_eq!(catalog.entries[0].provenance.id, "workspace:triage-errors");
|
||||
handle.join().unwrap();
|
||||
|
||||
+395
-76
@@ -76,6 +76,7 @@ use protocol::{
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::task::JoinHandle;
|
||||
use workdir::{LocalWorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle};
|
||||
|
||||
const RESTORE_RECONCILIATION_REACHABILITY_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
@@ -143,77 +144,283 @@ pub enum WorkspaceIdError {
|
||||
Empty,
|
||||
}
|
||||
|
||||
/// Narrow path-free workspace API handle injected by Runtime/host code.
|
||||
///
|
||||
/// This is deliberately not a filesystem authority surface. A Worker may have a
|
||||
/// workspace client without local filesystem authority, or neither. Local
|
||||
/// path-backed implementations are represented only as a capability marker here;
|
||||
/// the actual paths remain under [`WorkerFilesystemAuthority::Local`] or in host
|
||||
/// adapter code.
|
||||
/// One authority-bound operation sent through the Runtime-supplied Workspace client.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WorkspaceClient {
|
||||
/// Runtime/host supplied an HTTP workspace API endpoint.
|
||||
Http {
|
||||
workspace_id: String,
|
||||
base_url: String,
|
||||
},
|
||||
/// Runtime/host supplied a workspace API handle. The string is an opaque
|
||||
/// diagnostic/backend kind, not an endpoint, path, or secret-bearing value.
|
||||
Available { kind: String },
|
||||
/// Workspace-aware operations must fail closed or stay disabled.
|
||||
Unavailable { reason: String },
|
||||
pub struct WorkspaceRequest {
|
||||
pub method: WorkspaceRequestMethod,
|
||||
pub path: String,
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
impl WorkspaceClient {
|
||||
pub fn available(kind: impl Into<String>) -> Self {
|
||||
Self::Available { kind: kind.into() }
|
||||
impl WorkspaceRequest {
|
||||
pub fn get(path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
method: WorkspaceRequestMethod::Get,
|
||||
path: path.into(),
|
||||
body: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn http(workspace_id: impl Into<String>, base_url: impl Into<String>) -> Self {
|
||||
Self::Http {
|
||||
pub fn json(
|
||||
method: WorkspaceRequestMethod,
|
||||
path: impl Into<String>,
|
||||
body: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
method,
|
||||
path: path.into(),
|
||||
body: Some(body.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WorkspaceRequestMethod {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Patch,
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceResponse {
|
||||
pub status: u16,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl WorkspaceResponse {
|
||||
pub fn is_success(&self) -> bool {
|
||||
(200..300).contains(&self.status)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum WorkspaceClientError {
|
||||
#[error("workspace client is unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
#[error("workspace request path must start with '/': {0}")]
|
||||
InvalidPath(String),
|
||||
#[error("workspace request failed: {0}")]
|
||||
Request(String),
|
||||
}
|
||||
|
||||
/// Path-free Workspace operation authority injected by Runtime/host code.
|
||||
///
|
||||
/// Workers receive this trait object rather than a Backend URL. The concrete
|
||||
/// implementation is responsible for binding Runtime/Worker identity and
|
||||
/// forwarding operations to the Workspace authority.
|
||||
pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
|
||||
fn workspace_id(&self) -> Option<&str>;
|
||||
fn kind(&self) -> &str;
|
||||
fn is_available(&self) -> bool;
|
||||
fn execute(&self, request: WorkspaceRequest)
|
||||
-> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||
}
|
||||
|
||||
/// HTTP forwarding client created by Runtime for one concrete Worker execution.
|
||||
///
|
||||
/// The upstream endpoint and source headers are private implementation details;
|
||||
/// model-visible tools can only submit [`WorkspaceRequest`] values through the
|
||||
/// [`WorkspaceClient`] trait.
|
||||
pub struct RuntimeWorkspaceHttpClient {
|
||||
workspace_id: String,
|
||||
base_url: String,
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RuntimeWorkspaceHttpClient {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RuntimeWorkspaceHttpClient")
|
||||
.field("workspace_id", &self.workspace_id)
|
||||
.field("base_url", &self.base_url)
|
||||
.field("runtime_id", &self.runtime_id)
|
||||
.field("worker_id", &self.worker_id)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeWorkspaceHttpClient {
|
||||
pub fn new(
|
||||
workspace_id: impl Into<String>,
|
||||
base_url: impl Into<String>,
|
||||
runtime_id: impl Into<String>,
|
||||
worker_id: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
workspace_id: workspace_id.into(),
|
||||
base_url: base_url.into(),
|
||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||
runtime_id: runtime_id.into(),
|
||||
worker_id: worker_id.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unavailable(reason: impl Into<String>) -> Self {
|
||||
Self::Unavailable {
|
||||
reason: reason.into(),
|
||||
impl WorkspaceClient for RuntimeWorkspaceHttpClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some(&self.workspace_id)
|
||||
}
|
||||
|
||||
fn kind(&self) -> &str {
|
||||
"runtime-http-proxy"
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let base_url = self.base_url.clone();
|
||||
let runtime_id = self.runtime_id.clone();
|
||||
let worker_id = self.worker_id.clone();
|
||||
if tokio::runtime::Handle::try_current().is_ok() {
|
||||
std::thread::spawn(move || {
|
||||
execute_runtime_workspace_http(&base_url, &runtime_id, &worker_id, request)
|
||||
})
|
||||
.join()
|
||||
.map_err(|_| {
|
||||
WorkspaceClientError::Request("workspace request thread panicked".to_string())
|
||||
})?
|
||||
} else {
|
||||
execute_runtime_workspace_http(&base_url, &runtime_id, &worker_id, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_filesystem() -> Self {
|
||||
Self::available("local-filesystem")
|
||||
fn execute_runtime_workspace_http(
|
||||
base_url: &str,
|
||||
runtime_id: &str,
|
||||
worker_id: &str,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
if !request.path.starts_with('/') || request.path.starts_with("//") {
|
||||
return Err(WorkspaceClientError::InvalidPath(request.path));
|
||||
}
|
||||
let url = format!("{base_url}{}", request.path);
|
||||
let method = match request.method {
|
||||
WorkspaceRequestMethod::Get => reqwest::Method::GET,
|
||||
WorkspaceRequestMethod::Post => reqwest::Method::POST,
|
||||
WorkspaceRequestMethod::Put => reqwest::Method::PUT,
|
||||
WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
|
||||
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
|
||||
};
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let mut request_builder = client
|
||||
.request(method, url)
|
||||
.header("x-yoi-runtime-id", runtime_id)
|
||||
.header("x-yoi-worker-id", worker_id);
|
||||
if let Some(body) = request.body {
|
||||
request_builder = request_builder
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(body);
|
||||
}
|
||||
let response = request_builder
|
||||
.send()
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response
|
||||
.text()
|
||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||
Ok(WorkspaceResponse { status, body })
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MarkerWorkspaceClient {
|
||||
workspace_id: Option<String>,
|
||||
kind: String,
|
||||
available: bool,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
impl WorkspaceClient for MarkerWorkspaceClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
self.workspace_id.as_deref()
|
||||
}
|
||||
|
||||
pub fn is_available(&self) -> bool {
|
||||
matches!(self, Self::Available { .. } | Self::Http { .. })
|
||||
fn kind(&self) -> &str {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
self.available
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
_request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
Err(WorkspaceClientError::Unavailable(self.reason.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unavailable_workspace_client(
|
||||
workspace_id: Option<&WorkspaceId>,
|
||||
reason: impl Into<String>,
|
||||
) -> Arc<dyn WorkspaceClient> {
|
||||
Arc::new(MarkerWorkspaceClient {
|
||||
workspace_id: workspace_id.map(|id| id.as_str().to_string()),
|
||||
kind: "unavailable".to_string(),
|
||||
available: false,
|
||||
reason: reason.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn marker_workspace_client(
|
||||
workspace_id: Option<&WorkspaceId>,
|
||||
kind: impl Into<String>,
|
||||
) -> Arc<dyn WorkspaceClient> {
|
||||
let kind = kind.into();
|
||||
Arc::new(MarkerWorkspaceClient {
|
||||
workspace_id: workspace_id.map(|id| id.as_str().to_string()),
|
||||
reason: format!("workspace client kind `{kind}` does not expose Workspace operations"),
|
||||
kind,
|
||||
available: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Workspace context supplied to a Worker separately from filesystem authority.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Clone)]
|
||||
pub struct WorkerWorkspaceContext {
|
||||
workspace_id: Option<WorkspaceId>,
|
||||
client: WorkspaceClient,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WorkerWorkspaceContext {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("WorkerWorkspaceContext")
|
||||
.field("workspace_id", &self.workspace_id)
|
||||
.field("client_kind", &self.client.kind())
|
||||
.field("client_available", &self.client.is_available())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkerWorkspaceContext {
|
||||
pub fn no_workspace() -> Self {
|
||||
Self {
|
||||
workspace_id: None,
|
||||
client: WorkspaceClient::unavailable("no workspace configured"),
|
||||
client: unavailable_workspace_client(None, "no workspace configured"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn unavailable(workspace_id: Option<WorkspaceId>, reason: impl Into<String>) -> Self {
|
||||
let client = unavailable_workspace_client(workspace_id.as_ref(), reason);
|
||||
Self {
|
||||
workspace_id,
|
||||
client: WorkspaceClient::unavailable(reason),
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_client(workspace_id: Option<WorkspaceId>, client: WorkspaceClient) -> Self {
|
||||
pub fn with_client(
|
||||
workspace_id: Option<WorkspaceId>,
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
) -> Self {
|
||||
Self {
|
||||
workspace_id,
|
||||
client,
|
||||
@@ -221,15 +428,23 @@ impl WorkerWorkspaceContext {
|
||||
}
|
||||
|
||||
pub fn local_filesystem(workspace_id: Option<WorkspaceId>) -> Self {
|
||||
Self::with_client(workspace_id, WorkspaceClient::local_filesystem())
|
||||
let client = marker_workspace_client(workspace_id.as_ref(), "local-filesystem");
|
||||
Self {
|
||||
workspace_id,
|
||||
client,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn workspace_id(&self) -> Option<&WorkspaceId> {
|
||||
self.workspace_id.as_ref()
|
||||
}
|
||||
|
||||
pub fn client(&self) -> &WorkspaceClient {
|
||||
&self.client
|
||||
pub fn client(&self) -> &dyn WorkspaceClient {
|
||||
self.client.as_ref()
|
||||
}
|
||||
|
||||
pub fn client_handle(&self) -> Arc<dyn WorkspaceClient> {
|
||||
self.client.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,13 +643,15 @@ pub struct Worker<C: LlmClient, St: Store> {
|
||||
/// Explicit local filesystem authority, or `None` for Workers with no
|
||||
/// local cwd and no filesystem/Bash tool surface.
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
/// Live WorkdirSession provider derived once from the Worker–Workdir binding.
|
||||
/// Local tools, file views, and compaction workers clone this handle.
|
||||
workdir_session: Option<WorkdirSessionHandle>,
|
||||
/// Path-free workspace identity/client context injected by Runtime/host.
|
||||
/// This never grants local filesystem authority.
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
/// Shared, atomically-swappable view of the Worker's resolved scope.
|
||||
/// Cloned out to `ScopedFs` instances (builtin tools, fs_view,
|
||||
/// compact worker) so scope updates propagate to every consumer
|
||||
/// at the next permission check.
|
||||
/// Cloned into local WorkdirSession providers used by builtin tools, fs_view,
|
||||
/// and compaction so updates propagate at the next permission check.
|
||||
scope: SharedScope,
|
||||
/// Filesystem authority this Worker may pass to spawned children. Direct tools
|
||||
/// continue to use `scope`; SpawnWorker validates requested child scope here.
|
||||
@@ -610,6 +827,7 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Worker<C, St>
|
||||
worker_metadata_writer: None,
|
||||
segment_state: self.segment_state.clone(),
|
||||
filesystem_authority: self.filesystem_authority.clone(),
|
||||
workdir_session: self.workdir_session.clone(),
|
||||
workspace_context: self.workspace_context.clone(),
|
||||
scope: self.scope.clone(),
|
||||
delegation_scope: self.delegation_scope.clone(),
|
||||
@@ -797,6 +1015,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
let prompts = PromptCatalog::builtins_only()?;
|
||||
let delegation_scope =
|
||||
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
|
||||
let scope = SharedScope::new(scope);
|
||||
let workdir_session = workdir_session_from_authority(&filesystem_authority, &scope);
|
||||
let mut worker = Self {
|
||||
manifest,
|
||||
engine: Some(worker),
|
||||
@@ -804,8 +1024,9 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
worker_metadata_writer: None,
|
||||
segment_state: SegmentState::new(session_id, segment_id, 0),
|
||||
filesystem_authority,
|
||||
workdir_session,
|
||||
workspace_context,
|
||||
scope: SharedScope::new(scope),
|
||||
scope,
|
||||
delegation_scope,
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
interceptor_installed: false,
|
||||
@@ -918,6 +1139,17 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
self.filesystem_authority.as_local()
|
||||
}
|
||||
|
||||
pub fn workdir_session(&self) -> Option<&WorkdirSessionHandle> {
|
||||
self.workdir_session.as_ref()
|
||||
}
|
||||
|
||||
/// Replace the constructor fallback with the provider binding resolved by
|
||||
/// the owning Runtime. Runtime calls this before the Worker controller is
|
||||
/// spawned, so tools only ever observe the Runtime-bound handle.
|
||||
pub fn bind_workdir_session(&mut self, workdir_session: Option<WorkdirSessionHandle>) {
|
||||
self.workdir_session = workdir_session;
|
||||
}
|
||||
|
||||
/// Path-free workspace identity, if Runtime/host associated this Worker
|
||||
/// with a workspace.
|
||||
pub fn workspace_id(&self) -> Option<&WorkspaceId> {
|
||||
@@ -926,10 +1158,14 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
|
||||
/// Narrow workspace client/availability handle injected by Runtime/host.
|
||||
/// This never grants local filesystem authority.
|
||||
pub fn workspace_client(&self) -> &WorkspaceClient {
|
||||
pub fn workspace_client(&self) -> &dyn WorkspaceClient {
|
||||
self.workspace_context.client()
|
||||
}
|
||||
|
||||
pub fn workspace_client_handle(&self) -> Arc<dyn WorkspaceClient> {
|
||||
self.workspace_context.client_handle()
|
||||
}
|
||||
|
||||
async fn resident_summary_from_workspace_authority(
|
||||
&self,
|
||||
) -> Result<Option<String>, WorkerError> {
|
||||
@@ -1746,7 +1982,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
// Resolve `@<path>` file refs to system messages stashed for the
|
||||
// WorkerInterceptor to attach right after the user message. Resolution
|
||||
// failures are non-fatal alerts.
|
||||
let attachments = self.resolve_file_refs(&input);
|
||||
let attachments = self.resolve_file_refs(&input).await;
|
||||
let flattened = self.flatten_segments(&input);
|
||||
if !attachments.is_empty() {
|
||||
*self
|
||||
@@ -1777,8 +2013,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
/// directory) surface as `AlertLevel::Warn` Alerts and are skipped — the
|
||||
/// unresolved placeholder stays in the flattened user message so the LLM
|
||||
/// still sees the intent.
|
||||
fn resolve_file_refs(&self, segments: &[Segment]) -> Vec<SystemItem> {
|
||||
let Some(local) = self.local_working_directory() else {
|
||||
async fn resolve_file_refs(&self, segments: &[Segment]) -> Vec<SystemItem> {
|
||||
let Some(workdir) = self.workdir_session.clone() else {
|
||||
for seg in segments {
|
||||
if let Segment::FileRef { path } = seg {
|
||||
self.alert(
|
||||
@@ -1790,16 +2026,16 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
}
|
||||
return Vec::new();
|
||||
};
|
||||
let view = crate::fs_view::WorkerFsView::new(tools::ScopedFs::with_shared_scope(
|
||||
self.scope.clone(),
|
||||
local.cwd.clone(),
|
||||
));
|
||||
let view = crate::fs_view::WorkerFsView::new(workdir);
|
||||
let mut out = Vec::new();
|
||||
for seg in segments {
|
||||
let Segment::FileRef { path } = seg else {
|
||||
continue;
|
||||
};
|
||||
match view.resolve_file_ref(path, self.manifest.engine.file_upload.max_bytes) {
|
||||
match view
|
||||
.resolve_file_ref(path, self.manifest.engine.file_upload.max_bytes)
|
||||
.await
|
||||
{
|
||||
Ok(item) => {
|
||||
// `resolve_file_ref` returns an `Item::system_message`
|
||||
// whose text already carries the `[File: <path>]` or
|
||||
@@ -2555,13 +2791,10 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
auto_read_budget,
|
||||
)));
|
||||
|
||||
// Build an independent compact worker. When the main Worker has local
|
||||
// filesystem authority, compact-time reads go through the same scope
|
||||
// and cwd policy. No-workdir Workers deliberately omit compact-time
|
||||
// filesystem tools as well.
|
||||
let scoped_fs = self
|
||||
.local_working_directory()
|
||||
.map(|local| tools::ScopedFs::with_shared_scope(self.scope.clone(), local.cwd.clone()));
|
||||
// Build an independent compact worker. It clones the main Worker's
|
||||
// provider handle, so compact-time reads use the same WorkdirSession instance.
|
||||
// No-workdir Workers deliberately omit compact-time filesystem tools.
|
||||
let workdir = self.workdir_session.clone();
|
||||
let summary_tracker = tools::Tracker::new();
|
||||
let summary_client: Box<dyn LlmClient> = self.build_compactor_client()?;
|
||||
let summary_system_prompt = self
|
||||
@@ -2599,9 +2832,9 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
// Tools: read_file (shared scope, fresh tracker), bounded session
|
||||
// history exploration, and compact-specific tools that populate `ctx`.
|
||||
let compact_target_items = Arc::new(items_to_summarise.clone());
|
||||
if let Some(scoped_fs) = scoped_fs.clone() {
|
||||
summary_worker.register_tool(tools::read_tool(scoped_fs.clone(), summary_tracker));
|
||||
summary_worker.register_tool(mark_read_required_tool(scoped_fs, ctx.clone()));
|
||||
if let Some(workdir) = workdir.clone() {
|
||||
summary_worker.register_tool(tools::read_tool(workdir.clone(), summary_tracker));
|
||||
summary_worker.register_tool(mark_read_required_tool(workdir, ctx.clone()));
|
||||
}
|
||||
summary_worker.register_tool(search_session_log_tool(compact_target_items.clone()));
|
||||
summary_worker.register_tool(read_session_items_tool(compact_target_items));
|
||||
@@ -2681,12 +2914,13 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
// logged and skipped inside `render_auto_read` rather than
|
||||
// aborting compaction — a missing / moved file should not fail
|
||||
// the whole compact.
|
||||
let auto_read_messages = scoped_fs
|
||||
.clone()
|
||||
.map(|scoped_fs| {
|
||||
WorkerFsView::new(scoped_fs).render_auto_read(&final_ctx.read_required)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let auto_read_messages = if let Some(workdir) = workdir {
|
||||
WorkerFsView::new(workdir)
|
||||
.render_auto_read(&final_ctx.read_required)
|
||||
.await
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Reference list as a single system message; omitted when empty.
|
||||
let reference_message = (!final_ctx.references.is_empty()).then(|| {
|
||||
@@ -3197,7 +3431,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
items_to_extract,
|
||||
);
|
||||
let session_explore_state =
|
||||
SessionExploreState::new(session_view, self.workspace_client().clone(), source);
|
||||
SessionExploreState::new(session_view, self.workspace_client_handle(), source);
|
||||
let input_text = render_extract_input(session_explore_state.view());
|
||||
let mut internal_tools = Vec::new();
|
||||
let mut internal_hook_builder = HookRegistryBuilder::new();
|
||||
@@ -3464,7 +3698,7 @@ impl WorkerAuditBase {
|
||||
|
||||
async fn emit(
|
||||
&self,
|
||||
workspace_client: &WorkspaceClient,
|
||||
workspace_client: &dyn WorkspaceClient,
|
||||
event_tx: Option<&broadcast::Sender<Event>>,
|
||||
status: memory::audit::WorkerLifecycleStatus,
|
||||
reason: impl Into<String>,
|
||||
@@ -3623,6 +3857,8 @@ where
|
||||
apply_worker_manifest(&mut worker, &manifest.engine);
|
||||
worker.set_cache_key(Some(segment_id.to_string()));
|
||||
let worker_metadata_writer = Some(worker_metadata_writer_for_store(&store));
|
||||
let scope = SharedScope::new(common.scope);
|
||||
let workdir_session = workdir_session_from_authority(&common.filesystem_authority, &scope);
|
||||
|
||||
let mut worker = Self {
|
||||
manifest,
|
||||
@@ -3631,8 +3867,9 @@ where
|
||||
worker_metadata_writer,
|
||||
segment_state: SegmentState::new(session_id, segment_id, 0),
|
||||
filesystem_authority: common.filesystem_authority,
|
||||
workdir_session,
|
||||
workspace_context: common.workspace_context,
|
||||
scope: SharedScope::new(common.scope),
|
||||
scope,
|
||||
delegation_scope: common.delegation_scope,
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
interceptor_installed: false,
|
||||
@@ -3729,6 +3966,8 @@ where
|
||||
apply_worker_manifest(&mut worker, &manifest.engine);
|
||||
worker.set_cache_key(Some(segment_id.to_string()));
|
||||
let worker_metadata_writer = Some(worker_metadata_writer_for_store(&store));
|
||||
let scope = SharedScope::new(common.scope);
|
||||
let workdir_session = workdir_session_from_authority(&common.filesystem_authority, &scope);
|
||||
|
||||
let mut worker = Self {
|
||||
manifest,
|
||||
@@ -3737,8 +3976,9 @@ where
|
||||
worker_metadata_writer,
|
||||
segment_state: SegmentState::new(session_id, segment_id, 0),
|
||||
filesystem_authority: common.filesystem_authority,
|
||||
workdir_session,
|
||||
workspace_context: common.workspace_context,
|
||||
scope: SharedScope::new(common.scope),
|
||||
scope,
|
||||
delegation_scope: common.delegation_scope,
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
interceptor_installed: false,
|
||||
@@ -4018,6 +4258,8 @@ where
|
||||
let extract_pointer = memory::extract::fold_pointer(&state.extensions);
|
||||
let task_feature = TaskFeature::from_history(&state.history);
|
||||
let worker_metadata_writer = Some(worker_metadata_writer_for_store(&store));
|
||||
let scope = SharedScope::new(common.scope);
|
||||
let workdir_session = workdir_session_from_authority(&common.filesystem_authority, &scope);
|
||||
|
||||
let mut worker = Self {
|
||||
manifest,
|
||||
@@ -4026,8 +4268,9 @@ where
|
||||
worker_metadata_writer,
|
||||
segment_state: SegmentState::new(session_id, segment_id, state.entries_count),
|
||||
filesystem_authority: common.filesystem_authority,
|
||||
workdir_session,
|
||||
workspace_context: common.workspace_context,
|
||||
scope: SharedScope::new(common.scope),
|
||||
scope,
|
||||
delegation_scope: common.delegation_scope,
|
||||
hook_builder: HookRegistryBuilder::new(),
|
||||
interceptor_installed: false,
|
||||
@@ -4691,6 +4934,20 @@ pub enum WorkerError {
|
||||
},
|
||||
}
|
||||
|
||||
fn workdir_session_from_authority(
|
||||
authority: &WorkerFilesystemAuthority,
|
||||
scope: &SharedScope,
|
||||
) -> Option<WorkdirSessionHandle> {
|
||||
authority.as_local().map(|local| {
|
||||
Arc::new(LocalWorkdirSession::materialized(
|
||||
local.root.clone(),
|
||||
local.cwd.clone(),
|
||||
scope.clone(),
|
||||
WorkdirSessionCapabilities::ALL,
|
||||
)) as WorkdirSessionHandle
|
||||
})
|
||||
}
|
||||
|
||||
/// Bundle of resources that every high-level Worker constructor needs:
|
||||
/// filesystem authority, path-free workspace context, scope, an LLM client, the prompt catalog,
|
||||
/// and (optionally) a parsed system-prompt template. Built once by
|
||||
@@ -4936,7 +5193,7 @@ mod spawned_context_tests {
|
||||
false,
|
||||
WorkerWorkspaceContext::with_client(
|
||||
Some(workspace_id.clone()),
|
||||
WorkspaceClient::available("test-api"),
|
||||
marker_workspace_client(Some(&workspace_id), "test-api"),
|
||||
),
|
||||
WorkerFilesystemAuthority::None,
|
||||
manifest.scope.clone(),
|
||||
@@ -5773,7 +6030,12 @@ mod build_summary_prompt_tests {
|
||||
});
|
||||
WorkerWorkspaceContext::with_client(
|
||||
Some(WorkspaceId::new("test-memory").unwrap()),
|
||||
WorkspaceClient::http("test-memory", format!("http://{addr}")),
|
||||
Arc::new(RuntimeWorkspaceHttpClient::new(
|
||||
"test-memory",
|
||||
format!("http://{addr}"),
|
||||
"test-runtime",
|
||||
"test-worker",
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5905,7 +6167,12 @@ mod build_summary_prompt_tests {
|
||||
store,
|
||||
WorkerWorkspaceContext::with_client(
|
||||
Some(WorkspaceId::new("ws-skill").unwrap()),
|
||||
WorkspaceClient::http("ws-skill", format!("http://{addr}")),
|
||||
Arc::new(RuntimeWorkspaceHttpClient::new(
|
||||
"ws-skill",
|
||||
format!("http://{addr}"),
|
||||
"test-runtime",
|
||||
"test-worker",
|
||||
)),
|
||||
),
|
||||
authority,
|
||||
scope,
|
||||
@@ -5946,6 +6213,58 @@ mod build_summary_prompt_tests {
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_workspace_client_sends_runtime_worker_identity_without_bearer() {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
let mut first_line = String::new();
|
||||
reader.read_line(&mut first_line).unwrap();
|
||||
assert!(first_line.contains("/api/w/workspace-a/tickets/search"));
|
||||
let mut runtime_id = String::new();
|
||||
let mut worker_id = String::new();
|
||||
let mut authorization = String::new();
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
if let Some(value) = line.strip_prefix("x-yoi-runtime-id: ") {
|
||||
runtime_id = value.trim().to_string();
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("x-yoi-worker-id: ") {
|
||||
worker_id = value.trim().to_string();
|
||||
}
|
||||
if let Some(value) = line.strip_prefix("authorization: ") {
|
||||
authorization = value.trim().to_string();
|
||||
}
|
||||
if line == "\r\n" || line.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(runtime_id, "runtime-a");
|
||||
assert_eq!(worker_id, "worker-a");
|
||||
assert!(authorization.is_empty());
|
||||
stream
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}")
|
||||
.unwrap();
|
||||
});
|
||||
let client = RuntimeWorkspaceHttpClient::new(
|
||||
"workspace-a",
|
||||
format!("http://{address}"),
|
||||
"runtime-a",
|
||||
"worker-a",
|
||||
);
|
||||
let response = client
|
||||
.execute(WorkspaceRequest::get("/api/w/workspace-a/tickets/search"))
|
||||
.unwrap();
|
||||
assert_eq!(response.status, 200);
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
fn minimal_manifest() -> WorkerManifest {
|
||||
let toml_str = r#"
|
||||
[worker]
|
||||
|
||||
@@ -11,6 +11,10 @@ use llm_engine::llm_client::{ClientError, LlmClient, Request};
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use session_store::{CombinedStore, FsWorkerStore};
|
||||
use session_store::{FsStore, LogEntry};
|
||||
use workdir::{
|
||||
CommandRequest, LocalWorkdirSession, Workdir, WorkdirError, WorkdirSessionCapabilities,
|
||||
WorkdirSessionHandle,
|
||||
};
|
||||
|
||||
use worker::{
|
||||
Event, Method, Worker, WorkerController, WorkerFilesystemAuthority, WorkerHandle,
|
||||
@@ -213,6 +217,74 @@ async fn spawn_controller(worker: Worker<MockClient, TestStore>) -> WorkerHandle
|
||||
handle
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_closes_bound_workdir_session() {
|
||||
let (mut worker, pwd) = make_worker_with_pwd(MockClient::new(simple_text_events())).await;
|
||||
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
|
||||
Workdir::new("controller-test-workdir"),
|
||||
pwd.clone(),
|
||||
pwd,
|
||||
worker.scope().clone(),
|
||||
WorkdirSessionCapabilities::ALL,
|
||||
));
|
||||
let command = session
|
||||
.start_command(CommandRequest {
|
||||
command: "sleep 30".to_owned(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 1024,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
worker.bind_workdir_session(Some(Arc::clone(&session)));
|
||||
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
|
||||
.await
|
||||
.unwrap();
|
||||
handle.send(Method::Shutdown).await.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), shutdown_rx)
|
||||
.await
|
||||
.expect("controller should shut down")
|
||||
.expect("controller shutdown signal should remain open");
|
||||
|
||||
assert!(matches!(
|
||||
session.command_status(command).await,
|
||||
Err(WorkdirError::Unavailable(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn controller_startup_failure_closes_bound_workdir_session() {
|
||||
let (mut worker, pwd) = make_worker_with_pwd(MockClient::new(simple_text_events())).await;
|
||||
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
|
||||
Workdir::new("controller-startup-failure-workdir"),
|
||||
pwd.clone(),
|
||||
pwd,
|
||||
worker.scope().clone(),
|
||||
WorkdirSessionCapabilities::ALL,
|
||||
));
|
||||
worker.bind_workdir_session(Some(Arc::clone(&session)));
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let invalid_runtime_base = runtime_base.path().join("not-a-directory");
|
||||
std::fs::write(&invalid_runtime_base, "file").unwrap();
|
||||
|
||||
assert!(
|
||||
WorkerController::spawn(worker, &invalid_runtime_base)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(matches!(
|
||||
session
|
||||
.start_command(CommandRequest {
|
||||
command: "printf unreachable".to_owned(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
})
|
||||
.await,
|
||||
Err(WorkdirError::Unavailable(_))
|
||||
));
|
||||
}
|
||||
|
||||
async fn wait_for_status(handle: &WorkerHandle, status: WorkerStatus) {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
|
||||
@@ -31,9 +31,10 @@ sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
ticket.workspace = true
|
||||
memory.workspace = true
|
||||
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync"] }
|
||||
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
|
||||
tokio-tungstenite.workspace = true
|
||||
worker.workspace = true
|
||||
workdir = { workspace = true, features = ["http-client"] }
|
||||
worker-runtime.workspace = true
|
||||
toml.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
use crate::Error;
|
||||
use crate::resource_broker::BackendResourceBroker;
|
||||
use chrono::Utc;
|
||||
use reqwest::StatusCode;
|
||||
use reqwest::blocking::{Client as BlockingHttpClient, RequestBuilder};
|
||||
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
|
||||
use reqwest::{Client as AsyncHttpClient, StatusCode, Url};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
future::Future,
|
||||
path::PathBuf,
|
||||
pin::Pin,
|
||||
sync::{Arc, RwLock},
|
||||
time::Duration,
|
||||
};
|
||||
use workdir::{
|
||||
Workdir, WorkdirError,
|
||||
http::{OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirHttpAuthorization},
|
||||
};
|
||||
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
|
||||
use worker_runtime::catalog::{
|
||||
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef,
|
||||
@@ -34,7 +40,8 @@ use worker_runtime::http_server::{
|
||||
RuntimeHttpErrorResponse, RuntimeHttpSummaryResponse, RuntimeHttpWorkerCompletionsRequest,
|
||||
RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse,
|
||||
RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest,
|
||||
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse, RuntimeHttpWorkersResponse,
|
||||
RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse,
|
||||
RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse,
|
||||
RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse,
|
||||
};
|
||||
use worker_runtime::identity::{WorkerId as EmbeddedWorkerId, WorkerRef as EmbeddedWorkerRef};
|
||||
@@ -260,6 +267,14 @@ pub struct WorkerRestoreResult {
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerWorkspaceApiResult {
|
||||
pub state: WorkerOperationState,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub worker: Option<WorkerSummary>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RuntimeList<T> {
|
||||
pub items: Vec<T>,
|
||||
@@ -308,6 +323,27 @@ pub struct WorkerSpawnWorkingDirectoryRequest {
|
||||
pub selector: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerTicketAssignmentRequest {
|
||||
pub ticket_id: String,
|
||||
pub operation_id: String,
|
||||
}
|
||||
|
||||
pub(crate) fn worker_spawn_idempotency(
|
||||
request: &WorkerSpawnRequest,
|
||||
) -> Result<Option<(String, String)>, String> {
|
||||
let Some(assignment) = request.ticket_assignment.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let encoded = serde_json::to_vec(request)
|
||||
.map_err(|error| format!("serialize Worker spawn idempotency input: {error}"))?;
|
||||
Ok(Some((
|
||||
assignment.operation_id.clone(),
|
||||
format!("sha256:{}", digest_hex(&encoded, 64)),
|
||||
)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerSpawnRequest {
|
||||
@@ -317,6 +353,8 @@ pub struct WorkerSpawnRequest {
|
||||
pub acceptance: WorkerSpawnAcceptanceRequirement,
|
||||
pub profile: ProfileSelector,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ticket_assignment: Option<WorkerTicketAssignmentRequest>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub initial_input: Option<EmbeddedWorkerInput>,
|
||||
/// Optional safe working-directory creation request. The Workspace server resolves
|
||||
/// this into a runtime-internal `WorkingDirectoryRequest` from configured
|
||||
@@ -329,6 +367,8 @@ pub struct WorkerSpawnRequest {
|
||||
pub resolved_working_directory: Option<WorkingDirectoryClaim>,
|
||||
#[serde(skip, default)]
|
||||
pub resolved_config_bundle: Option<ConfigBundle>,
|
||||
#[serde(skip, default)]
|
||||
pub resolved_workspace_api: Option<WorkspaceApiRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -390,6 +430,18 @@ pub struct ConfigBundleListResult {
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
fn required_worker_workspace_api(
|
||||
request: &WorkerSpawnRequest,
|
||||
) -> Result<WorkspaceApiRef, RuntimeDiagnostic> {
|
||||
request.resolved_workspace_api.clone().ok_or_else(|| {
|
||||
diagnostic(
|
||||
"worker_workspace_api_missing",
|
||||
DiagnosticSeverity::Error,
|
||||
"Workspace-bound Worker spawn requires a resolved Workspace API binding",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerOperationState {
|
||||
@@ -427,6 +479,8 @@ pub struct WorkerStopResult {
|
||||
pub struct WorkerLifecycleRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ticket_assignment: Option<WorkerTicketAssignmentRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -434,8 +488,6 @@ pub struct WorkerLifecycleResult {
|
||||
pub state: WorkerOperationState,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub event_id: Option<u64>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
@@ -489,8 +541,6 @@ pub struct WorkerInputResult {
|
||||
pub state: WorkerOperationState,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub event_id: Option<u64>,
|
||||
pub diagnostics: Vec<RuntimeDiagnostic>,
|
||||
}
|
||||
|
||||
@@ -595,6 +645,24 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_worker_workspace_api(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
_workspace_api: WorkspaceApiRef,
|
||||
) -> WorkerWorkspaceApiResult {
|
||||
WorkerWorkspaceApiResult {
|
||||
state: WorkerOperationState::Unsupported,
|
||||
worker: None,
|
||||
diagnostics: vec![diagnostic(
|
||||
"worker_workspace_api_replace_unsupported",
|
||||
DiagnosticSeverity::Info,
|
||||
format!(
|
||||
"runtime does not support replacing the Workspace API for worker `{worker_id}`"
|
||||
),
|
||||
)],
|
||||
}
|
||||
}
|
||||
|
||||
fn create_working_directory(
|
||||
&self,
|
||||
_request: WorkingDirectoryRequest,
|
||||
@@ -628,6 +696,20 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_workdir_session<'a>(
|
||||
&'a self,
|
||||
_working_directory_id: &'a str,
|
||||
_owner_worker_id: Option<&'a str>,
|
||||
) -> Pin<
|
||||
Box<dyn Future<Output = Result<workdir::WorkdirSessionHandle, WorkdirError>> + Send + 'a>,
|
||||
> {
|
||||
Box::pin(async {
|
||||
Err(WorkdirError::Unavailable(
|
||||
"Runtime does not expose Workdir operation sessions".to_string(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn cleanup_working_directory(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
@@ -717,7 +799,6 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
||||
state: WorkerOperationState::Unsupported,
|
||||
runtime_id: self.runtime_id().to_string(),
|
||||
worker_id: worker_id.to_string(),
|
||||
event_id: None,
|
||||
diagnostics: vec![diagnostic(
|
||||
"worker_stop_pending",
|
||||
DiagnosticSeverity::Info,
|
||||
@@ -737,7 +818,6 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
||||
state: WorkerOperationState::Unsupported,
|
||||
runtime_id: self.runtime_id().to_string(),
|
||||
worker_id: worker_id.to_string(),
|
||||
event_id: None,
|
||||
diagnostics: vec![diagnostic(
|
||||
"worker_cancel_pending",
|
||||
DiagnosticSeverity::Info,
|
||||
@@ -774,7 +854,6 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
||||
state: WorkerOperationState::Unsupported,
|
||||
runtime_id: self.runtime_id().to_string(),
|
||||
worker_id: worker_id.to_string(),
|
||||
event_id: None,
|
||||
diagnostics: vec![diagnostic(
|
||||
"worker_input_pending",
|
||||
DiagnosticSeverity::Info,
|
||||
@@ -1045,6 +1124,18 @@ impl RuntimeRegistry {
|
||||
Ok(runtime.restore_worker(worker_id))
|
||||
}
|
||||
|
||||
pub fn replace_worker_workspace_api(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
worker_id: &str,
|
||||
workspace_api: WorkspaceApiRef,
|
||||
) -> Result<WorkerWorkspaceApiResult, RuntimeRegistryError> {
|
||||
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||
validate_backend_identifier("worker_id", worker_id)?;
|
||||
let runtime = self.runtime(runtime_id)?;
|
||||
Ok(runtime.replace_worker_workspace_api(worker_id, workspace_api))
|
||||
}
|
||||
|
||||
pub fn spawn_worker(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
@@ -1285,8 +1376,6 @@ impl RuntimeRegistry {
|
||||
pub struct EmbeddedWorkerRuntime {
|
||||
runtime_id: String,
|
||||
host_id: String,
|
||||
workspace_id: String,
|
||||
backend_base_url: Option<String>,
|
||||
runtime: worker_runtime::Runtime,
|
||||
execution_enabled: bool,
|
||||
resource_broker: BackendResourceBroker,
|
||||
@@ -1325,7 +1414,6 @@ impl EmbeddedWorkerRuntime {
|
||||
FsRuntimeStoreOptions {
|
||||
root: store_root.into(),
|
||||
display_name: Some("embedded".to_string()),
|
||||
limits: EmbeddedRuntimeOptions::default().limits,
|
||||
},
|
||||
backend,
|
||||
)?;
|
||||
@@ -1339,9 +1427,8 @@ impl EmbeddedWorkerRuntime {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_backend_base_url(mut self, backend_base_url: impl Into<String>) -> Self {
|
||||
self.backend_base_url = Some(backend_base_url.into().trim_end_matches('/').to_string());
|
||||
self
|
||||
pub(crate) fn subscription_runtime(&self) -> worker_runtime::Runtime {
|
||||
self.runtime.clone()
|
||||
}
|
||||
|
||||
pub fn from_runtime(workspace_id: impl AsRef<str>, runtime: worker_runtime::Runtime) -> Self {
|
||||
@@ -1349,8 +1436,6 @@ impl EmbeddedWorkerRuntime {
|
||||
Self {
|
||||
runtime_id: EMBEDDED_RUNTIME_ID.to_string(),
|
||||
host_id: host_id_for_embedded_workspace(&workspace_id),
|
||||
workspace_id,
|
||||
backend_base_url: None,
|
||||
runtime,
|
||||
execution_enabled: false,
|
||||
resource_broker: BackendResourceBroker::default(),
|
||||
@@ -1599,6 +1684,39 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_worker_workspace_api(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
workspace_api: WorkspaceApiRef,
|
||||
) -> WorkerWorkspaceApiResult {
|
||||
let Some(worker_ref) = self.worker_ref(worker_id) else {
|
||||
return WorkerWorkspaceApiResult {
|
||||
state: WorkerOperationState::Rejected,
|
||||
worker: None,
|
||||
diagnostics: vec![diagnostic(
|
||||
"embedded_worker_id_invalid",
|
||||
DiagnosticSeverity::Warning,
|
||||
"Worker id was empty and cannot receive Workspace access".to_string(),
|
||||
)],
|
||||
};
|
||||
};
|
||||
match self
|
||||
.runtime
|
||||
.replace_worker_workspace_api(&worker_ref, workspace_api)
|
||||
{
|
||||
Ok(detail) => WorkerWorkspaceApiResult {
|
||||
state: WorkerOperationState::Accepted,
|
||||
worker: Some(self.map_worker_detail(detail)),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
Err(err) => WorkerWorkspaceApiResult {
|
||||
state: WorkerOperationState::Rejected,
|
||||
worker: None,
|
||||
diagnostics: vec![embedded_runtime_diagnostic(&err)],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn create_working_directory(
|
||||
&self,
|
||||
_request: WorkingDirectoryRequest,
|
||||
@@ -1695,7 +1813,26 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
};
|
||||
}
|
||||
};
|
||||
let (idempotency_key, idempotency_fingerprint) = worker_spawn_idempotency(&request)
|
||||
.expect("WorkerSpawnRequest serialization is infallible")
|
||||
.map_or((None, None), |(key, fingerprint)| {
|
||||
(Some(key), Some(fingerprint))
|
||||
});
|
||||
let workspace_api = match required_worker_workspace_api(&request) {
|
||||
Ok(workspace_api) => workspace_api,
|
||||
Err(diagnostic) => {
|
||||
diagnostics.push(diagnostic);
|
||||
return WorkerSpawnResult {
|
||||
state: WorkerOperationState::Rejected,
|
||||
worker: None,
|
||||
acceptance_evidence: Vec::new(),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
};
|
||||
let create_request = CreateWorkerRequest {
|
||||
idempotency_key,
|
||||
idempotency_fingerprint,
|
||||
profile,
|
||||
display_name: request.requested_worker_name.clone(),
|
||||
config_bundle: None,
|
||||
@@ -1703,13 +1840,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
initial_input: request.initial_input.clone(),
|
||||
working_directory_request: request.resolved_working_directory_request.clone(),
|
||||
working_directory: request.resolved_working_directory.clone(),
|
||||
workspace_api: self
|
||||
.backend_base_url
|
||||
.as_ref()
|
||||
.map(|base_url| WorkspaceApiRef {
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
base_url: base_url.clone(),
|
||||
}),
|
||||
workspace_api: Some(workspace_api),
|
||||
};
|
||||
match self.runtime.create_worker(create_request) {
|
||||
Ok(detail) => WorkerSpawnResult {
|
||||
@@ -1813,11 +1944,10 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
);
|
||||
};
|
||||
match self.runtime.stop_worker(&worker_ref, request.reason) {
|
||||
Ok(ack) => WorkerLifecycleResult {
|
||||
Ok(_) => WorkerLifecycleResult {
|
||||
state: WorkerOperationState::Accepted,
|
||||
runtime_id: self.runtime_id.clone(),
|
||||
worker_id: worker_id.to_string(),
|
||||
event_id: Some(ack.event_id),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
Err(error) => embedded_lifecycle_rejected(
|
||||
@@ -1858,11 +1988,10 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
);
|
||||
};
|
||||
match self.runtime.cancel_worker(&worker_ref, request.reason) {
|
||||
Ok(ack) => WorkerLifecycleResult {
|
||||
Ok(_) => WorkerLifecycleResult {
|
||||
state: WorkerOperationState::Accepted,
|
||||
runtime_id: self.runtime_id.clone(),
|
||||
worker_id: worker_id.to_string(),
|
||||
event_id: Some(ack.event_id),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
Err(error) => embedded_lifecycle_rejected(
|
||||
@@ -1989,11 +2118,10 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
||||
segments: request.segments,
|
||||
};
|
||||
match self.runtime.send_input(&worker_ref, input) {
|
||||
Ok(ack) => WorkerInputResult {
|
||||
Ok(_) => WorkerInputResult {
|
||||
state: WorkerOperationState::Accepted,
|
||||
runtime_id: self.runtime_id.clone(),
|
||||
worker_id: worker_id.to_string(),
|
||||
event_id: Some(ack.event_id),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
Err(error) => embedded_input_rejected(
|
||||
@@ -2141,6 +2269,52 @@ impl RemoteRuntimeConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RemoteWorkdirAuthorization {
|
||||
runtime_id: String,
|
||||
workspace_id: String,
|
||||
auth: Option<RemoteRuntimeAuthConfig>,
|
||||
fallback_bearer_token: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RemoteWorkdirAuthorization {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RemoteWorkdirAuthorization")
|
||||
.field("runtime_id", &self.runtime_id)
|
||||
.field("workspace_id", &self.workspace_id)
|
||||
.field("auth", &self.auth.as_ref().map(|_| "capability_token"))
|
||||
.field(
|
||||
"fallback_bearer_token",
|
||||
&self.fallback_bearer_token.as_ref().map(|_| "configured"),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkdirHttpAuthorization for RemoteWorkdirAuthorization {
|
||||
fn bearer_token(&self) -> Result<String, WorkdirError> {
|
||||
if let Some(auth) = self.auth.as_ref() {
|
||||
let claims = capability_claims(
|
||||
&auth.server_id,
|
||||
&self.runtime_id,
|
||||
&self.workspace_id,
|
||||
all_remote_runtime_permissions(),
|
||||
300,
|
||||
)
|
||||
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
|
||||
return CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key)
|
||||
.sign(&claims)
|
||||
.map_err(|error| WorkdirError::Unavailable(error.to_string()));
|
||||
}
|
||||
self.fallback_bearer_token.clone().ok_or_else(|| {
|
||||
WorkdirError::Unavailable(
|
||||
"remote Runtime does not have bearer authorization configured".to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RemoteWorkerRuntime {
|
||||
runtime_id: String,
|
||||
@@ -2155,6 +2329,7 @@ pub struct RemoteWorkerRuntime {
|
||||
host_id: String,
|
||||
resource_broker: BackendResourceBroker,
|
||||
http: BlockingHttpClient,
|
||||
async_http: AsyncHttpClient,
|
||||
}
|
||||
|
||||
fn all_remote_runtime_permissions() -> Vec<String> {
|
||||
@@ -2166,6 +2341,7 @@ fn all_remote_runtime_permissions() -> Vec<String> {
|
||||
"workers:input",
|
||||
"workers:stop",
|
||||
"workers:protocol",
|
||||
"workdirs:operate",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
@@ -2188,6 +2364,17 @@ impl RemoteWorkerRuntime {
|
||||
code: "remote_runtime_client_build_failed".to_string(),
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
// Workdir command-output waits are bounded to 20 seconds by Runtime;
|
||||
// leave transport margin while retaining a finite client timeout.
|
||||
let workdir_timeout = timeout.max(Duration::from_secs(30));
|
||||
let async_http = AsyncHttpClient::builder()
|
||||
.timeout(workdir_timeout)
|
||||
.build()
|
||||
.map_err(|err| RuntimeRegistryError::RuntimeOperationFailed {
|
||||
runtime_id: config.runtime_id.clone(),
|
||||
code: "remote_runtime_async_client_build_failed".to_string(),
|
||||
message: err.to_string(),
|
||||
})?;
|
||||
Ok(Self {
|
||||
host_id: host_id_for_remote_runtime(&config.runtime_id),
|
||||
runtime_id: config.runtime_id,
|
||||
@@ -2201,6 +2388,7 @@ impl RemoteWorkerRuntime {
|
||||
cached_status: config.cached_status,
|
||||
resource_broker: BackendResourceBroker::default(),
|
||||
http,
|
||||
async_http,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2209,6 +2397,33 @@ impl RemoteWorkerRuntime {
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn open_workdir_session(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
owner_worker_id: Option<&str>,
|
||||
) -> Result<RemoteWorkdirSession, WorkdirError> {
|
||||
let base_url = Url::parse(&self.base_url)
|
||||
.map_err(|error| WorkdirError::InvalidArgument(error.to_string()))?;
|
||||
let workdir_id = Workdir::new(working_directory_id).id().clone();
|
||||
let authorization: Arc<dyn WorkdirHttpAuthorization> =
|
||||
Arc::new(RemoteWorkdirAuthorization {
|
||||
runtime_id: self.runtime_id.clone(),
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
auth: self.auth.clone(),
|
||||
fallback_bearer_token: self.bearer_token.clone(),
|
||||
});
|
||||
RemoteWorkdirSession::open_with_authorization(
|
||||
self.async_http.clone(),
|
||||
base_url,
|
||||
authorization,
|
||||
workdir_id,
|
||||
OpenWorkdirSessionRequest {
|
||||
owner_worker_id: owner_worker_id.map(str::to_string),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn endpoint(&self, path: &str) -> String {
|
||||
format!("{}{}", self.base_url, path)
|
||||
}
|
||||
@@ -2414,7 +2629,6 @@ impl RemoteWorkerRuntime {
|
||||
state: WorkerOperationState::Accepted,
|
||||
runtime_id: self.runtime_id.clone(),
|
||||
worker_id: worker_id.to_string(),
|
||||
event_id: Some(response.ack.event_id),
|
||||
diagnostics: vec![diagnostic(
|
||||
"remote_runtime_lifecycle_accepted",
|
||||
DiagnosticSeverity::Info,
|
||||
@@ -2565,6 +2779,28 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_worker_workspace_api(
|
||||
&self,
|
||||
worker_id: &str,
|
||||
workspace_api: WorkspaceApiRef,
|
||||
) -> WorkerWorkspaceApiResult {
|
||||
match self.post_json::<_, RuntimeHttpWorkerResponse>(
|
||||
&format!("/v1/workers/{worker_id}/workspace-api"),
|
||||
&RuntimeHttpWorkerWorkspaceApiRequest { workspace_api },
|
||||
) {
|
||||
Ok(response) => WorkerWorkspaceApiResult {
|
||||
state: WorkerOperationState::Accepted,
|
||||
worker: Some(self.map_worker_detail(response.worker)),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
Err(diagnostic) => WorkerWorkspaceApiResult {
|
||||
state: WorkerOperationState::Rejected,
|
||||
worker: None,
|
||||
diagnostics: vec![diagnostic],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn create_working_directory(
|
||||
&self,
|
||||
request: WorkingDirectoryRequest,
|
||||
@@ -2610,6 +2846,24 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_workdir_session<'a>(
|
||||
&'a self,
|
||||
working_directory_id: &'a str,
|
||||
owner_worker_id: Option<&'a str>,
|
||||
) -> Pin<
|
||||
Box<dyn Future<Output = Result<workdir::WorkdirSessionHandle, WorkdirError>> + Send + 'a>,
|
||||
> {
|
||||
Box::pin(async move {
|
||||
let session = RemoteWorkerRuntime::open_workdir_session(
|
||||
self,
|
||||
working_directory_id,
|
||||
owner_worker_id,
|
||||
)
|
||||
.await?;
|
||||
Ok(Arc::new(session) as workdir::WorkdirSessionHandle)
|
||||
})
|
||||
}
|
||||
|
||||
fn cleanup_working_directory(
|
||||
&self,
|
||||
working_directory_id: &str,
|
||||
@@ -2669,7 +2923,25 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
};
|
||||
}
|
||||
};
|
||||
let (idempotency_key, idempotency_fingerprint) = worker_spawn_idempotency(&request)
|
||||
.expect("WorkerSpawnRequest serialization is infallible")
|
||||
.map_or((None, None), |(key, fingerprint)| {
|
||||
(Some(key), Some(fingerprint))
|
||||
});
|
||||
let workspace_api = match required_worker_workspace_api(&request) {
|
||||
Ok(workspace_api) => workspace_api,
|
||||
Err(diagnostic) => {
|
||||
return WorkerSpawnResult {
|
||||
state: WorkerOperationState::Rejected,
|
||||
worker: None,
|
||||
acceptance_evidence: Vec::new(),
|
||||
diagnostics: vec![diagnostic],
|
||||
};
|
||||
}
|
||||
};
|
||||
let create = CreateWorkerRequest {
|
||||
idempotency_key,
|
||||
idempotency_fingerprint,
|
||||
profile,
|
||||
display_name: request.requested_worker_name.clone(),
|
||||
config_bundle: None,
|
||||
@@ -2677,10 +2949,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
initial_input: request.initial_input.clone(),
|
||||
working_directory_request: request.resolved_working_directory_request.clone(),
|
||||
working_directory: request.resolved_working_directory.clone(),
|
||||
workspace_api: Some(WorkspaceApiRef {
|
||||
workspace_id: self.workspace_id.clone(),
|
||||
base_url: self.backend_base_url.clone(),
|
||||
}),
|
||||
workspace_api: Some(workspace_api),
|
||||
};
|
||||
match self.post_json::<_, RuntimeHttpWorkerResponse>("/v1/workers", &create) {
|
||||
Ok(response) => WorkerSpawnResult {
|
||||
@@ -2823,11 +3092,10 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
||||
&format!("/v1/workers/{worker_id}/input"),
|
||||
&input,
|
||||
) {
|
||||
Ok(response) => WorkerInputResult {
|
||||
Ok(_) => WorkerInputResult {
|
||||
state: WorkerOperationState::Accepted,
|
||||
runtime_id: self.runtime_id.clone(),
|
||||
worker_id: worker_id.to_string(),
|
||||
event_id: Some(response.ack.event_id),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
Err(diagnostic) => remote_input_rejected(&self.runtime_id, worker_id, diagnostic),
|
||||
@@ -3121,8 +3389,11 @@ fn embedded_profile_path(profile: &ProfileSelector) -> Result<String, String> {
|
||||
fn embedded_profile_label(profile: &ProfileSelector) -> Option<String> {
|
||||
Some(match profile {
|
||||
ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => {
|
||||
if name.strip_prefix("builtin:").unwrap_or(name) == MEMORY_CONSOLIDATION_PROFILE {
|
||||
let builtin_name = name.strip_prefix("builtin:").unwrap_or(name);
|
||||
if builtin_name == MEMORY_CONSOLIDATION_PROFILE {
|
||||
MEMORY_CONSOLIDATION_PROFILE.to_string()
|
||||
} else if builtin_name == WORKSPACE_ORCHESTRATOR_PROFILE {
|
||||
WORKSPACE_ORCHESTRATOR_PROFILE.to_string()
|
||||
} else {
|
||||
safe_display_hint(name)
|
||||
}
|
||||
@@ -3132,6 +3403,8 @@ fn embedded_profile_label(profile: &ProfileSelector) -> Option<String> {
|
||||
|
||||
const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation";
|
||||
const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation";
|
||||
const WORKSPACE_ORCHESTRATOR_PROFILE: &str = "orchestrator";
|
||||
pub(crate) const WORKSPACE_ORCHESTRATOR_SINGLETON_KEY: &str = "workspace-orchestrator";
|
||||
|
||||
struct WorkerDisplayMetadata {
|
||||
display_name: String,
|
||||
@@ -3160,6 +3433,20 @@ fn worker_display_metadata(
|
||||
tags,
|
||||
};
|
||||
}
|
||||
if profile_label == Some(WORKSPACE_ORCHESTRATOR_PROFILE) {
|
||||
let mut tags = vec!["orchestrator".to_string(), "singleton".to_string()];
|
||||
if internal {
|
||||
tags.insert(0, "internal".to_string());
|
||||
}
|
||||
return WorkerDisplayMetadata {
|
||||
display_name: requested_display_name
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(safe_display_hint)
|
||||
.unwrap_or_else(|| "Workspace Orchestrator".to_string()),
|
||||
singleton_key: Some(WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.to_string()),
|
||||
tags,
|
||||
};
|
||||
}
|
||||
let display_name = requested_display_name
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(safe_display_hint)
|
||||
@@ -3203,7 +3490,6 @@ fn embedded_input_rejected(
|
||||
state: WorkerOperationState::Rejected,
|
||||
runtime_id: runtime_id.to_string(),
|
||||
worker_id: worker_id.to_string(),
|
||||
event_id: None,
|
||||
diagnostics: vec![diagnostic],
|
||||
}
|
||||
}
|
||||
@@ -3217,7 +3503,6 @@ fn remote_input_rejected(
|
||||
state: WorkerOperationState::Rejected,
|
||||
runtime_id: runtime_id.to_string(),
|
||||
worker_id: worker_id.to_string(),
|
||||
event_id: None,
|
||||
diagnostics: vec![diagnostic],
|
||||
}
|
||||
}
|
||||
@@ -3231,7 +3516,6 @@ fn embedded_lifecycle_rejected(
|
||||
state: WorkerOperationState::Rejected,
|
||||
runtime_id: runtime_id.to_string(),
|
||||
worker_id: worker_id.to_string(),
|
||||
event_id: None,
|
||||
diagnostics: vec![diagnostic],
|
||||
}
|
||||
}
|
||||
@@ -3245,7 +3529,6 @@ fn remote_lifecycle_rejected(
|
||||
state: WorkerOperationState::Rejected,
|
||||
runtime_id: runtime_id.to_string(),
|
||||
worker_id: worker_id.to_string(),
|
||||
event_id: None,
|
||||
diagnostics: vec![diagnostic],
|
||||
}
|
||||
}
|
||||
@@ -3633,6 +3916,14 @@ mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
|
||||
fn test_workspace_api() -> WorkspaceApiRef {
|
||||
WorkspaceApiRef {
|
||||
workspace_id: "workspace-test".to_string(),
|
||||
base_url: "http://127.0.0.1:8787".to_string(),
|
||||
runtime_id: Some("runtime-test".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_builtin_decodal_profiles_resolve_through_archive() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
@@ -4149,14 +4440,37 @@ mod tests {
|
||||
expected_segments: 0,
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_input: None,
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
resolved_config_bundle: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_runtime_rejects_missing_workspace_api_binding() {
|
||||
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
||||
"local:test",
|
||||
Arc::new(AcceptingExecutionBackend::default()),
|
||||
)
|
||||
.expect("test backend should connect");
|
||||
let mut request = embedded_spawn_request();
|
||||
request.resolved_workspace_api = None;
|
||||
|
||||
let spawned = runtime.spawn_worker(request);
|
||||
|
||||
assert_eq!(spawned.state, WorkerOperationState::Rejected);
|
||||
assert!(
|
||||
spawned
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| { diagnostic.code == "worker_workspace_api_missing" })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_runtime_spawn_execution_failure_is_rejected_and_not_input_capable() {
|
||||
let runtime = EmbeddedWorkerRuntime::new_memory_with_execution_backend(
|
||||
@@ -4275,11 +4589,13 @@ mod tests {
|
||||
expected_segments: 0,
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_input: None,
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
resolved_config_bundle: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -4371,11 +4687,13 @@ mod tests {
|
||||
expected_segments: 0,
|
||||
},
|
||||
profile: ProfileSelector::Builtin("builtin:coder".to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_input: None,
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
resolved_config_bundle: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -4403,11 +4721,13 @@ mod tests {
|
||||
requested_worker_name: None,
|
||||
acceptance: WorkerSpawnAcceptanceRequirement::SocketReady,
|
||||
profile: ProfileSelector::Builtin("builtin:companion".to_string()),
|
||||
ticket_assignment: None,
|
||||
initial_input: None,
|
||||
working_directory_request: None,
|
||||
resolved_working_directory_request: None,
|
||||
resolved_working_directory: None,
|
||||
resolved_config_bundle: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -4464,8 +4784,7 @@ mod tests {
|
||||
json!({
|
||||
"ack": {
|
||||
"worker_ref": { "runtime_id": "remote:primary", "worker_id": 1 },
|
||||
"status": "running",
|
||||
"event_id": 8
|
||||
"status": "running"
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
@@ -4524,7 +4843,6 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(input.state, WorkerOperationState::Accepted);
|
||||
assert_eq!(input.event_id, Some(8));
|
||||
|
||||
server.join().expect("mock remote server finished");
|
||||
let browser_payload = serde_json::to_string(&(workers, input)).unwrap();
|
||||
@@ -4674,6 +4992,79 @@ mod tests {
|
||||
server.join().expect("mock remote server finished");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn remote_workdir_session_uses_authenticated_http_operations_and_closes() {
|
||||
use workdir::{EntryKind, StatRequest, StatResult, WorkdirPath};
|
||||
|
||||
let opened = workdir::http::OpenWorkdirSessionResponse {
|
||||
session_id: workdir::http::WorkdirSessionId::new("session-1").unwrap(),
|
||||
workdir_id: Workdir::new("wd-1").id().clone(),
|
||||
capabilities: workdir::WorkdirSessionCapabilities::ALL,
|
||||
};
|
||||
let stat = workdir::http::WorkdirSessionOperationResult::Stat(StatResult {
|
||||
path: WorkdirPath::new("hello.txt").unwrap(),
|
||||
kind: EntryKind::File,
|
||||
size: 5,
|
||||
});
|
||||
let (base_url, server) = serve_mock_http(vec![
|
||||
mock_response(
|
||||
"POST",
|
||||
"/v1/working-directories/wd-1/sessions",
|
||||
true,
|
||||
200,
|
||||
serde_json::to_string(&opened).unwrap(),
|
||||
),
|
||||
mock_response(
|
||||
"POST",
|
||||
"/v1/workdir-sessions/session-1/operations",
|
||||
true,
|
||||
200,
|
||||
serde_json::to_string(&stat).unwrap(),
|
||||
),
|
||||
mock_response(
|
||||
"DELETE",
|
||||
"/v1/workdir-sessions/session-1",
|
||||
true,
|
||||
204,
|
||||
String::new(),
|
||||
),
|
||||
]);
|
||||
let runtime = RemoteWorkerRuntime::new(
|
||||
RemoteRuntimeConfig::new(
|
||||
"runtime-a",
|
||||
"Runtime A",
|
||||
base_url,
|
||||
Some("secret-token".to_string()),
|
||||
),
|
||||
"workspace-a".to_string(),
|
||||
"http://backend.invalid".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let runtime: Arc<dyn WorkspaceWorkerRuntime> = Arc::new(runtime);
|
||||
let session = runtime
|
||||
.open_workdir_session("wd-1", Some("1"))
|
||||
.await
|
||||
.expect("open remote Workdir session");
|
||||
let result = session
|
||||
.stat(StatRequest {
|
||||
path: WorkdirPath::new("hello.txt").unwrap(),
|
||||
})
|
||||
.await
|
||||
.expect("remote stat");
|
||||
assert_eq!(result.size, 5);
|
||||
session.close().await.expect("close remote session");
|
||||
session.close().await.expect("idempotent close");
|
||||
let error = session
|
||||
.stat(StatRequest {
|
||||
path: WorkdirPath::new("hello.txt").unwrap(),
|
||||
})
|
||||
.await
|
||||
.expect_err("closed session must reject local operation without another request");
|
||||
assert!(matches!(error, WorkdirError::Unavailable(_)));
|
||||
server.join().expect("mock remote server finished");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_runtime_auth_errors_map_to_typed_backend_error() {
|
||||
let (base_url, server) = serve_mock_http(vec![mock_response(
|
||||
@@ -4798,8 +5189,7 @@ mod tests {
|
||||
"size_bytes": 0,
|
||||
"source_graph": { "source_count": 0, "total_source_bytes": 0, "entrypoints": {}, "import_count": 0 }
|
||||
},
|
||||
"config_bundle": { "id": "remote-bundle", "digest": "remote-digest" },
|
||||
"last_event_id": 0
|
||||
"config_bundle": { "id": "remote-bundle", "digest": "remote-digest" }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,9 +19,11 @@ pub mod records;
|
||||
pub use records::ticket_api_typescript;
|
||||
pub mod repositories;
|
||||
pub mod resource_broker;
|
||||
pub mod runtime_subscription;
|
||||
pub mod server;
|
||||
pub mod skills;
|
||||
pub mod store;
|
||||
mod workspace_subscription;
|
||||
|
||||
pub use authority::{
|
||||
MemoryAuthority, MemoryDocument, MemoryStagingEntry, MemoryStagingResolution,
|
||||
@@ -85,6 +87,10 @@ pub enum Error {
|
||||
UnknownRepository(String),
|
||||
#[error("workspace id does not match this Workspace backend")]
|
||||
WorkspaceIdMismatch,
|
||||
#[error("Ticket assignment conflict: {0}")]
|
||||
TicketAssignmentConflict(String),
|
||||
#[error("Worker source identity is invalid: {0}")]
|
||||
WorkerSourceIdentity(String),
|
||||
#[error("workspace identity error: {0}")]
|
||||
WorkspaceIdentity(String),
|
||||
#[error("store error: {0}")]
|
||||
|
||||
@@ -0,0 +1,941 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use protocol::subscription::{
|
||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||
SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot, SubscriptionTerminationCode,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
|
||||
|
||||
use crate::hosts::RemoteRuntimeConfig;
|
||||
|
||||
const DOWNSTREAM_QUEUE_CAPACITY: usize = 256;
|
||||
const RECONNECT_DELAY: Duration = Duration::from_millis(100);
|
||||
|
||||
type RuntimeSocket =
|
||||
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RuntimeSubscriptionBrokerError {
|
||||
#[error("unknown Runtime {0:?}")]
|
||||
UnknownRuntime(String),
|
||||
#[error("Runtime subscription broker command channel closed")]
|
||||
Closed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BrokerSubscriptionEvent {
|
||||
Snapshot {
|
||||
connection_generation: u64,
|
||||
snapshot_revision: u64,
|
||||
snapshot: SubscriptionSnapshot,
|
||||
},
|
||||
Event {
|
||||
connection_generation: u64,
|
||||
subject_revision: u64,
|
||||
payload: SubscriptionEventPayload,
|
||||
},
|
||||
Disconnected {
|
||||
connection_generation: u64,
|
||||
message: String,
|
||||
},
|
||||
Rejected {
|
||||
connection_generation: u64,
|
||||
code: SubscriptionRejectionCode,
|
||||
message: String,
|
||||
},
|
||||
Closed {
|
||||
connection_generation: u64,
|
||||
code: SubscriptionTerminationCode,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RuntimeSubscriptionBrokerStatus {
|
||||
pub connection_generation: u64,
|
||||
pub connected: bool,
|
||||
pub desired_selectors: usize,
|
||||
pub upstream_subscriptions: usize,
|
||||
}
|
||||
|
||||
pub struct BrokerSubscription {
|
||||
downstream_id: u64,
|
||||
runtime_id: String,
|
||||
selector: EventSubscriptionSelector,
|
||||
receiver: mpsc::Receiver<BrokerSubscriptionEvent>,
|
||||
commands: mpsc::UnboundedSender<Command>,
|
||||
}
|
||||
|
||||
impl BrokerSubscription {
|
||||
pub fn runtime_id(&self) -> &str {
|
||||
&self.runtime_id
|
||||
}
|
||||
pub fn selector(&self) -> &EventSubscriptionSelector {
|
||||
&self.selector
|
||||
}
|
||||
pub async fn recv(&mut self) -> Option<BrokerSubscriptionEvent> {
|
||||
self.receiver.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BrokerSubscription {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.commands.send(Command::Unsubscribe(self.downstream_id));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Registration {
|
||||
generation: u64,
|
||||
commands: mpsc::UnboundedSender<Command>,
|
||||
status: Arc<RwLock<RuntimeSubscriptionBrokerStatus>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RuntimeSubscriptionBroker {
|
||||
workspace_id: Arc<str>,
|
||||
next_generation: Arc<AtomicU64>,
|
||||
next_downstream: Arc<AtomicU64>,
|
||||
registrations: Arc<RwLock<HashMap<String, Registration>>>,
|
||||
}
|
||||
|
||||
impl RuntimeSubscriptionBroker {
|
||||
pub fn new(workspace_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
workspace_id: Arc::from(workspace_id.into()),
|
||||
next_generation: Arc::new(AtomicU64::new(1)),
|
||||
next_downstream: Arc::new(AtomicU64::new(1)),
|
||||
registrations: Arc::new(RwLock::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_remote_runtime(&self, config: RemoteRuntimeConfig) -> u64 {
|
||||
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||
let (commands, receiver) = mpsc::unbounded_channel();
|
||||
let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus {
|
||||
connection_generation: generation,
|
||||
..Default::default()
|
||||
}));
|
||||
let registration = Registration {
|
||||
generation,
|
||||
commands: commands.clone(),
|
||||
status: status.clone(),
|
||||
};
|
||||
let previous = self
|
||||
.registrations
|
||||
.write()
|
||||
.expect("broker registry poisoned")
|
||||
.insert(config.runtime_id.clone(), registration);
|
||||
if let Some(previous) = previous {
|
||||
let _ = previous.commands.send(Command::Shutdown(generation));
|
||||
}
|
||||
tokio::spawn(run_connection(
|
||||
config,
|
||||
self.workspace_id.to_string(),
|
||||
generation,
|
||||
receiver,
|
||||
status,
|
||||
));
|
||||
generation
|
||||
}
|
||||
|
||||
pub fn register_embedded_runtime(
|
||||
&self,
|
||||
runtime_id: impl Into<String>,
|
||||
runtime: worker_runtime::Runtime,
|
||||
) -> u64 {
|
||||
let runtime_id = runtime_id.into();
|
||||
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||
let (commands, receiver) = mpsc::unbounded_channel();
|
||||
let status = Arc::new(RwLock::new(RuntimeSubscriptionBrokerStatus {
|
||||
connection_generation: generation,
|
||||
connected: true,
|
||||
..Default::default()
|
||||
}));
|
||||
let previous = self
|
||||
.registrations
|
||||
.write()
|
||||
.expect("broker registry poisoned")
|
||||
.insert(
|
||||
runtime_id.clone(),
|
||||
Registration {
|
||||
generation,
|
||||
commands: commands.clone(),
|
||||
status: status.clone(),
|
||||
},
|
||||
);
|
||||
if let Some(previous) = previous {
|
||||
let _ = previous.commands.send(Command::Shutdown(generation));
|
||||
}
|
||||
tokio::spawn(run_embedded_connection(
|
||||
runtime_id,
|
||||
runtime,
|
||||
self.workspace_id.to_string(),
|
||||
generation,
|
||||
receiver,
|
||||
status,
|
||||
));
|
||||
generation
|
||||
}
|
||||
|
||||
pub fn unregister_runtime(&self, runtime_id: &str) {
|
||||
if let Some(registration) = self
|
||||
.registrations
|
||||
.write()
|
||||
.expect("broker registry poisoned")
|
||||
.remove(runtime_id)
|
||||
{
|
||||
let _ = registration
|
||||
.commands
|
||||
.send(Command::Shutdown(registration.generation.saturating_add(1)));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn runtime_ids(&self) -> Vec<String> {
|
||||
let mut runtime_ids = self
|
||||
.registrations
|
||||
.read()
|
||||
.expect("broker registry poisoned")
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
runtime_ids.sort();
|
||||
runtime_ids
|
||||
}
|
||||
|
||||
pub fn status(&self, runtime_id: &str) -> Option<RuntimeSubscriptionBrokerStatus> {
|
||||
let status = self
|
||||
.registrations
|
||||
.read()
|
||||
.expect("broker registry poisoned")
|
||||
.get(runtime_id)?
|
||||
.status
|
||||
.clone();
|
||||
Some(status.read().expect("broker status poisoned").clone())
|
||||
}
|
||||
|
||||
pub fn subscribe(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
selector: EventSubscriptionSelector,
|
||||
) -> Result<BrokerSubscription, RuntimeSubscriptionBrokerError> {
|
||||
selector
|
||||
.validate()
|
||||
.map_err(|_| RuntimeSubscriptionBrokerError::Closed)?;
|
||||
let registration = self
|
||||
.registrations
|
||||
.read()
|
||||
.expect("broker registry poisoned")
|
||||
.get(runtime_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
RuntimeSubscriptionBrokerError::UnknownRuntime(runtime_id.to_string())
|
||||
})?;
|
||||
let downstream_id = self.next_downstream.fetch_add(1, Ordering::Relaxed);
|
||||
let (events, receiver) = mpsc::channel(DOWNSTREAM_QUEUE_CAPACITY);
|
||||
let initial_events = events.clone();
|
||||
registration
|
||||
.commands
|
||||
.send(Command::Subscribe {
|
||||
downstream_id,
|
||||
selector: selector.clone(),
|
||||
events,
|
||||
})
|
||||
.map_err(|_| RuntimeSubscriptionBrokerError::Closed)?;
|
||||
let initial_status = registration
|
||||
.status
|
||||
.read()
|
||||
.expect("broker status poisoned")
|
||||
.clone();
|
||||
if !initial_status.connected {
|
||||
let _ = initial_events.try_send(BrokerSubscriptionEvent::Disconnected {
|
||||
connection_generation: initial_status.connection_generation,
|
||||
message: "Runtime subscription connection is not currently available".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(BrokerSubscription {
|
||||
downstream_id,
|
||||
runtime_id: runtime_id.to_string(),
|
||||
selector,
|
||||
receiver,
|
||||
commands: registration.commands,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Command {
|
||||
Subscribe {
|
||||
downstream_id: u64,
|
||||
selector: EventSubscriptionSelector,
|
||||
events: mpsc::Sender<BrokerSubscriptionEvent>,
|
||||
},
|
||||
Unsubscribe(u64),
|
||||
Shutdown(u64),
|
||||
}
|
||||
|
||||
struct SelectorState {
|
||||
downstreams: HashMap<u64, mpsc::Sender<BrokerSubscriptionEvent>>,
|
||||
upstream_id: Option<SubscriptionId>,
|
||||
pending: bool,
|
||||
snapshot: Option<(u64, SubscriptionSnapshot)>,
|
||||
revisions: HashMap<String, u64>,
|
||||
}
|
||||
impl SelectorState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
downstreams: HashMap::new(),
|
||||
upstream_id: None,
|
||||
pending: false,
|
||||
snapshot: None,
|
||||
revisions: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct State {
|
||||
runtime_id: String,
|
||||
generation: u64,
|
||||
next_request: u64,
|
||||
selectors: HashMap<EventSubscriptionSelector, SelectorState>,
|
||||
downstream_index: HashMap<u64, EventSubscriptionSelector>,
|
||||
pending: HashMap<SubscriptionRequestId, EventSubscriptionSelector>,
|
||||
upstream_index: HashMap<SubscriptionId, EventSubscriptionSelector>,
|
||||
}
|
||||
impl State {
|
||||
fn new(runtime_id: String, generation: u64) -> Self {
|
||||
Self {
|
||||
runtime_id,
|
||||
generation,
|
||||
next_request: 1,
|
||||
selectors: HashMap::new(),
|
||||
downstream_index: HashMap::new(),
|
||||
pending: HashMap::new(),
|
||||
upstream_index: HashMap::new(),
|
||||
}
|
||||
}
|
||||
fn request_id(&mut self) -> SubscriptionRequestId {
|
||||
let id = self.next_request;
|
||||
self.next_request = self.next_request.saturating_add(1);
|
||||
SubscriptionRequestId::new(format!("server-{}-{id}", self.generation)).unwrap()
|
||||
}
|
||||
fn disconnected(&mut self, message: String) {
|
||||
self.pending.clear();
|
||||
self.upstream_index.clear();
|
||||
for selector in self.selectors.values_mut() {
|
||||
selector.upstream_id = None;
|
||||
selector.pending = false;
|
||||
selector.snapshot = None;
|
||||
selector.revisions.clear();
|
||||
broadcast(
|
||||
&mut selector.downstreams,
|
||||
BrokerSubscriptionEvent::Disconnected {
|
||||
connection_generation: self.generation,
|
||||
message: message.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EmbeddedEntry {
|
||||
downstreams: HashMap<u64, mpsc::Sender<BrokerSubscriptionEvent>>,
|
||||
snapshot_revision: u64,
|
||||
snapshot: SubscriptionSnapshot,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
async fn run_embedded_connection(
|
||||
runtime_id: String,
|
||||
runtime: worker_runtime::Runtime,
|
||||
_workspace_id: String,
|
||||
generation: u64,
|
||||
mut commands: mpsc::UnboundedReceiver<Command>,
|
||||
status: Arc<RwLock<RuntimeSubscriptionBrokerStatus>>,
|
||||
) {
|
||||
let (updates, mut update_receiver) = mpsc::unbounded_channel();
|
||||
let mut entries = HashMap::<EventSubscriptionSelector, EmbeddedEntry>::new();
|
||||
let mut downstream_index = HashMap::<u64, EventSubscriptionSelector>::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
command = commands.recv() => match command {
|
||||
Some(Command::Subscribe { downstream_id, selector, events }) => {
|
||||
downstream_index.insert(downstream_id, selector.clone());
|
||||
if let Some(entry) = entries.get_mut(&selector) {
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
|
||||
connection_generation: generation,
|
||||
snapshot_revision: entry.snapshot_revision,
|
||||
snapshot: entry.snapshot.clone(),
|
||||
});
|
||||
entry.downstreams.insert(downstream_id, events);
|
||||
} else {
|
||||
match runtime.subscribe_event_selector(selector.clone()) {
|
||||
Ok(mut subscription) => {
|
||||
let snapshot_revision = subscription.snapshot_revision();
|
||||
let snapshot = project_snapshot_runtime(subscription.snapshot().clone(), &runtime_id);
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot { connection_generation: generation, snapshot_revision, snapshot: snapshot.clone() });
|
||||
let sender = updates.clone();
|
||||
let task_selector = selector.clone();
|
||||
let task_runtime_id = runtime_id.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
while let Ok(update) = subscription.recv().await {
|
||||
let payload = project_payload_runtime(update.payload, &task_runtime_id);
|
||||
if sender.send((task_selector.clone(), update.subject_revision, payload)).is_err() { break; }
|
||||
}
|
||||
});
|
||||
entries.insert(selector, EmbeddedEntry { downstreams: HashMap::from([(downstream_id, events)]), snapshot_revision, snapshot, task });
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Rejected { connection_generation: generation, code: SubscriptionRejectionCode::UnsupportedSelector, message: error.to_string() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Command::Unsubscribe(id)) => {
|
||||
if let Some(selector) = downstream_index.remove(&id) {
|
||||
let empty = entries.get_mut(&selector).is_some_and(|entry| { entry.downstreams.remove(&id); entry.downstreams.is_empty() });
|
||||
if empty { if let Some(entry) = entries.remove(&selector) { entry.task.abort(); } }
|
||||
}
|
||||
}
|
||||
Some(Command::Shutdown(replacement)) => {
|
||||
for entry in entries.values_mut() {
|
||||
broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Closed { connection_generation: generation, code: SubscriptionTerminationCode::ServerShutdown, message: format!("embedded Runtime generation {generation} was fenced by {replacement}") });
|
||||
entry.task.abort();
|
||||
}
|
||||
return;
|
||||
}
|
||||
None => return,
|
||||
},
|
||||
update = update_receiver.recv() => {
|
||||
let Some((selector, subject_revision, payload)) = update else { return; };
|
||||
if let Some(entry) = entries.get_mut(&selector) {
|
||||
broadcast(&mut entry.downstreams, BrokerSubscriptionEvent::Event { connection_generation: generation, subject_revision, payload });
|
||||
}
|
||||
}
|
||||
}
|
||||
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
|
||||
connection_generation: generation,
|
||||
connected: true,
|
||||
desired_selectors: entries.len(),
|
||||
upstream_subscriptions: entries.len(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn project_snapshot_runtime(
|
||||
mut snapshot: SubscriptionSnapshot,
|
||||
runtime_id: &str,
|
||||
) -> SubscriptionSnapshot {
|
||||
if let SubscriptionSnapshot::Workers { workers } = &mut snapshot {
|
||||
for worker in workers {
|
||||
worker.runtime_id = Some(runtime_id.to_string());
|
||||
}
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn project_payload_runtime(
|
||||
mut payload: SubscriptionEventPayload,
|
||||
runtime_id: &str,
|
||||
) -> SubscriptionEventPayload {
|
||||
match &mut payload {
|
||||
SubscriptionEventPayload::WorkerUpserted { worker } => {
|
||||
worker.runtime_id = Some(runtime_id.to_string());
|
||||
}
|
||||
SubscriptionEventPayload::WorkerRemoved {
|
||||
runtime_id: projected_runtime_id,
|
||||
..
|
||||
} => {
|
||||
*projected_runtime_id = Some(runtime_id.to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
async fn run_connection(
|
||||
config: RemoteRuntimeConfig,
|
||||
workspace_id: String,
|
||||
generation: u64,
|
||||
mut commands: mpsc::UnboundedReceiver<Command>,
|
||||
status: Arc<RwLock<RuntimeSubscriptionBrokerStatus>>,
|
||||
) {
|
||||
let mut state = State::new(config.runtime_id.clone(), generation);
|
||||
let mut disconnect_notified = false;
|
||||
loop {
|
||||
update_status(&status, &state, false);
|
||||
let connecting = connect_runtime(&config, &workspace_id);
|
||||
tokio::pin!(connecting);
|
||||
let connection = loop {
|
||||
tokio::select! {
|
||||
command = commands.recv() => match command {
|
||||
Some(Command::Shutdown(replacement)) => { close_all(&mut state, replacement); return; }
|
||||
Some(command) => { apply_offline(&mut state, command); update_status(&status, &state, false); }
|
||||
None => return,
|
||||
},
|
||||
connected = &mut connecting => break connected,
|
||||
}
|
||||
};
|
||||
let mut socket = match connection {
|
||||
Ok(socket) => socket,
|
||||
Err(error) => {
|
||||
if !disconnect_notified {
|
||||
state.disconnected(error);
|
||||
disconnect_notified = true;
|
||||
}
|
||||
tokio::time::sleep(RECONNECT_DELAY).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if resubscribe_all(&mut socket, &mut state).await.is_err() {
|
||||
state.disconnected("failed to restore Runtime subscriptions".into());
|
||||
disconnect_notified = true;
|
||||
tokio::time::sleep(RECONNECT_DELAY).await;
|
||||
continue;
|
||||
}
|
||||
update_status(&status, &state, true);
|
||||
let reason = loop {
|
||||
tokio::select! {
|
||||
command = commands.recv() => match command {
|
||||
Some(Command::Shutdown(replacement)) => { let _ = socket.close(None).await; close_all(&mut state, replacement); return; }
|
||||
Some(command) => if apply_online(&mut socket, &mut state, command).await.is_err() { break "failed to apply Runtime subscription command".into(); },
|
||||
None => return,
|
||||
},
|
||||
message = socket.next() => match message {
|
||||
Some(Ok(Message::Text(text))) => match serde_json::from_str::<SubscriptionFrame>(text.as_str()) {
|
||||
Ok(frame) if frame.validate().is_ok() => if handle_frame(&mut socket, &mut state, frame).await.is_err() { break "invalid Runtime subscription transition".into(); },
|
||||
_ => break "Runtime returned an invalid subscription frame".into(),
|
||||
},
|
||||
Some(Ok(Message::Ping(value))) => if socket.send(Message::Pong(value)).await.is_err() { break "Runtime pong failed".into(); },
|
||||
Some(Ok(Message::Pong(_))) => {},
|
||||
Some(Ok(Message::Close(_))) | None => break "Runtime subscription connection closed".into(),
|
||||
Some(Ok(Message::Binary(_) | Message::Frame(_))) => break "Runtime returned a non-text subscription frame".into(),
|
||||
Some(Err(error)) => break format!("Runtime subscription connection failed: {error}"),
|
||||
}
|
||||
}
|
||||
update_status(&status, &state, true);
|
||||
};
|
||||
state.disconnected(reason);
|
||||
disconnect_notified = true;
|
||||
update_status(&status, &state, false);
|
||||
tokio::time::sleep(RECONNECT_DELAY).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_offline(state: &mut State, command: Command) {
|
||||
match command {
|
||||
Command::Subscribe {
|
||||
downstream_id,
|
||||
selector,
|
||||
events,
|
||||
} => {
|
||||
state
|
||||
.downstream_index
|
||||
.insert(downstream_id, selector.clone());
|
||||
state
|
||||
.selectors
|
||||
.entry(selector)
|
||||
.or_insert_with(SelectorState::new)
|
||||
.downstreams
|
||||
.insert(downstream_id, events);
|
||||
}
|
||||
Command::Unsubscribe(id) => remove_downstream(state, id),
|
||||
Command::Shutdown(_) => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_online(
|
||||
socket: &mut RuntimeSocket,
|
||||
state: &mut State,
|
||||
command: Command,
|
||||
) -> Result<(), ()> {
|
||||
match command {
|
||||
Command::Subscribe {
|
||||
downstream_id,
|
||||
selector,
|
||||
events,
|
||||
} => {
|
||||
state
|
||||
.downstream_index
|
||||
.insert(downstream_id, selector.clone());
|
||||
let entry = state
|
||||
.selectors
|
||||
.entry(selector.clone())
|
||||
.or_insert_with(SelectorState::new);
|
||||
if let Some((revision, snapshot)) = &entry.snapshot {
|
||||
let _ = events.try_send(BrokerSubscriptionEvent::Snapshot {
|
||||
connection_generation: state.generation,
|
||||
snapshot_revision: *revision,
|
||||
snapshot: snapshot.clone(),
|
||||
});
|
||||
}
|
||||
entry.downstreams.insert(downstream_id, events);
|
||||
if entry.upstream_id.is_none() && !entry.pending {
|
||||
send_subscribe(socket, state, selector).await?;
|
||||
}
|
||||
}
|
||||
Command::Unsubscribe(id) => {
|
||||
let selector = state.downstream_index.get(&id).cloned();
|
||||
remove_downstream(state, id);
|
||||
if let Some(selector) = selector {
|
||||
maybe_unsubscribe(socket, state, selector).await?;
|
||||
}
|
||||
}
|
||||
Command::Shutdown(_) => unreachable!(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resubscribe_all(socket: &mut RuntimeSocket, state: &mut State) -> Result<(), ()> {
|
||||
let selectors = state
|
||||
.selectors
|
||||
.iter()
|
||||
.filter(|(_, value)| !value.downstreams.is_empty())
|
||||
.map(|(key, _)| key.clone())
|
||||
.collect::<Vec<_>>();
|
||||
for selector in selectors {
|
||||
send_subscribe(socket, state, selector).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_subscribe(
|
||||
socket: &mut RuntimeSocket,
|
||||
state: &mut State,
|
||||
selector: EventSubscriptionSelector,
|
||||
) -> Result<(), ()> {
|
||||
let request_id = state.request_id();
|
||||
send_frame(
|
||||
socket,
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Request(
|
||||
SubscriptionRequest::SubscribeEvents {
|
||||
request_id: request_id.clone(),
|
||||
selector: selector.clone(),
|
||||
},
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
state.pending.insert(request_id, selector.clone());
|
||||
state.selectors.get_mut(&selector).unwrap().pending = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn maybe_unsubscribe(
|
||||
socket: &mut RuntimeSocket,
|
||||
state: &mut State,
|
||||
selector: EventSubscriptionSelector,
|
||||
) -> Result<(), ()> {
|
||||
let Some(entry) = state.selectors.get(&selector) else {
|
||||
return Ok(());
|
||||
};
|
||||
if !entry.downstreams.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(subscription_id) = entry.upstream_id.clone() {
|
||||
let request_id = state.request_id();
|
||||
send_frame(
|
||||
socket,
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Request(
|
||||
SubscriptionRequest::UnsubscribeEvents {
|
||||
request_id,
|
||||
subscription_id: subscription_id.clone(),
|
||||
},
|
||||
)),
|
||||
)
|
||||
.await?;
|
||||
state.upstream_index.remove(&subscription_id);
|
||||
state.selectors.remove(&selector);
|
||||
} else if !entry.pending {
|
||||
state.selectors.remove(&selector);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_frame(
|
||||
socket: &mut RuntimeSocket,
|
||||
state: &mut State,
|
||||
frame: SubscriptionFrame,
|
||||
) -> Result<(), ()> {
|
||||
match frame.payload {
|
||||
SubscriptionFramePayload::Response(SubscriptionResponse::Subscribed {
|
||||
request_id,
|
||||
subscription_id,
|
||||
selector,
|
||||
snapshot_revision,
|
||||
snapshot,
|
||||
}) => {
|
||||
if state.pending.remove(&request_id) != Some(selector.clone()) {
|
||||
return Err(());
|
||||
}
|
||||
let snapshot = project_snapshot_runtime(snapshot, &state.runtime_id);
|
||||
let entry = state.selectors.get_mut(&selector).ok_or(())?;
|
||||
entry.pending = false;
|
||||
entry.upstream_id = Some(subscription_id.clone());
|
||||
entry.snapshot = Some((snapshot_revision, snapshot.clone()));
|
||||
entry.revisions = snapshot_revisions(&snapshot);
|
||||
state
|
||||
.upstream_index
|
||||
.insert(subscription_id, selector.clone());
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Snapshot {
|
||||
connection_generation: state.generation,
|
||||
snapshot_revision,
|
||||
snapshot,
|
||||
},
|
||||
);
|
||||
if entry.downstreams.is_empty() {
|
||||
maybe_unsubscribe(socket, state, selector).await?;
|
||||
}
|
||||
}
|
||||
SubscriptionFramePayload::Response(SubscriptionResponse::Unsubscribed { .. }) => {}
|
||||
SubscriptionFramePayload::Response(SubscriptionResponse::SubscriptionRejected {
|
||||
request_id,
|
||||
code,
|
||||
message,
|
||||
..
|
||||
}) => {
|
||||
if let Some(selector) = state.pending.remove(&request_id) {
|
||||
if let Some(mut entry) = state.selectors.remove(&selector) {
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Rejected {
|
||||
connection_generation: state.generation,
|
||||
code,
|
||||
message,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||
subscription_id,
|
||||
subject_revision,
|
||||
payload,
|
||||
}) => {
|
||||
let selector = state
|
||||
.upstream_index
|
||||
.get(&subscription_id)
|
||||
.cloned()
|
||||
.ok_or(())?;
|
||||
payload.validate_for_selector(&selector).map_err(|_| ())?;
|
||||
let payload = project_payload_runtime(payload, &state.runtime_id);
|
||||
let entry = state.selectors.get_mut(&selector).ok_or(())?;
|
||||
if let Some(subject) = event_subject(&payload) {
|
||||
let revision = entry.revisions.entry(subject).or_insert(0);
|
||||
if subject_revision <= *revision {
|
||||
return Ok(());
|
||||
}
|
||||
*revision = subject_revision;
|
||||
}
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Event {
|
||||
connection_generation: state.generation,
|
||||
subject_revision,
|
||||
payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
SubscriptionFramePayload::Event(SubscriptionEvent::SubscriptionClosed {
|
||||
subscription_id,
|
||||
code,
|
||||
message,
|
||||
}) => {
|
||||
let selector = state.upstream_index.remove(&subscription_id).ok_or(())?;
|
||||
let should_resubscribe = if let Some(entry) = state.selectors.get_mut(&selector) {
|
||||
entry.upstream_id = None;
|
||||
entry.snapshot = None;
|
||||
entry.revisions.clear();
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Closed {
|
||||
connection_generation: state.generation,
|
||||
code,
|
||||
message,
|
||||
},
|
||||
);
|
||||
!entry.downstreams.is_empty()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if should_resubscribe {
|
||||
send_subscribe(socket, state, selector).await?;
|
||||
}
|
||||
}
|
||||
SubscriptionFramePayload::Request(_) | SubscriptionFramePayload::WorkerProtocol(_) => {
|
||||
return Err(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_downstream(state: &mut State, id: u64) {
|
||||
if let Some(selector) = state.downstream_index.remove(&id) {
|
||||
if let Some(entry) = state.selectors.get_mut(&selector) {
|
||||
entry.downstreams.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_all(state: &mut State, replacement: u64) {
|
||||
for entry in state.selectors.values_mut() {
|
||||
broadcast(
|
||||
&mut entry.downstreams,
|
||||
BrokerSubscriptionEvent::Closed {
|
||||
connection_generation: state.generation,
|
||||
code: SubscriptionTerminationCode::ServerShutdown,
|
||||
message: format!(
|
||||
"Runtime connection generation {} was fenced by generation {replacement}",
|
||||
state.generation
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn broadcast(
|
||||
downstreams: &mut HashMap<u64, mpsc::Sender<BrokerSubscriptionEvent>>,
|
||||
event: BrokerSubscriptionEvent,
|
||||
) {
|
||||
let mut closed = HashSet::new();
|
||||
for (id, sender) in downstreams.iter() {
|
||||
if sender.try_send(event.clone()).is_err() {
|
||||
closed.insert(*id);
|
||||
}
|
||||
}
|
||||
downstreams.retain(|id, _| !closed.contains(id));
|
||||
}
|
||||
|
||||
fn snapshot_revisions(snapshot: &SubscriptionSnapshot) -> HashMap<String, u64> {
|
||||
match snapshot {
|
||||
SubscriptionSnapshot::Workers { workers } => workers
|
||||
.iter()
|
||||
.map(|worker| {
|
||||
(
|
||||
format!(
|
||||
"{}:{}",
|
||||
worker.runtime_id.as_deref().unwrap_or_default(),
|
||||
worker.worker_id
|
||||
),
|
||||
worker.subject_revision,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
SubscriptionSnapshot::WorkerProtocol { worker_id, .. } => {
|
||||
HashMap::from([(worker_id.to_string(), 0)])
|
||||
}
|
||||
SubscriptionSnapshot::WorkspaceWorkdirs { .. } => HashMap::new(),
|
||||
}
|
||||
}
|
||||
fn event_subject(payload: &SubscriptionEventPayload) -> Option<String> {
|
||||
Some(match payload {
|
||||
SubscriptionEventPayload::WorkerUpserted { worker } => format!(
|
||||
"{}:{}",
|
||||
worker.runtime_id.as_deref().unwrap_or_default(),
|
||||
worker.worker_id
|
||||
),
|
||||
SubscriptionEventPayload::WorkerRemoved {
|
||||
worker_id,
|
||||
runtime_id,
|
||||
} => format!(
|
||||
"{}:{}",
|
||||
runtime_id.as_deref().unwrap_or_default(),
|
||||
worker_id
|
||||
),
|
||||
SubscriptionEventPayload::WorkerProtocol { worker_id, .. } => worker_id.to_string(),
|
||||
SubscriptionEventPayload::WorkdirUpserted { workdir } => {
|
||||
workdir.working_directory_id.to_string()
|
||||
}
|
||||
SubscriptionEventPayload::WorkdirRemoved {
|
||||
working_directory_id,
|
||||
} => working_directory_id.to_string(),
|
||||
})
|
||||
}
|
||||
async fn send_frame(socket: &mut RuntimeSocket, frame: SubscriptionFrame) -> Result<(), ()> {
|
||||
frame.validate().map_err(|_| ())?;
|
||||
socket
|
||||
.send(Message::Text(
|
||||
serde_json::to_string(&frame).map_err(|_| ())?.into(),
|
||||
))
|
||||
.await
|
||||
.map_err(|_| ())
|
||||
}
|
||||
|
||||
async fn connect_runtime(
|
||||
config: &RemoteRuntimeConfig,
|
||||
workspace_id: &str,
|
||||
) -> Result<RuntimeSocket, String> {
|
||||
let endpoint = runtime_endpoint(&config.base_url);
|
||||
let mut request = endpoint
|
||||
.into_client_request()
|
||||
.map_err(|error| format!("invalid Runtime subscription endpoint: {error}"))?;
|
||||
if let Some(token) = runtime_token(config, workspace_id)? {
|
||||
request.headers_mut().insert(
|
||||
"authorization",
|
||||
format!("Bearer {token}")
|
||||
.parse()
|
||||
.map_err(|error| format!("invalid Runtime authorization header: {error}"))?,
|
||||
);
|
||||
}
|
||||
connect_async(request)
|
||||
.await
|
||||
.map(|(socket, _)| socket)
|
||||
.map_err(|error| format!("failed to connect Runtime subscription endpoint: {error}"))
|
||||
}
|
||||
fn runtime_endpoint(base_url: &str) -> String {
|
||||
let base = base_url.trim_end_matches('/');
|
||||
if let Some(rest) = base.strip_prefix("https://") {
|
||||
format!("wss://{rest}/v1/protocol/ws")
|
||||
} else if let Some(rest) = base.strip_prefix("http://") {
|
||||
format!("ws://{rest}/v1/protocol/ws")
|
||||
} else {
|
||||
format!("{base}/v1/protocol/ws")
|
||||
}
|
||||
}
|
||||
fn runtime_token(
|
||||
config: &RemoteRuntimeConfig,
|
||||
workspace_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let Some(auth) = config.auth.as_ref() else {
|
||||
return Ok(config.bearer_token.clone());
|
||||
};
|
||||
let signer = CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key);
|
||||
let claims = capability_claims(
|
||||
&auth.server_id,
|
||||
&config.runtime_id,
|
||||
workspace_id,
|
||||
vec!["workers:list".into()],
|
||||
300,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
signer
|
||||
.sign(&claims)
|
||||
.map(Some)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
fn update_status(status: &RwLock<RuntimeSubscriptionBrokerStatus>, state: &State, connected: bool) {
|
||||
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
|
||||
connection_generation: state.generation,
|
||||
connected,
|
||||
desired_selectors: state
|
||||
.selectors
|
||||
.values()
|
||||
.filter(|value| !value.downstreams.is_empty())
|
||||
.count(),
|
||||
upstream_subscriptions: state.upstream_index.len(),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "runtime_subscription_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,316 @@
|
||||
use super::*;
|
||||
use protocol::subscription::{SubscriptionWorkerIds, SubscriptionWorkerState};
|
||||
use worker_runtime::Runtime;
|
||||
use worker_runtime::catalog::{
|
||||
CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
|
||||
};
|
||||
use worker_runtime::execution::{
|
||||
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
|
||||
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
||||
};
|
||||
use worker_runtime::profile_archive::{ProfileSourceArchiveRef, ProfileSourceGraphSummary};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestExecutionBackend;
|
||||
|
||||
impl WorkerExecutionBackend for TestExecutionBackend {
|
||||
fn backend_id(&self) -> &str {
|
||||
"runtime-subscription-test"
|
||||
}
|
||||
|
||||
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
|
||||
WorkerExecutionSpawnResult::connected(
|
||||
WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
|
||||
WorkerExecutionRunState::Idle,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn dispatch_input(
|
||||
&self,
|
||||
_handle: &WorkerExecutionHandle,
|
||||
_input: worker_runtime::interaction::WorkerInput,
|
||||
) -> WorkerExecutionResult {
|
||||
WorkerExecutionResult::accepted(
|
||||
WorkerExecutionOperation::Input,
|
||||
WorkerExecutionRunState::Busy,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const TOKEN: &str = "runtime-subscription-test-token";
|
||||
|
||||
fn create_request(name: &str) -> CreateWorkerRequest {
|
||||
CreateWorkerRequest {
|
||||
idempotency_key: None,
|
||||
idempotency_fingerprint: None,
|
||||
profile: ProfileSelector::Builtin("builtin:companion".to_string()),
|
||||
display_name: Some(name.to_string()),
|
||||
config_bundle: None,
|
||||
profile_source: ProfileSourceArchiveSource::Http {
|
||||
location: ProfileSourceArchiveHttpRef {
|
||||
url: "http://127.0.0.1/profiles/test".to_string(),
|
||||
etag: None,
|
||||
archive: ProfileSourceArchiveRef {
|
||||
id: "test-profile-source".to_string(),
|
||||
digest: "test-digest".to_string(),
|
||||
size_bytes: 0,
|
||||
source_graph: ProfileSourceGraphSummary {
|
||||
source_count: 0,
|
||||
total_source_bytes: 0,
|
||||
entrypoints: std::collections::BTreeMap::new(),
|
||||
import_count: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
initial_input: None,
|
||||
working_directory_request: None,
|
||||
working_directory: None,
|
||||
workspace_api: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_runtime_server(
|
||||
listener: tokio::net::TcpListener,
|
||||
runtime: Runtime,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
let router = worker_runtime::http_server::runtime_http_router(runtime, TOKEN.to_string());
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
})
|
||||
}
|
||||
|
||||
async fn fixture() -> (Runtime, RemoteRuntimeConfig, tokio::task::JoinHandle<()>) {
|
||||
let runtime = Runtime::with_execution_backend(
|
||||
worker_runtime::RuntimeOptions::default(),
|
||||
std::sync::Arc::new(TestExecutionBackend),
|
||||
)
|
||||
.unwrap();
|
||||
runtime
|
||||
.create_worker_scoped(
|
||||
&worker_runtime::RuntimeWorkspaceScope::new("local", "local-token"),
|
||||
create_request("fixture"),
|
||||
)
|
||||
.unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let task = start_runtime_server(listener, runtime.clone()).await;
|
||||
let config = RemoteRuntimeConfig::new(
|
||||
"runtime-test",
|
||||
"Runtime test",
|
||||
format!("http://{address}"),
|
||||
Some(TOKEN.to_string()),
|
||||
);
|
||||
(runtime, config, task)
|
||||
}
|
||||
|
||||
async fn next_event(subscription: &mut BrokerSubscription) -> BrokerSubscriptionEvent {
|
||||
tokio::time::timeout(Duration::from_secs(5), subscription.recv())
|
||||
.await
|
||||
.expect("subscription event timed out")
|
||||
.expect("subscription closed")
|
||||
}
|
||||
|
||||
async fn next_snapshot(subscription: &mut BrokerSubscription) -> BrokerSubscriptionEvent {
|
||||
loop {
|
||||
let event = next_event(subscription).await;
|
||||
if matches!(event, BrokerSubscriptionEvent::Snapshot { .. }) {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_status(
|
||||
broker: &RuntimeSubscriptionBroker,
|
||||
runtime_id: &str,
|
||||
predicate: impl Fn(&RuntimeSubscriptionBrokerStatus) -> bool,
|
||||
) -> RuntimeSubscriptionBrokerStatus {
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Some(status) = broker.status(runtime_id) {
|
||||
if predicate(&status) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("broker status timed out")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn equal_downstream_selectors_share_one_upstream_subscription() {
|
||||
let (runtime, config, server) = fixture().await;
|
||||
let worker = runtime.list_workers().unwrap().remove(0);
|
||||
let broker = RuntimeSubscriptionBroker::new("local");
|
||||
broker.register_remote_runtime(config);
|
||||
let selector = EventSubscriptionSelector::WorkerLifecycle {
|
||||
worker_ids: SubscriptionWorkerIds::new([
|
||||
protocol::subscription::SubscriptionWorkerId::new(
|
||||
worker.worker_ref.worker_id.to_string(),
|
||||
)
|
||||
.unwrap(),
|
||||
])
|
||||
.unwrap(),
|
||||
};
|
||||
let mut first = broker.subscribe("runtime-test", selector.clone()).unwrap();
|
||||
let mut second = broker.subscribe("runtime-test", selector).unwrap();
|
||||
assert!(matches!(
|
||||
next_snapshot(&mut first).await,
|
||||
BrokerSubscriptionEvent::Snapshot { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
next_snapshot(&mut second).await,
|
||||
BrokerSubscriptionEvent::Snapshot { .. }
|
||||
));
|
||||
let status = wait_for_status(&broker, "runtime-test", |status| {
|
||||
status.upstream_subscriptions == 1
|
||||
})
|
||||
.await;
|
||||
assert_eq!(status.desired_selectors, 1);
|
||||
|
||||
runtime
|
||||
.observe_worker_event(
|
||||
&worker.worker_ref,
|
||||
protocol::Event::Status {
|
||||
status: protocol::WorkerStatus::Running,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
for subscription in [&mut first, &mut second] {
|
||||
assert!(matches!(
|
||||
next_event(subscription).await,
|
||||
BrokerSubscriptionEvent::Event {
|
||||
payload: SubscriptionEventPayload::WorkerUpserted { ref worker },
|
||||
..
|
||||
} if worker.state == SubscriptionWorkerState::Running
|
||||
));
|
||||
}
|
||||
|
||||
drop(first);
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(
|
||||
broker
|
||||
.status("runtime-test")
|
||||
.unwrap()
|
||||
.upstream_subscriptions,
|
||||
1
|
||||
);
|
||||
drop(second);
|
||||
wait_for_status(&broker, "runtime-test", |status| {
|
||||
status.upstream_subscriptions == 0 && status.desired_selectors == 0
|
||||
})
|
||||
.await;
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacing_runtime_registration_fences_the_old_generation() {
|
||||
let (_runtime, config, server) = fixture().await;
|
||||
let broker = RuntimeSubscriptionBroker::new("local");
|
||||
let first_generation = broker.register_remote_runtime(config.clone());
|
||||
let mut old = broker
|
||||
.subscribe("runtime-test", EventSubscriptionSelector::RuntimeWorkers)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
next_snapshot(&mut old).await,
|
||||
BrokerSubscriptionEvent::Snapshot { .. }
|
||||
));
|
||||
|
||||
let second_generation = broker.register_remote_runtime(config);
|
||||
assert!(second_generation > first_generation);
|
||||
assert!(matches!(
|
||||
next_event(&mut old).await,
|
||||
BrokerSubscriptionEvent::Closed {
|
||||
connection_generation,
|
||||
..
|
||||
} if connection_generation == first_generation
|
||||
));
|
||||
let status = wait_for_status(&broker, "runtime-test", |status| {
|
||||
status.connection_generation == second_generation && status.connected
|
||||
})
|
||||
.await;
|
||||
assert_eq!(status.connection_generation, second_generation);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconnect_resubscribes_and_replaces_state_from_fresh_snapshot() {
|
||||
let (runtime, config, server) = fixture().await;
|
||||
let address = config
|
||||
.base_url
|
||||
.strip_prefix("http://")
|
||||
.unwrap()
|
||||
.parse::<std::net::SocketAddr>()
|
||||
.unwrap();
|
||||
server.abort();
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
let broker = RuntimeSubscriptionBroker::new("local");
|
||||
broker.register_remote_runtime(config);
|
||||
let mut subscription = broker
|
||||
.subscribe("runtime-test", EventSubscriptionSelector::RuntimeWorkers)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
next_event(&mut subscription).await,
|
||||
BrokerSubscriptionEvent::Disconnected { .. }
|
||||
));
|
||||
|
||||
runtime
|
||||
.create_worker_scoped(
|
||||
&worker_runtime::RuntimeWorkspaceScope::new("local", "local-token"),
|
||||
create_request("after-reconnect"),
|
||||
)
|
||||
.unwrap();
|
||||
let listener = tokio::net::TcpListener::bind(address).await.unwrap();
|
||||
let restarted = start_runtime_server(listener, runtime).await;
|
||||
let event = next_snapshot(&mut subscription).await;
|
||||
let BrokerSubscriptionEvent::Snapshot { snapshot, .. } = event else {
|
||||
panic!("expected fresh snapshot after reconnect");
|
||||
};
|
||||
let SubscriptionSnapshot::Workers { workers } = snapshot else {
|
||||
panic!("expected Worker snapshot");
|
||||
};
|
||||
assert_eq!(workers.len(), 2);
|
||||
restarted.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedded_runtime_uses_in_process_subscription_source() {
|
||||
let (runtime, _config, server) = fixture().await;
|
||||
let worker = runtime.list_workers().unwrap().remove(0);
|
||||
let broker = RuntimeSubscriptionBroker::new("local");
|
||||
broker.register_embedded_runtime("embedded-worker-runtime", runtime.clone());
|
||||
assert_eq!(broker.runtime_ids(), vec!["embedded-worker-runtime"]);
|
||||
let mut subscription = broker
|
||||
.subscribe(
|
||||
"embedded-worker-runtime",
|
||||
EventSubscriptionSelector::RuntimeWorkers,
|
||||
)
|
||||
.unwrap();
|
||||
let BrokerSubscriptionEvent::Snapshot { snapshot, .. } = next_snapshot(&mut subscription).await
|
||||
else {
|
||||
panic!("expected embedded snapshot");
|
||||
};
|
||||
let SubscriptionSnapshot::Workers { workers } = snapshot else {
|
||||
panic!("expected Worker snapshot");
|
||||
};
|
||||
assert_eq!(
|
||||
workers[0].runtime_id.as_deref(),
|
||||
Some("embedded-worker-runtime")
|
||||
);
|
||||
runtime
|
||||
.observe_worker_event(
|
||||
&worker.worker_ref,
|
||||
protocol::Event::Status {
|
||||
status: protocol::WorkerStatus::Running,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(next_event(&mut subscription).await,
|
||||
BrokerSubscriptionEvent::Event { payload: SubscriptionEventPayload::WorkerUpserted { worker }, .. }
|
||||
if worker.runtime_id.as_deref() == Some("embedded-worker-runtime") && worker.state == SubscriptionWorkerState::Running));
|
||||
server.abort();
|
||||
}
|
||||
+2725
-179
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,558 @@
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use protocol::subscription::{
|
||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||
SubscriptionResponse, SubscriptionSnapshot, SubscriptionTerminationCode, SubscriptionWorker,
|
||||
};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::runtime_subscription::{BrokerSubscriptionEvent, RuntimeSubscriptionBroker};
|
||||
use crate::server::{WorkspaceApi, connect_workspace_worker_protocol};
|
||||
|
||||
const OUTBOUND_CAPACITY: usize = 256;
|
||||
|
||||
struct ActiveSubscription {
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
methods: Option<mpsc::Sender<protocol::Method>>,
|
||||
}
|
||||
|
||||
pub(crate) async fn serve_workspace_subscription(api: WorkspaceApi, socket: WebSocket) {
|
||||
let broker = api.runtime_subscription_broker().clone();
|
||||
let (mut socket_sender, mut socket_receiver) = socket.split();
|
||||
let (control_outbound, mut control_receiver) = mpsc::channel::<WsMessage>(OUTBOUND_CAPACITY);
|
||||
let (protocol_outbound, mut protocol_receiver) = mpsc::channel::<WsMessage>(OUTBOUND_CAPACITY);
|
||||
let writer = tokio::spawn(async move {
|
||||
loop {
|
||||
let message = tokio::select! {
|
||||
biased;
|
||||
message = control_receiver.recv() => message,
|
||||
message = protocol_receiver.recv() => message,
|
||||
};
|
||||
let Some(message) = message else { break };
|
||||
if socket_sender.send(message).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut next_subscription_id = 1_u64;
|
||||
let mut subscriptions = HashMap::<SubscriptionId, ActiveSubscription>::new();
|
||||
|
||||
while let Some(message) = socket_receiver.next().await {
|
||||
let Ok(message) = message else { break };
|
||||
match message {
|
||||
WsMessage::Text(text) => {
|
||||
let Ok(frame) = serde_json::from_str::<SubscriptionFrame>(text.as_str()) else {
|
||||
break;
|
||||
};
|
||||
if frame.validate().is_err() {
|
||||
break;
|
||||
}
|
||||
subscriptions.retain(|_, subscription| !subscription.task.is_finished());
|
||||
match frame.payload {
|
||||
SubscriptionFramePayload::Request(SubscriptionRequest::SubscribeEvents {
|
||||
request_id,
|
||||
selector,
|
||||
}) => {
|
||||
let subscription_id = SubscriptionId::new(format!(
|
||||
"workspace-subscription-{next_subscription_id}"
|
||||
))
|
||||
.expect("generated Workspace subscription id is valid");
|
||||
next_subscription_id = next_subscription_id.saturating_add(1);
|
||||
match selector {
|
||||
EventSubscriptionSelector::WorkspaceWorkers => {
|
||||
let task = tokio::spawn(run_workspace_workers(
|
||||
broker.clone(),
|
||||
request_id,
|
||||
subscription_id.clone(),
|
||||
control_outbound.clone(),
|
||||
));
|
||||
subscriptions.insert(
|
||||
subscription_id,
|
||||
ActiveSubscription {
|
||||
task,
|
||||
methods: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
EventSubscriptionSelector::WorkerProtocol {
|
||||
worker_id,
|
||||
runtime_id: Some(runtime_id),
|
||||
} => {
|
||||
match connect_workspace_worker_protocol(
|
||||
&api,
|
||||
&runtime_id,
|
||||
worker_id.as_str(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(connection) => {
|
||||
let methods = connection.methods.clone();
|
||||
let task = tokio::spawn(run_worker_protocol(
|
||||
request_id,
|
||||
subscription_id.clone(),
|
||||
runtime_id,
|
||||
worker_id,
|
||||
connection.events,
|
||||
control_outbound.clone(),
|
||||
protocol_outbound.clone(),
|
||||
));
|
||||
subscriptions.insert(
|
||||
subscription_id,
|
||||
ActiveSubscription {
|
||||
task,
|
||||
methods: Some(methods),
|
||||
},
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = send_rejected(
|
||||
&control_outbound,
|
||||
request_id,
|
||||
SubscriptionRejectionCode::ResourceNotFound,
|
||||
error.to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = send_rejected(
|
||||
&control_outbound, request_id, SubscriptionRejectionCode::UnsupportedSelector,
|
||||
"Workspace clients may subscribe only to workspace_workers or a runtime-scoped worker_protocol selector".to_string(),
|
||||
).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
SubscriptionFramePayload::Request(SubscriptionRequest::UnsubscribeEvents {
|
||||
request_id,
|
||||
subscription_id,
|
||||
}) => {
|
||||
if let Some(subscription) = subscriptions.remove(&subscription_id) {
|
||||
subscription.task.abort();
|
||||
}
|
||||
if send_frame(
|
||||
&control_outbound,
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Response(
|
||||
SubscriptionResponse::Unsubscribed {
|
||||
request_id,
|
||||
subscription_id,
|
||||
},
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
SubscriptionFramePayload::WorkerProtocol(message) => {
|
||||
let Some(methods) = subscriptions
|
||||
.get(&message.subscription_id)
|
||||
.and_then(|value| value.methods.clone())
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if methods.send(message.method).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
SubscriptionFramePayload::Response(_) | SubscriptionFramePayload::Event(_) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
WsMessage::Ping(value) => {
|
||||
if control_outbound.send(WsMessage::Pong(value)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
WsMessage::Pong(_) => {}
|
||||
WsMessage::Close(_) | WsMessage::Binary(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
for (_, subscription) in subscriptions {
|
||||
subscription.task.abort();
|
||||
}
|
||||
drop(control_outbound);
|
||||
drop(protocol_outbound);
|
||||
let _ = writer.await;
|
||||
}
|
||||
|
||||
async fn send_rejected(
|
||||
outbound: &mpsc::Sender<WsMessage>,
|
||||
request_id: protocol::subscription::SubscriptionRequestId,
|
||||
code: SubscriptionRejectionCode,
|
||||
message: String,
|
||||
) -> Result<(), ()> {
|
||||
send_frame(
|
||||
outbound,
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Response(
|
||||
SubscriptionResponse::SubscriptionRejected {
|
||||
request_id,
|
||||
subscription_id: None,
|
||||
code,
|
||||
message,
|
||||
},
|
||||
)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_worker_protocol(
|
||||
request_id: protocol::subscription::SubscriptionRequestId,
|
||||
subscription_id: SubscriptionId,
|
||||
runtime_id: String,
|
||||
worker_id: protocol::subscription::SubscriptionWorkerId,
|
||||
mut events: mpsc::Receiver<protocol::Event>,
|
||||
control_outbound: mpsc::Sender<WsMessage>,
|
||||
protocol_outbound: mpsc::Sender<WsMessage>,
|
||||
) {
|
||||
if send_frame(
|
||||
&control_outbound,
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Response(
|
||||
SubscriptionResponse::Subscribed {
|
||||
request_id,
|
||||
subscription_id: subscription_id.clone(),
|
||||
selector: EventSubscriptionSelector::WorkerProtocol {
|
||||
worker_id: worker_id.clone(),
|
||||
runtime_id: Some(runtime_id),
|
||||
},
|
||||
snapshot_revision: 0,
|
||||
snapshot: SubscriptionSnapshot::WorkerProtocol {
|
||||
worker_id: worker_id.clone(),
|
||||
events: Vec::new(),
|
||||
},
|
||||
},
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut subject_revision = 0_u64;
|
||||
while let Some(event) = events.recv().await {
|
||||
subject_revision = subject_revision.saturating_add(1);
|
||||
let frame =
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||
subscription_id: subscription_id.clone(),
|
||||
subject_revision,
|
||||
payload: SubscriptionEventPayload::WorkerProtocol {
|
||||
worker_id: worker_id.clone(),
|
||||
event,
|
||||
},
|
||||
}));
|
||||
if try_send_frame(&protocol_outbound, frame).is_err() {
|
||||
let _ = send_frame(
|
||||
&control_outbound,
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(
|
||||
SubscriptionEvent::SubscriptionClosed {
|
||||
subscription_id: subscription_id.clone(),
|
||||
code: SubscriptionTerminationCode::Lagged,
|
||||
message:
|
||||
"Worker protocol subscriber lagged; resubscribe for a fresh snapshot"
|
||||
.to_string(),
|
||||
},
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = send_frame(
|
||||
&control_outbound,
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(
|
||||
SubscriptionEvent::SubscriptionClosed {
|
||||
subscription_id,
|
||||
code: SubscriptionTerminationCode::ResourceGone,
|
||||
message: "Worker protocol stream closed".to_string(),
|
||||
},
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn run_workspace_workers(
|
||||
broker: RuntimeSubscriptionBroker,
|
||||
request_id: protocol::subscription::SubscriptionRequestId,
|
||||
subscription_id: SubscriptionId,
|
||||
outbound: mpsc::Sender<WsMessage>,
|
||||
) {
|
||||
let runtime_ids = broker.runtime_ids();
|
||||
let mut pending = runtime_ids.iter().cloned().collect::<HashSet<_>>();
|
||||
let (events, mut event_receiver) = mpsc::channel(OUTBOUND_CAPACITY);
|
||||
let mut upstreams = tokio::task::JoinSet::new();
|
||||
for runtime_id in runtime_ids {
|
||||
let Ok(mut subscription) =
|
||||
broker.subscribe(&runtime_id, EventSubscriptionSelector::RuntimeWorkers)
|
||||
else {
|
||||
pending.remove(&runtime_id);
|
||||
continue;
|
||||
};
|
||||
let sender = events.clone();
|
||||
upstreams.spawn(async move {
|
||||
while let Some(event) = subscription.recv().await {
|
||||
if sender.send((runtime_id.clone(), event)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
drop(events);
|
||||
|
||||
let mut workers = HashMap::<String, BTreeMap<String, SubscriptionWorker>>::new();
|
||||
while !pending.is_empty() {
|
||||
let Some((runtime_id, event)) = event_receiver.recv().await else {
|
||||
return;
|
||||
};
|
||||
match event {
|
||||
BrokerSubscriptionEvent::Snapshot { snapshot, .. } => {
|
||||
install_snapshot(&mut workers, &runtime_id, snapshot);
|
||||
pending.remove(&runtime_id);
|
||||
}
|
||||
BrokerSubscriptionEvent::Disconnected { .. }
|
||||
| BrokerSubscriptionEvent::Rejected { .. }
|
||||
| BrokerSubscriptionEvent::Closed { .. } => {
|
||||
pending.remove(&runtime_id);
|
||||
}
|
||||
BrokerSubscriptionEvent::Event { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut revisions = HashMap::<String, u64>::new();
|
||||
let mut initial_workers = workers
|
||||
.values_mut()
|
||||
.flat_map(|runtime| runtime.values_mut())
|
||||
.map(|worker| {
|
||||
let key = worker_key(worker.runtime_id.as_deref(), worker.worker_id.as_str());
|
||||
worker.subject_revision = next_revision(&mut revisions, &key);
|
||||
worker.clone()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
sort_workers(&mut initial_workers);
|
||||
if send_frame(
|
||||
&outbound,
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Response(
|
||||
SubscriptionResponse::Subscribed {
|
||||
request_id,
|
||||
subscription_id: subscription_id.clone(),
|
||||
selector: EventSubscriptionSelector::WorkspaceWorkers,
|
||||
snapshot_revision: 1,
|
||||
snapshot: SubscriptionSnapshot::Workers {
|
||||
workers: initial_workers,
|
||||
},
|
||||
},
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while let Some((runtime_id, event)) = event_receiver.recv().await {
|
||||
match event {
|
||||
BrokerSubscriptionEvent::Snapshot { snapshot, .. } => {
|
||||
let removed = workers.remove(&runtime_id).unwrap_or_default();
|
||||
for worker in removed.values() {
|
||||
let key = worker_key(Some(&runtime_id), worker.worker_id.as_str());
|
||||
let revision = next_revision(&mut revisions, &key);
|
||||
if send_event(
|
||||
&outbound,
|
||||
&subscription_id,
|
||||
revision,
|
||||
SubscriptionEventPayload::WorkerRemoved {
|
||||
worker_id: worker.worker_id.clone(),
|
||||
runtime_id: Some(runtime_id.clone()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
install_snapshot(&mut workers, &runtime_id, snapshot);
|
||||
if let Some(current) = workers.get_mut(&runtime_id) {
|
||||
for worker in current.values_mut() {
|
||||
let key = worker_key(Some(&runtime_id), worker.worker_id.as_str());
|
||||
let revision = next_revision(&mut revisions, &key);
|
||||
worker.subject_revision = revision;
|
||||
if send_event(
|
||||
&outbound,
|
||||
&subscription_id,
|
||||
revision,
|
||||
SubscriptionEventPayload::WorkerUpserted {
|
||||
worker: worker.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BrokerSubscriptionEvent::Event { payload, .. } => match payload {
|
||||
SubscriptionEventPayload::WorkerUpserted { mut worker } => {
|
||||
worker.runtime_id = Some(runtime_id.clone());
|
||||
let key = worker_key(Some(&runtime_id), worker.worker_id.as_str());
|
||||
let revision = next_revision(&mut revisions, &key);
|
||||
worker.subject_revision = revision;
|
||||
workers
|
||||
.entry(runtime_id)
|
||||
.or_default()
|
||||
.insert(worker.worker_id.to_string(), worker.clone());
|
||||
if send_event(
|
||||
&outbound,
|
||||
&subscription_id,
|
||||
revision,
|
||||
SubscriptionEventPayload::WorkerUpserted { worker },
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
SubscriptionEventPayload::WorkerRemoved { worker_id, .. } => {
|
||||
workers
|
||||
.entry(runtime_id.clone())
|
||||
.or_default()
|
||||
.remove(worker_id.as_str());
|
||||
let key = worker_key(Some(&runtime_id), worker_id.as_str());
|
||||
let revision = next_revision(&mut revisions, &key);
|
||||
if send_event(
|
||||
&outbound,
|
||||
&subscription_id,
|
||||
revision,
|
||||
SubscriptionEventPayload::WorkerRemoved {
|
||||
worker_id,
|
||||
runtime_id: Some(runtime_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
BrokerSubscriptionEvent::Disconnected { .. } => {}
|
||||
BrokerSubscriptionEvent::Rejected { code, message, .. } => {
|
||||
let _ = send_frame(
|
||||
&outbound,
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(
|
||||
SubscriptionEvent::SubscriptionClosed {
|
||||
subscription_id: subscription_id.clone(),
|
||||
code: rejection_termination(code),
|
||||
message,
|
||||
},
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
BrokerSubscriptionEvent::Closed { code, message, .. } => {
|
||||
let _ = send_frame(
|
||||
&outbound,
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(
|
||||
SubscriptionEvent::SubscriptionClosed {
|
||||
subscription_id: subscription_id.clone(),
|
||||
code,
|
||||
message,
|
||||
},
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn install_snapshot(
|
||||
workers: &mut HashMap<String, BTreeMap<String, SubscriptionWorker>>,
|
||||
runtime_id: &str,
|
||||
snapshot: SubscriptionSnapshot,
|
||||
) {
|
||||
let SubscriptionSnapshot::Workers {
|
||||
workers: snapshot_workers,
|
||||
} = snapshot
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut projected = BTreeMap::new();
|
||||
for mut worker in snapshot_workers {
|
||||
worker.runtime_id = Some(runtime_id.to_string());
|
||||
projected.insert(worker.worker_id.to_string(), worker);
|
||||
}
|
||||
workers.insert(runtime_id.to_string(), projected);
|
||||
}
|
||||
|
||||
async fn send_event(
|
||||
outbound: &mpsc::Sender<WsMessage>,
|
||||
subscription_id: &SubscriptionId,
|
||||
subject_revision: u64,
|
||||
payload: SubscriptionEventPayload,
|
||||
) -> Result<(), ()> {
|
||||
send_frame(
|
||||
outbound,
|
||||
SubscriptionFrame::new(SubscriptionFramePayload::Event(SubscriptionEvent::Event {
|
||||
subscription_id: subscription_id.clone(),
|
||||
subject_revision,
|
||||
payload,
|
||||
})),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn try_send_frame(outbound: &mpsc::Sender<WsMessage>, frame: SubscriptionFrame) -> Result<(), ()> {
|
||||
frame.validate().map_err(|_| ())?;
|
||||
let text = serde_json::to_string(&frame).map_err(|_| ())?;
|
||||
outbound
|
||||
.try_send(WsMessage::Text(text.into()))
|
||||
.map_err(|_| ())
|
||||
}
|
||||
|
||||
async fn send_frame(
|
||||
outbound: &mpsc::Sender<WsMessage>,
|
||||
frame: SubscriptionFrame,
|
||||
) -> Result<(), ()> {
|
||||
frame.validate().map_err(|_| ())?;
|
||||
outbound
|
||||
.send(WsMessage::Text(
|
||||
serde_json::to_string(&frame).map_err(|_| ())?.into(),
|
||||
))
|
||||
.await
|
||||
.map_err(|_| ())
|
||||
}
|
||||
|
||||
fn next_revision(revisions: &mut HashMap<String, u64>, key: &str) -> u64 {
|
||||
let revision = revisions.entry(key.to_string()).or_insert(0);
|
||||
*revision = revision.saturating_add(1);
|
||||
*revision
|
||||
}
|
||||
fn worker_key(runtime_id: Option<&str>, worker_id: &str) -> String {
|
||||
format!("{}:{worker_id}", runtime_id.unwrap_or_default())
|
||||
}
|
||||
fn sort_workers(workers: &mut [SubscriptionWorker]) {
|
||||
workers.sort_by(|left, right| {
|
||||
left.runtime_id
|
||||
.cmp(&right.runtime_id)
|
||||
.then_with(|| left.worker_id.cmp(&right.worker_id))
|
||||
});
|
||||
}
|
||||
fn rejection_termination(code: SubscriptionRejectionCode) -> SubscriptionTerminationCode {
|
||||
match code {
|
||||
SubscriptionRejectionCode::Unauthorized => SubscriptionTerminationCode::Unauthorized,
|
||||
SubscriptionRejectionCode::ResourceNotFound => SubscriptionTerminationCode::ResourceGone,
|
||||
_ => SubscriptionTerminationCode::ServerShutdown,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Restored Worker retained unusable Workspace credential after Backend restart
|
||||
|
||||
## Observed
|
||||
|
||||
After restarting the Runtime and Server, Worker 30 restored and continued executing normal turns, but every typed Ticket operation failed with:
|
||||
|
||||
```text
|
||||
Worker Workspace authentication failed: missing Runtime Workspace credential
|
||||
```
|
||||
|
||||
The Server control-plane DB contained a current active `worker_workspace_credentials` row for the same Workspace, Runtime, and Worker identity, while the restored Worker/tool request did not authenticate with it. The failure prevented the required ticket-first workflow for an auth/storage regression even though the Worker itself remained live.
|
||||
|
||||
## Impact
|
||||
|
||||
- Restore can appear healthy because model turns still execute while Workspace-authority tools are unusable.
|
||||
- A Worker cannot report or ticket the restore regression through the intended typed authority.
|
||||
- The failure is easy to misattribute to the Browser multiplexer; in this incident Browser authentication/bootstrap was a separate issue.
|
||||
|
||||
## Resolution
|
||||
|
||||
The per-Worker bearer credential was removed rather than adding restore-time secret rotation and reinjection. Worker Workspace requests now carry the Runtime/Worker identity binding established by Runtime, and Server verifies that identity against the current Runtime catalog before applying Ticket role/assignment gates.
|
||||
|
||||
The removal also deletes token mint/rotate/revoke/refresh behavior, live Worker token replacement, and the control-plane credential table. Runtime/Server trust remains the security boundary; Worker role checks remain the accidental-misuse gate.
|
||||
@@ -0,0 +1,28 @@
|
||||
# SpawnWorker が current `yoi-runtime` CLI と不整合で起動できない
|
||||
|
||||
Date: 2026-07-30
|
||||
|
||||
## 発生状況
|
||||
|
||||
Workdir selector/ref変更の未コミット差分をreviewer Workerへ委譲するため、`SpawnWorker`をread-only scopeで呼び出した。
|
||||
|
||||
## 結果
|
||||
|
||||
Worker socketが作成されず、child stderrには以下が記録された。
|
||||
|
||||
```text
|
||||
yoi-runtime: unexpected positional argument `worker`
|
||||
|
||||
Usage: yoi-runtime [OPTIONS]
|
||||
```
|
||||
|
||||
現在の`yoi-runtime`はRuntime REST serverの直接起動CLIであり、`worker` positional subcommandを受け付けない。SpawnWorker側のlauncherが旧CLI契約を使っている可能性がある。
|
||||
|
||||
## 影響
|
||||
|
||||
- reviewer/coder Workerをspawnできず、今回の差分は親Worker内で実装・検証した。
|
||||
- scope delegationやreviewer profile以前にprocess起動で失敗するため、Worker orchestration機能が利用できない。
|
||||
|
||||
## 改善案
|
||||
|
||||
SpawnWorker launcherがcurrent Worker/Runtime起動contractを使用しているかを確認し、CLI rename後のdirect executable契約と同期する。失敗時には実際に組み立てたargvと解決したexecutable pathもbounded diagnosticとして返すと、installed binary/worktree binaryの取り違えを判別しやすい。
|
||||
@@ -0,0 +1,24 @@
|
||||
# SpawnWorker rejects a workspace subdirectory inside the parent write boundary
|
||||
|
||||
Date: 2026-07-30
|
||||
|
||||
## Symptom
|
||||
|
||||
While implementing the multiplexer protocol foundation, the parent Worker could write the checkout but could not delegate `crates/protocol` to a spawned Coder:
|
||||
|
||||
```text
|
||||
Invalid argument: requested child scope .../checkout/crates/protocol Write is outside this Worker's delegation scope grant
|
||||
```
|
||||
|
||||
The requested child scope was a recursive subdirectory of the checkout advertised as writable in the parent instructions. No child was created.
|
||||
|
||||
## Impact
|
||||
|
||||
The parent had to implement and review the protocol slice serially. This removed useful parallel review and made the long Runtime/Server protocol change more interruption-prone.
|
||||
|
||||
## Suggested improvement
|
||||
|
||||
- Make the effective delegation grant visible separately from the Worker's direct filesystem write scope.
|
||||
- Validate and explain which ancestor rule prevents delegation.
|
||||
- When a requested child scope is a strict subset of a writable/delegable checkout, accept it consistently.
|
||||
- Distinguish “parent may write but may not delegate” from a malformed/out-of-bound scope error.
|
||||
@@ -0,0 +1,25 @@
|
||||
# SpawnWorker fails against current `yoi-runtime` CLI
|
||||
|
||||
Date: 2026-07-30
|
||||
|
||||
## Symptom
|
||||
|
||||
A `SpawnWorker` tool call failed before creating its socket. The child process stderr was:
|
||||
|
||||
```text
|
||||
yoi-runtime: unexpected positional argument `worker`
|
||||
|
||||
Usage: yoi-runtime [OPTIONS]
|
||||
```
|
||||
|
||||
The Worker-management layer appears to invoke the configured Runtime executable with a legacy positional `worker` subcommand, while the current `yoi-runtime` binary starts the Runtime HTTP service directly and no longer accepts that subcommand.
|
||||
|
||||
## Impact
|
||||
|
||||
Read-only review delegation was unavailable during the Workspace credential repair implementation. Work had to continue in the parent Worker without a spawned reviewer.
|
||||
|
||||
## Suggested investigation
|
||||
|
||||
- Check the SpawnWorker process launcher command construction against the current `yoi-runtime` CLI contract.
|
||||
- Ensure runtime executable discovery does not select the server binary when a dedicated Worker child executable/protocol entrypoint is required.
|
||||
- Add an integration test that starts a spawned Worker through the same command used by the Worker-management tool and waits for its socket.
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts",
|
||||
"test": "deno test --allow-read=src --allow-env=VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/sidebar/repository-nav.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
||||
@@ -12,294 +12,159 @@ export type WorkerStatus = "idle" | "running" | "paused";
|
||||
|
||||
export type TurnResult = "finished" | "paused";
|
||||
|
||||
export type InvokeKind =
|
||||
| "user_send"
|
||||
| "notify"
|
||||
| "worker_event"
|
||||
| "system_reminder"
|
||||
| "wakeup";
|
||||
export type InvokeKind = "user_send" | "notify" | "worker_event" | "system_reminder" | "wakeup";
|
||||
|
||||
export type RunResult = "finished" | "paused" | "limit_reached" | "rolled_back";
|
||||
|
||||
export type ErrorCode =
|
||||
| "already_running"
|
||||
| "not_running"
|
||||
| "not_paused"
|
||||
| "provider_error"
|
||||
| "tool_error"
|
||||
| "invalid_request"
|
||||
| "internal";
|
||||
export type ErrorCode = "already_running" | "not_running" | "not_paused" | "provider_error" | "tool_error" | "invalid_request" | "internal";
|
||||
|
||||
export type Permission = "read" | "write";
|
||||
|
||||
export type InFlightToolCallState = "pending" | "streaming_args" | "done";
|
||||
|
||||
export type ScopeRule = {
|
||||
/**
|
||||
* Target path. Must be absolute by the time a `Scope` is built from
|
||||
* this rule — relative paths are resolved per-layer against the
|
||||
* manifest file's directory (cwd for overlay layers) before cascade
|
||||
* merge.
|
||||
*/
|
||||
target: string;
|
||||
/**
|
||||
* Permission level this rule grants (allow) or caps strictly below
|
||||
* (deny).
|
||||
*/
|
||||
permission: Permission;
|
||||
/**
|
||||
* When `false`, the rule only matches the target itself and its
|
||||
* direct children. Defaults to `true`.
|
||||
*/
|
||||
recursive: boolean;
|
||||
};
|
||||
/**
|
||||
* Target path. Must be absolute by the time a `Scope` is built from
|
||||
* this rule — relative paths are resolved per-layer against the
|
||||
* manifest file's directory (cwd for overlay layers) before cascade
|
||||
* merge.
|
||||
*/
|
||||
target: string,
|
||||
/**
|
||||
* Permission level this rule grants (allow) or caps strictly below
|
||||
* (deny).
|
||||
*/
|
||||
permission: Permission,
|
||||
/**
|
||||
* When `false`, the rule only matches the target itself and its
|
||||
* direct children. Defaults to `true`.
|
||||
*/
|
||||
recursive: boolean, };
|
||||
|
||||
export type CompletionEntry = { value: string; is_dir: boolean };
|
||||
export type CompletionEntry = { value: string, is_dir: boolean, };
|
||||
|
||||
export type RewindTargetId = {
|
||||
segment_id: string;
|
||||
user_input_entry_index: number;
|
||||
};
|
||||
export type RewindTargetId = { segment_id: string, user_input_entry_index: number, };
|
||||
|
||||
export type RewindTarget = {
|
||||
id: RewindTargetId;
|
||||
expected_head_entries: number;
|
||||
truncate_entries: number;
|
||||
turn_index: number;
|
||||
timestamp_ms: number | null;
|
||||
preview: string;
|
||||
eligible: boolean;
|
||||
disabled_reason: string | null;
|
||||
warning: string | null;
|
||||
};
|
||||
export type RewindTarget = { id: RewindTargetId, expected_head_entries: number, truncate_entries: number, turn_index: number, timestamp_ms: number | null, preview: string, eligible: boolean, disabled_reason: string | null, warning: string | null, };
|
||||
|
||||
export type RewindSummary = {
|
||||
truncated_to_entries: number;
|
||||
discarded_entries: number;
|
||||
tool_side_effect_warning: boolean;
|
||||
};
|
||||
export type RewindSummary = { truncated_to_entries: number, discarded_entries: number, tool_side_effect_warning: boolean, };
|
||||
|
||||
export type InFlightBlock =
|
||||
| { "kind": "text"; text: string; finished?: boolean }
|
||||
| { "kind": "thinking"; text: string; finished?: boolean }
|
||||
| {
|
||||
"kind": "tool_call";
|
||||
id: string;
|
||||
name: string;
|
||||
args: string;
|
||||
state?: InFlightToolCallState;
|
||||
};
|
||||
export type InFlightBlock = { "kind": "text", text: string, finished?: boolean, } | { "kind": "thinking", text: string, finished?: boolean, } | { "kind": "tool_call", id: string, name: string, args: string, state?: InFlightToolCallState, };
|
||||
|
||||
export type InFlightSnapshot = { blocks?: Array<InFlightBlock> };
|
||||
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, };
|
||||
|
||||
export type Greeting = {
|
||||
worker_name: string;
|
||||
cwd: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
scope_summary: string;
|
||||
tools: Array<string>;
|
||||
/**
|
||||
* Model context window in tokens. Always filled by the Worker greeting.
|
||||
*/
|
||||
context_window: number;
|
||||
/**
|
||||
* Estimated current session context tokens at connect time.
|
||||
*/
|
||||
context_tokens: number;
|
||||
};
|
||||
export type Greeting = { worker_name: string, cwd: string, provider: string, model: string, scope_summary: string, tools: Array<string>,
|
||||
/**
|
||||
* Model context window in tokens. Always filled by the Worker greeting.
|
||||
*/
|
||||
context_window: number,
|
||||
/**
|
||||
* Estimated current session context tokens at connect time.
|
||||
*/
|
||||
context_tokens: number, };
|
||||
|
||||
export type Alert = {
|
||||
level: AlertLevel;
|
||||
source: AlertSource;
|
||||
message: string;
|
||||
/**
|
||||
* Milliseconds since the Unix epoch.
|
||||
*/
|
||||
timestamp_ms: number;
|
||||
};
|
||||
export type Alert = { level: AlertLevel, source: AlertSource, message: string,
|
||||
/**
|
||||
* Milliseconds since the Unix epoch.
|
||||
*/
|
||||
timestamp_ms: number, };
|
||||
|
||||
export type MemoryWorkerEvent = {
|
||||
worker: string;
|
||||
status: string;
|
||||
run_id: string;
|
||||
trigger: string;
|
||||
reason: string;
|
||||
/**
|
||||
* Human-readable compact form for actionbar rendering.
|
||||
*/
|
||||
message: string;
|
||||
/**
|
||||
* Milliseconds since the Unix epoch.
|
||||
*/
|
||||
timestamp_ms: number;
|
||||
};
|
||||
export type MemoryWorkerEvent = { worker: string, status: string, run_id: string, trigger: string, reason: string,
|
||||
/**
|
||||
* Human-readable compact form for actionbar rendering.
|
||||
*/
|
||||
message: string,
|
||||
/**
|
||||
* Milliseconds since the Unix epoch.
|
||||
*/
|
||||
timestamp_ms: number, };
|
||||
|
||||
export type Segment =
|
||||
| { "kind": "text"; content: string }
|
||||
| {
|
||||
"kind": "paste";
|
||||
id: number;
|
||||
chars: number;
|
||||
lines: number;
|
||||
content: string;
|
||||
}
|
||||
| { "kind": "file_ref"; path: string }
|
||||
| { "kind": "unknown" };
|
||||
export type Segment = { "kind": "text", content: string, } | { "kind": "paste", id: number, chars: number, lines: number, content: string, } | { "kind": "file_ref", path: string, } | { "kind": "unknown" };
|
||||
|
||||
export type WorkerEvent =
|
||||
| { "kind": "turn_ended"; worker_name: string }
|
||||
| { "kind": "errored"; worker_name: string; message: string }
|
||||
| { "kind": "shut_down"; worker_name: string }
|
||||
| {
|
||||
"kind": "scope_sub_delegated";
|
||||
/**
|
||||
* Sub-delegating Worker (= the sender itself).
|
||||
*/
|
||||
parent_worker: string;
|
||||
/**
|
||||
* Name of the grandchild Worker.
|
||||
*/
|
||||
sub_worker: string;
|
||||
/**
|
||||
* Unix-socket path where the grandchild is reachable.
|
||||
*/
|
||||
sub_socket: string;
|
||||
/**
|
||||
* Scope delegated to the grandchild.
|
||||
*/
|
||||
scope: Array<ScopeRule>;
|
||||
};
|
||||
export type WorkerEvent = { "kind": "turn_ended", worker_name: string, } | { "kind": "errored", worker_name: string, message: string, } | { "kind": "shut_down", worker_name: string, } | { "kind": "scope_sub_delegated",
|
||||
/**
|
||||
* Sub-delegating Worker (= the sender itself).
|
||||
*/
|
||||
parent_worker: string,
|
||||
/**
|
||||
* Name of the grandchild Worker.
|
||||
*/
|
||||
sub_worker: string,
|
||||
/**
|
||||
* Unix-socket path where the grandchild is reachable.
|
||||
*/
|
||||
sub_socket: string,
|
||||
/**
|
||||
* Scope delegated to the grandchild.
|
||||
*/
|
||||
scope: Array<ScopeRule>, };
|
||||
|
||||
export type Method =
|
||||
| { "method": "run"; "params": { input: Array<Segment> } }
|
||||
| { "method": "notify"; "params": { message: string; auto_run?: boolean } }
|
||||
| { "method": "worker_event"; "params": WorkerEvent }
|
||||
| { "method": "resume" }
|
||||
| { "method": "cancel" }
|
||||
| { "method": "pause" }
|
||||
| { "method": "compact" }
|
||||
| { "method": "list_rewind_targets" }
|
||||
| {
|
||||
"method": "rewind_to";
|
||||
"params": { target: RewindTargetId; expected_head_entries: number };
|
||||
}
|
||||
| { "method": "shutdown" }
|
||||
| {
|
||||
"method": "list_completions";
|
||||
"params": { kind: CompletionKind; prefix: string };
|
||||
}
|
||||
| { "method": "list_workers" }
|
||||
| { "method": "restore_worker"; "params": { name: string } }
|
||||
| { "method": "register_peer"; "params": { name: string } };
|
||||
export type SubscriptionRequestId = string;
|
||||
|
||||
export type Event =
|
||||
| { "event": "user_message"; "data": { segments: Array<Segment> } }
|
||||
| { "event": "system_item"; "data": { item: unknown } }
|
||||
| { "event": "invoke_start"; "data": { kind: InvokeKind } }
|
||||
| { "event": "turn_start"; "data": { turn: number } }
|
||||
| { "event": "turn_end"; "data": { turn: number; result: TurnResult } }
|
||||
| { "event": "llm_call_start"; "data": { llm_call: number } }
|
||||
| { "event": "llm_call_end"; "data": { llm_call: number } }
|
||||
| {
|
||||
"event": "llm_retry";
|
||||
"data": {
|
||||
llm_call: number;
|
||||
/**
|
||||
* The attempt that just failed. 1 origin.
|
||||
*/
|
||||
failed_attempt: number;
|
||||
max_attempts: number;
|
||||
wait_ms: number;
|
||||
elapsed_ms: number;
|
||||
status?: number | null;
|
||||
error: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
"event": "llm_continuation";
|
||||
"data": {
|
||||
llm_call: number;
|
||||
attempt: number;
|
||||
max_attempts: number;
|
||||
reason: string;
|
||||
};
|
||||
}
|
||||
| { "event": "text_delta"; "data": { text: string } }
|
||||
| { "event": "text_done"; "data": { text: string } }
|
||||
| { "event": "thinking_start" }
|
||||
| { "event": "thinking_delta"; "data": { text: string } }
|
||||
| { "event": "thinking_done"; "data": { text: string } }
|
||||
| { "event": "tool_call_start"; "data": { id: string; name: string } }
|
||||
| { "event": "tool_call_args_delta"; "data": { id: string; json: string } }
|
||||
| {
|
||||
"event": "tool_call_done";
|
||||
"data": { id: string; name: string; arguments: string };
|
||||
}
|
||||
| {
|
||||
"event": "tool_result";
|
||||
"data": {
|
||||
id: string;
|
||||
/**
|
||||
* Short human-readable summary. Always present; used by clients
|
||||
* that only want a 1-line rendering (e.g. collapsed views).
|
||||
*/
|
||||
summary: string;
|
||||
/**
|
||||
* Full tool output. Absent when the tool chose to return
|
||||
* summary-only, or when the result was pruned.
|
||||
*/
|
||||
output?: string | null;
|
||||
is_error: boolean;
|
||||
};
|
||||
}
|
||||
| {
|
||||
"event": "usage";
|
||||
"data": {
|
||||
input_tokens: number | null;
|
||||
output_tokens: number | null;
|
||||
cache_read_input_tokens?: number | null;
|
||||
};
|
||||
}
|
||||
| { "event": "run_end"; "data": { result: RunResult } }
|
||||
| { "event": "error"; "data": { code: ErrorCode; message: string } }
|
||||
| {
|
||||
"event": "snapshot";
|
||||
"data": {
|
||||
entries: Array<unknown>;
|
||||
greeting: Greeting;
|
||||
status: WorkerStatus;
|
||||
/**
|
||||
* Unfinished model output that has already streamed in the current
|
||||
* run but is not yet represented by committed snapshot entries.
|
||||
*/
|
||||
in_flight?: InFlightSnapshot;
|
||||
};
|
||||
}
|
||||
| { "event": "segment_rotated"; "data": { entry: unknown } }
|
||||
| { "event": "status"; "data": { status: WorkerStatus } }
|
||||
| {
|
||||
"event": "completions";
|
||||
"data": { kind: CompletionKind; entries: Array<CompletionEntry> };
|
||||
}
|
||||
| {
|
||||
"event": "rewind_targets";
|
||||
"data": { head_entries: number; targets: Array<RewindTarget> };
|
||||
}
|
||||
| {
|
||||
"event": "rewind_applied";
|
||||
"data": {
|
||||
entries: Array<unknown>;
|
||||
input: Array<Segment>;
|
||||
summary: RewindSummary;
|
||||
};
|
||||
}
|
||||
| { "event": "workers_listed"; "data": { workers: unknown } }
|
||||
| { "event": "worker_restored"; "data": { result: unknown } }
|
||||
| { "event": "peer_registered"; "data": { result: unknown } }
|
||||
| { "event": "alert"; "data": Alert }
|
||||
| { "event": "memory_worker"; "data": MemoryWorkerEvent }
|
||||
| { "event": "compact_start" }
|
||||
| { "event": "compact_done"; "data": { new_segment_id: string } }
|
||||
| { "event": "compact_failed"; "data": { error: string } }
|
||||
| { "event": "shutdown" };
|
||||
export type SubscriptionId = string;
|
||||
|
||||
export type SubscriptionWorkerId = string;
|
||||
|
||||
export type SubscriptionWorkdirId = string;
|
||||
|
||||
export type SubscriptionWorkerIds = Array<SubscriptionWorkerId>;
|
||||
|
||||
export type SubscriptionWorkerState = "idle" | "running" | "paused" | "stopped" | "cancelled";
|
||||
|
||||
export type EventSubscriptionSelector = { "topic": "runtime_workers" } | { "topic": "worker_lifecycle", worker_ids: SubscriptionWorkerIds, } | { "topic": "worker_protocol", worker_id: SubscriptionWorkerId, runtime_id?: string | null, } | { "topic": "workspace_workers" } | { "topic": "workspace_workdirs" };
|
||||
|
||||
export type SubscriptionWorker = { worker_id: SubscriptionWorkerId,
|
||||
/**
|
||||
* Set by the Workspace Server when projecting a Runtime-owned Worker to clients.
|
||||
* Runtime producers leave this unset because the connection identifies the Runtime.
|
||||
*/
|
||||
runtime_id?: string | null,
|
||||
/**
|
||||
* Producer-owned monotonic revision for this Worker subject.
|
||||
*/
|
||||
subject_revision: number, state: SubscriptionWorkerState, workspace_id?: string | null, display_name?: string | null, profile?: string | null, repository_id?: string | null, working_directory_id?: SubscriptionWorkdirId | null, };
|
||||
|
||||
export type SubscriptionWorkdir = { working_directory_id: SubscriptionWorkdirId, repository_id: string, state: string, primary_worker_id?: SubscriptionWorkerId | null, };
|
||||
|
||||
export type SubscriptionSnapshot = { "topic": "workers", "data": { workers: Array<SubscriptionWorker>, } } | { "topic": "worker_protocol", "data": { worker_id: SubscriptionWorkerId, events: Array<Event>, } } | { "topic": "workspace_workdirs", "data": { workdirs: Array<SubscriptionWorkdir>, } };
|
||||
|
||||
export type SubscriptionEventPayload = { "event": "worker_upserted", "data": { worker: SubscriptionWorker, } } | { "event": "worker_removed", "data": { worker_id: SubscriptionWorkerId, runtime_id?: string | null, } } | { "event": "worker_protocol", "data": { worker_id: SubscriptionWorkerId, event: Event, } } | { "event": "workdir_upserted", "data": { workdir: SubscriptionWorkdir, } } | { "event": "workdir_removed", "data": { working_directory_id: SubscriptionWorkdirId, } };
|
||||
|
||||
export type SubscriptionRejectionCode = "invalid_request" | "unsupported_protocol_version" | "unsupported_selector" | "unauthorized" | "resource_not_found" | "capacity_exceeded" | "internal";
|
||||
|
||||
export type SubscriptionTerminationCode = "lagged" | "resource_gone" | "unauthorized" | "server_shutdown";
|
||||
|
||||
export type SubscriptionRequest = { "method": "subscribe_events", "params": { request_id: SubscriptionRequestId, selector: EventSubscriptionSelector, } } | { "method": "unsubscribe_events", "params": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, } };
|
||||
|
||||
export type SubscriptionWorkerProtocolMethod = { subscription_id: SubscriptionId, method: Method, };
|
||||
|
||||
export type SubscriptionResponse = { "result": "subscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, selector: EventSubscriptionSelector, snapshot_revision: number, snapshot: SubscriptionSnapshot, } } | { "result": "unsubscribed", "payload": { request_id: SubscriptionRequestId, subscription_id: SubscriptionId, } } | { "result": "subscription_rejected", "payload": { request_id: SubscriptionRequestId, subscription_id?: SubscriptionId | null, code: SubscriptionRejectionCode, message: string, } };
|
||||
|
||||
export type SubscriptionEvent = { "event": "event", "data": { subscription_id: SubscriptionId, subject_revision: number, payload: SubscriptionEventPayload, } } | { "event": "subscription_closed", "data": { subscription_id: SubscriptionId, code: SubscriptionTerminationCode, message: string, } };
|
||||
|
||||
export type SubscriptionFramePayload = { "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod };
|
||||
|
||||
export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod });
|
||||
|
||||
export type Method = { "method": "run", "params": { input: Array<Segment>, } } | { "method": "notify", "params": { message: string, auto_run?: boolean, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "resume" } | { "method": "cancel" } | { "method": "pause" } | { "method": "compact" } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown" } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } };
|
||||
|
||||
export type Event = { "event": "user_message", "data": { segments: Array<Segment>, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number,
|
||||
/**
|
||||
* The attempt that just failed. 1 origin.
|
||||
*/
|
||||
failed_attempt: number, max_attempts: number, wait_ms: number, elapsed_ms: number, status?: number | null, error: string, } } | { "event": "llm_continuation", "data": { llm_call: number, attempt: number, max_attempts: number, reason: string, } } | { "event": "text_delta", "data": { text: string, } } | { "event": "text_done", "data": { text: string, } } | { "event": "thinking_start" } | { "event": "thinking_delta", "data": { text: string, } } | { "event": "thinking_done", "data": { text: string, } } | { "event": "tool_call_start", "data": { id: string, name: string, } } | { "event": "tool_call_args_delta", "data": { id: string, json: string, } } | { "event": "tool_call_done", "data": { id: string, name: string, arguments: string, } } | { "event": "tool_result", "data": { id: string,
|
||||
/**
|
||||
* Short human-readable summary. Always present; used by clients
|
||||
* that only want a 1-line rendering (e.g. collapsed views).
|
||||
*/
|
||||
summary: string,
|
||||
/**
|
||||
* Full tool output. Absent when the tool chose to return
|
||||
* summary-only, or when the result was pruned.
|
||||
*/
|
||||
output?: string | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { entries: Array<unknown>, greeting: Greeting, status: WorkerStatus,
|
||||
/**
|
||||
* Unfinished model output that has already streamed in the current
|
||||
* run but is not yet represented by committed snapshot entries.
|
||||
*/
|
||||
in_flight?: InFlightSnapshot, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" };
|
||||
|
||||
@@ -125,9 +125,12 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
||||
workersNav.includes("href={`/w/${workspaceId}/workers`}") &&
|
||||
workersNav.includes("filter(canShowWorkerInSidebar)") &&
|
||||
workersNav.includes("worker.display_name || worker.label") &&
|
||||
workersNav.includes("worker {worker.worker_id}") &&
|
||||
workersNav.includes("worker-status-dot") &&
|
||||
workersNav.includes("worker-status-spinner") &&
|
||||
workersNav.includes("worker.repository_id ?? '—'") &&
|
||||
workersNav.includes("worker.working_directory_id ?? '—'") &&
|
||||
!workersNav.includes('aria-disabled="true"'),
|
||||
"Workers sidebar should link to the Worker list page and omit registry-only Workers",
|
||||
"Workers sidebar should link to the Worker list page and show state indicators with repository/workdir metadata",
|
||||
);
|
||||
assert(
|
||||
!sidebar.includes("CompanionNavSection") &&
|
||||
@@ -310,7 +313,8 @@ Deno.test("Worker Console uses protocol observation events without transcript fe
|
||||
assert(
|
||||
consolePage.includes("connectProtocolTransport") &&
|
||||
consolePage.includes("handleIncomingProtocolEvent") &&
|
||||
consolePage.includes("/protocol/ws") &&
|
||||
consolePage.includes("workspaceMultiplexer") &&
|
||||
consolePage.includes('topic: "worker_protocol"') &&
|
||||
!consolePage.includes("seenObservationEventIds") &&
|
||||
consolePage.includes("createConsoleProjector") &&
|
||||
consolePage.includes("consoleProjector.append(eventBatch)") &&
|
||||
@@ -541,7 +545,8 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
|
||||
);
|
||||
assert(
|
||||
!consolePage.includes("/transcript") &&
|
||||
consolePage.includes("/protocol/ws") &&
|
||||
consolePage.includes("workspaceMultiplexer") &&
|
||||
consolePage.includes("sendWorkerMethod") &&
|
||||
!consolePage.includes("/events" + "/ws") &&
|
||||
!consolePage.includes("/input") &&
|
||||
!consolePage.includes("/completions"),
|
||||
@@ -558,10 +563,11 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
|
||||
"reload token advancement should not synchronously read and write the rune state",
|
||||
);
|
||||
assert(
|
||||
consolePage.includes("advanceReloadToken();") &&
|
||||
consolePage.includes("void loadConsoleData(target);") &&
|
||||
consolePage.includes("const token = advanceReloadToken();") &&
|
||||
consolePage.includes("worker = targetWorker;") &&
|
||||
consolePage.includes("if (!targetWorker) void loadWorker(target, token);") &&
|
||||
!consolePage.includes("void refreshConsole();\n });\n\n $effect"),
|
||||
"target-change effect should load data without depending on manual refresh state reads",
|
||||
"target-change effect should install route data and guard fallback loading with the new target token",
|
||||
);
|
||||
assert(
|
||||
consolePage.includes(
|
||||
@@ -708,3 +714,33 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
||||
"Root layout should not redirect account and device-login public routes to a workspace",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Workspace Worker list and Console share the multiplexed connection", async () => {
|
||||
const consolePage = await Deno.readTextFile(
|
||||
new URL(
|
||||
"./../../../routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const sidebarStore = await Deno.readTextFile(
|
||||
new URL("./../sidebar/worker-subscription.ts", import.meta.url),
|
||||
);
|
||||
const multiplexer = await Deno.readTextFile(
|
||||
new URL("./../multiplexer.ts", import.meta.url),
|
||||
);
|
||||
assert(
|
||||
consolePage.includes("workspaceMultiplexer(target.workspaceId)") &&
|
||||
sidebarStore.includes("workspaceMultiplexer(workspaceId)") &&
|
||||
multiplexer.includes("const multiplexers = new Map") &&
|
||||
multiplexer.includes("frame: 'worker_protocol'"),
|
||||
"Sidebar and Console should share one Workspace multiplexer and route Worker methods through a subscription lane",
|
||||
);
|
||||
assert(
|
||||
multiplexer.includes("this.#socket?.readyState === WebSocket.OPEN") &&
|
||||
multiplexer.includes("this.#sendSubscribe(subscription)") &&
|
||||
consolePage.includes("const targetWorker = data.worker") &&
|
||||
consolePage.includes("worker = targetWorker") &&
|
||||
consolePage.includes("const consoleTarget = $derived({ workspaceId, runtimeId, workerId })"),
|
||||
"A reused Console route should subscribe immediately on the live Workspace socket and install the new route Worker",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getHeaderController } from './context';
|
||||
|
||||
let { content }: { content: Snippet<[]> } = $props();
|
||||
const controller = getHeaderController();
|
||||
|
||||
$effect(() => {
|
||||
controller.content = content;
|
||||
return () => {
|
||||
if (controller.content === content) controller.content = null;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { buildWorkspaceBreadcrumbs } from './breadcrumb-model';
|
||||
|
||||
let { workspaceId }: { workspaceId: string } = $props();
|
||||
|
||||
const workerName = $derived.by(() => {
|
||||
const data = page.data as Record<string, unknown>;
|
||||
const worker = data.worker as { display_name?: string | null; label?: string | null } | null | undefined;
|
||||
return worker?.display_name ?? worker?.label ?? null;
|
||||
});
|
||||
const breadcrumbs = $derived(buildWorkspaceBreadcrumbs(page.url.pathname, workspaceId, { workerName }));
|
||||
const workspaceRoot = $derived(`/w/${encodeURIComponent(workspaceId)}`);
|
||||
</script>
|
||||
|
||||
<nav class="workspace-breadcrumbs" aria-label="Current workspace location">
|
||||
<a class="workspace-breadcrumb-root" href={workspaceRoot} aria-label="Workspace home">/</a>
|
||||
{#each breadcrumbs as breadcrumb, index (`${index}:${breadcrumb.label}`)}
|
||||
{#if index > 0}<span class="workspace-breadcrumb-separator" aria-hidden="true">/</span>{/if}
|
||||
{#if breadcrumb.href}
|
||||
<a href={breadcrumb.href}>{breadcrumb.label}</a>
|
||||
{:else}
|
||||
<span class="workspace-breadcrumb-label" aria-current={index === breadcrumbs.length - 1 ? 'page' : undefined}>
|
||||
{breadcrumb.label}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.workspace-breadcrumbs {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
color: var(--workspace-muted, #53606e);
|
||||
font-family: var(--workspace-font-mono, monospace);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.workspace-breadcrumbs a,
|
||||
.workspace-breadcrumb-label {
|
||||
max-width: min(32vw, 28rem);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace-breadcrumbs a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.workspace-breadcrumbs a:hover {
|
||||
color: var(--workspace-ink, #151b23);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.22rem;
|
||||
}
|
||||
|
||||
.workspace-breadcrumb-root {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workspace-breadcrumb-separator {
|
||||
color: color-mix(in srgb, currentColor 45%, transparent);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.workspace-breadcrumbs span[aria-current='page'] {
|
||||
color: var(--workspace-ink, #151b23);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
import { buildWorkspaceBreadcrumbs } from "./breadcrumb-model.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => Promise<void> | void): void;
|
||||
};
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("Ticket detail breadcrumbs expose the Ticket list and current id", () => {
|
||||
assertEquals(
|
||||
buildWorkspaceBreadcrumbs("/w/workspace/tickets/TICKET-42", "workspace"),
|
||||
[
|
||||
{ label: "tickets", href: "/w/workspace/tickets" },
|
||||
{ label: "TICKET-42" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker console breadcrumbs use the logical Workers route and display name", () => {
|
||||
assertEquals(
|
||||
buildWorkspaceBreadcrumbs(
|
||||
"/w/workspace/runtimes/runtime-a/workers/worker-7/console",
|
||||
"workspace",
|
||||
{ workerName: "Review Worker" },
|
||||
),
|
||||
[
|
||||
{ label: "workers", href: "/w/workspace/workers" },
|
||||
{ label: "Review Worker" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker console breadcrumbs fall back to Worker id", () => {
|
||||
assertEquals(
|
||||
buildWorkspaceBreadcrumbs(
|
||||
"/w/workspace/runtimes/runtime-a/workers/worker-7/console",
|
||||
"workspace",
|
||||
),
|
||||
[
|
||||
{ label: "workers", href: "/w/workspace/workers" },
|
||||
{ label: "worker-7" },
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
export type WorkspaceBreadcrumb = {
|
||||
label: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export type WorkspaceBreadcrumbContext = {
|
||||
workerName?: string | null;
|
||||
};
|
||||
|
||||
export function buildWorkspaceBreadcrumbs(
|
||||
pathname: string,
|
||||
workspaceId: string,
|
||||
context: WorkspaceBreadcrumbContext = {},
|
||||
): WorkspaceBreadcrumb[] {
|
||||
const workspaceRoot = `/w/${encodeURIComponent(workspaceId)}`;
|
||||
const prefix = `${workspaceRoot}/`;
|
||||
if (pathname === workspaceRoot || pathname === `${workspaceRoot}/`) return [];
|
||||
if (!pathname.startsWith(prefix)) return [];
|
||||
|
||||
const segments = pathname
|
||||
.slice(prefix.length)
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map(decodeURIComponent);
|
||||
|
||||
if (
|
||||
segments[0] === "runtimes" &&
|
||||
segments[2] === "workers" &&
|
||||
segments[3]
|
||||
) {
|
||||
return [
|
||||
{ label: "workers", href: `${workspaceRoot}/workers` },
|
||||
{ label: context.workerName?.trim() || segments[3] },
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
segments[0] === "settings" && segments[1] === "profiles" &&
|
||||
segments[2] === "trees"
|
||||
) {
|
||||
return [
|
||||
{ label: "settings", href: `${workspaceRoot}/settings` },
|
||||
{ label: "profiles", href: `${workspaceRoot}/settings/profiles` },
|
||||
{ label: segments[3] || "trees" },
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
segments[0] === "settings" &&
|
||||
segments[1] === "runtimes" &&
|
||||
segments[2] &&
|
||||
segments[3] === "workdirs"
|
||||
) {
|
||||
return [
|
||||
{ label: "settings", href: `${workspaceRoot}/settings` },
|
||||
{ label: "runtimes", href: `${workspaceRoot}/settings/runtimes` },
|
||||
{ label: segments[2] },
|
||||
{ label: "workdirs" },
|
||||
];
|
||||
}
|
||||
|
||||
return segments.map((segment, index) => {
|
||||
const isCurrent = index === segments.length - 1;
|
||||
const href = isCurrent || (segments[0] === "repositories" && index === 0)
|
||||
? undefined
|
||||
: `${workspaceRoot}/${
|
||||
segments.slice(0, index + 1).map(encodeURIComponent).join("/")
|
||||
}`;
|
||||
return {
|
||||
label: segment,
|
||||
href,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getContext, setContext } from "svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
const HEADER_CONTEXT_KEY = Symbol("workspace-header");
|
||||
|
||||
export type HeaderContent = Snippet<[]> | null;
|
||||
export type HeaderController = {
|
||||
content: HeaderContent;
|
||||
};
|
||||
|
||||
export function provideHeaderController(controller: HeaderController): void {
|
||||
setContext(HEADER_CONTEXT_KEY, controller);
|
||||
}
|
||||
|
||||
export function getHeaderController(): HeaderController {
|
||||
return getContext<HeaderController>(HEADER_CONTEXT_KEY);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { browser } from '$app/environment';
|
||||
import type {
|
||||
EventSubscriptionSelector,
|
||||
Method,
|
||||
SubscriptionFrame,
|
||||
SubscriptionId,
|
||||
} from '$lib/generated/protocol';
|
||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||
|
||||
type Listener = {
|
||||
onFrame(frame: SubscriptionFrame): void;
|
||||
onStatus?(status: 'connecting' | 'open' | 'closed', message?: string): void;
|
||||
};
|
||||
|
||||
type ActiveSubscription = {
|
||||
clientId: string;
|
||||
selector: EventSubscriptionSelector;
|
||||
listener: Listener;
|
||||
requestId: string | null;
|
||||
subscriptionId: SubscriptionId | null;
|
||||
};
|
||||
|
||||
export type WorkspaceMultiplexerSubscription = {
|
||||
close(): void;
|
||||
sendWorkerMethod(method: Method): void;
|
||||
};
|
||||
|
||||
const multiplexers = new Map<string, WorkspaceMultiplexer>();
|
||||
|
||||
export function workspaceMultiplexer(workspaceId: string): WorkspaceMultiplexer {
|
||||
let multiplexer = multiplexers.get(workspaceId);
|
||||
if (!multiplexer) {
|
||||
multiplexer = new WorkspaceMultiplexer(workspaceId);
|
||||
multiplexers.set(workspaceId, multiplexer);
|
||||
}
|
||||
return multiplexer;
|
||||
}
|
||||
|
||||
export class WorkspaceMultiplexer {
|
||||
readonly #workspaceId: string;
|
||||
readonly #subscriptions = new Map<string, ActiveSubscription>();
|
||||
readonly #requests = new Map<string, string>();
|
||||
readonly #runtimeSubscriptions = new Map<string, string>();
|
||||
#socket: WebSocket | null = null;
|
||||
#reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
#closed = false;
|
||||
|
||||
constructor(workspaceId: string) {
|
||||
this.#workspaceId = workspaceId;
|
||||
}
|
||||
|
||||
subscribe(
|
||||
selector: EventSubscriptionSelector,
|
||||
listener: Listener,
|
||||
): WorkspaceMultiplexerSubscription {
|
||||
const clientId = crypto.randomUUID();
|
||||
const subscription: ActiveSubscription = {
|
||||
clientId,
|
||||
selector,
|
||||
listener,
|
||||
requestId: null,
|
||||
subscriptionId: null,
|
||||
};
|
||||
this.#subscriptions.set(clientId, subscription);
|
||||
this.#closed = false;
|
||||
if (this.#socket?.readyState === WebSocket.OPEN) {
|
||||
subscription.listener.onStatus?.('open');
|
||||
this.#sendSubscribe(subscription);
|
||||
} else {
|
||||
this.#ensureConnected();
|
||||
}
|
||||
return {
|
||||
close: () => this.#remove(clientId),
|
||||
sendWorkerMethod: (method) => this.#sendWorkerMethod(clientId, method),
|
||||
};
|
||||
}
|
||||
|
||||
#ensureConnected(): void {
|
||||
if (!browser || this.#socket || this.#closed || this.#subscriptions.size === 0) return;
|
||||
for (const subscription of this.#subscriptions.values()) {
|
||||
subscription.listener.onStatus?.('connecting');
|
||||
}
|
||||
const url = new URL(
|
||||
workspaceApiPath(this.#workspaceId, '/protocol/ws'),
|
||||
window.location.origin,
|
||||
);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const socket = new WebSocket(url);
|
||||
this.#socket = socket;
|
||||
socket.addEventListener('open', () => {
|
||||
for (const subscription of this.#subscriptions.values()) {
|
||||
subscription.listener.onStatus?.('open');
|
||||
this.#sendSubscribe(subscription);
|
||||
}
|
||||
});
|
||||
socket.addEventListener('message', (event) => this.#receive(String(event.data)));
|
||||
socket.addEventListener('error', () => socket.close());
|
||||
socket.addEventListener('close', () => {
|
||||
if (this.#socket !== socket) return;
|
||||
this.#socket = null;
|
||||
this.#requests.clear();
|
||||
this.#runtimeSubscriptions.clear();
|
||||
for (const subscription of this.#subscriptions.values()) {
|
||||
subscription.requestId = null;
|
||||
subscription.subscriptionId = null;
|
||||
subscription.listener.onStatus?.('closed', 'Workspace subscription disconnected');
|
||||
}
|
||||
if (!this.#closed && this.#subscriptions.size > 0) {
|
||||
this.#reconnectTimer = setTimeout(() => this.#ensureConnected(), 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#sendSubscribe(subscription: ActiveSubscription): void {
|
||||
const requestId = crypto.randomUUID();
|
||||
subscription.requestId = requestId;
|
||||
this.#requests.set(requestId, subscription.clientId);
|
||||
this.#send({
|
||||
protocol_version: 1,
|
||||
frame: 'request',
|
||||
message: {
|
||||
method: 'subscribe_events',
|
||||
params: { request_id: requestId, selector: subscription.selector },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
#receive(text: string): void {
|
||||
let frame: SubscriptionFrame;
|
||||
try {
|
||||
frame = JSON.parse(text) as SubscriptionFrame;
|
||||
} catch {
|
||||
this.#socket?.close();
|
||||
return;
|
||||
}
|
||||
if (frame.protocol_version !== 1) {
|
||||
this.#socket?.close();
|
||||
return;
|
||||
}
|
||||
if (frame.frame === 'response' && frame.message.result === 'subscribed') {
|
||||
const clientId = this.#requests.get(frame.message.payload.request_id);
|
||||
const subscription = clientId ? this.#subscriptions.get(clientId) : undefined;
|
||||
if (!clientId || !subscription) return;
|
||||
this.#requests.delete(frame.message.payload.request_id);
|
||||
const subscriptionId = frame.message.payload.subscription_id;
|
||||
if (!subscriptionId) return;
|
||||
subscription.subscriptionId = subscriptionId;
|
||||
this.#runtimeSubscriptions.set(subscriptionId, clientId);
|
||||
subscription.listener.onFrame(frame);
|
||||
return;
|
||||
}
|
||||
if (frame.frame === 'response' && frame.message.result === 'subscription_rejected') {
|
||||
const clientId = this.#requests.get(frame.message.payload.request_id);
|
||||
const subscription = clientId ? this.#subscriptions.get(clientId) : undefined;
|
||||
subscription?.listener.onFrame(frame);
|
||||
if (clientId) {
|
||||
this.#requests.delete(frame.message.payload.request_id);
|
||||
this.#remove(clientId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (frame.frame === 'event') {
|
||||
const clientId = this.#runtimeSubscriptions.get(frame.message.data.subscription_id);
|
||||
const subscription = clientId ? this.#subscriptions.get(clientId) : undefined;
|
||||
subscription?.listener.onFrame(frame);
|
||||
if (
|
||||
frame.message.event === 'subscription_closed' &&
|
||||
clientId &&
|
||||
subscription &&
|
||||
this.#socket?.readyState === WebSocket.OPEN
|
||||
) {
|
||||
this.#runtimeSubscriptions.delete(frame.message.data.subscription_id);
|
||||
subscription.subscriptionId = null;
|
||||
subscription.listener.onStatus?.('connecting', frame.message.data.message);
|
||||
this.#sendSubscribe(subscription);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#sendWorkerMethod(clientId: string, method: Method): void {
|
||||
const subscription = this.#subscriptions.get(clientId);
|
||||
if (!subscription?.subscriptionId) throw new Error('Worker protocol subscription is not open');
|
||||
this.#send({
|
||||
protocol_version: 1,
|
||||
frame: 'worker_protocol',
|
||||
message: { subscription_id: subscription.subscriptionId, method },
|
||||
});
|
||||
}
|
||||
|
||||
#remove(clientId: string): void {
|
||||
const subscription = this.#subscriptions.get(clientId);
|
||||
if (!subscription) return;
|
||||
this.#subscriptions.delete(clientId);
|
||||
if (subscription.subscriptionId && this.#socket?.readyState === WebSocket.OPEN) {
|
||||
this.#send({
|
||||
protocol_version: 1,
|
||||
frame: 'request',
|
||||
message: {
|
||||
method: 'unsubscribe_events',
|
||||
params: {
|
||||
request_id: crypto.randomUUID(),
|
||||
subscription_id: subscription.subscriptionId,
|
||||
},
|
||||
},
|
||||
});
|
||||
this.#runtimeSubscriptions.delete(subscription.subscriptionId);
|
||||
}
|
||||
if (this.#subscriptions.size === 0) {
|
||||
this.#closed = true;
|
||||
if (this.#reconnectTimer) clearTimeout(this.#reconnectTimer);
|
||||
this.#socket?.close();
|
||||
this.#socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
#send(frame: SubscriptionFrame): void {
|
||||
if (this.#socket?.readyState !== WebSocket.OPEN) return;
|
||||
this.#socket.send(JSON.stringify(frame));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { WorkingDirectorySummary } from "../sidebar/types.ts";
|
||||
import { formatCurrentWorkdirRevision } from "./workdir-revision.ts";
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => Promise<void> | void): void;
|
||||
};
|
||||
|
||||
function assertEquals<T>(actual: T, expected: T): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected ${String(expected)}, got ${String(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function workdir(
|
||||
current_selector: string | null,
|
||||
current_ref: string | null,
|
||||
): WorkingDirectorySummary {
|
||||
return {
|
||||
working_directory_id: "workdir-1",
|
||||
repository_id: "repository-1",
|
||||
current_selector,
|
||||
current_ref,
|
||||
materializer_kind: "local_git_worktree",
|
||||
status: "active",
|
||||
cleanup_target: {
|
||||
kind: "local_git_worktree",
|
||||
working_directory_id: "workdir-1",
|
||||
repository_id: "repository-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("Git detached Workdir shows only its current ref", () => {
|
||||
assertEquals(
|
||||
formatCurrentWorkdirRevision(
|
||||
workdir(null, "0123456789abcdef0123456789abcdef01234567"),
|
||||
"git",
|
||||
),
|
||||
"0123456789ab",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Git Workdir with a selector shows selector at current ref", () => {
|
||||
assertEquals(
|
||||
formatCurrentWorkdirRevision(
|
||||
workdir("feature/current", "fedcba9876543210fedcba9876543210fedcba98"),
|
||||
"git",
|
||||
),
|
||||
"feature/current@fedcba987654",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("non-Git Workdir does not receive Git hash formatting", () => {
|
||||
assertEquals(
|
||||
formatCurrentWorkdirRevision(
|
||||
workdir("snapshot", "revision-value"),
|
||||
"archive",
|
||||
),
|
||||
"snapshot · revision-value",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { WorkingDirectorySummary } from "../sidebar/types.ts";
|
||||
|
||||
export function formatCurrentWorkdirRevision(
|
||||
workdir: WorkingDirectorySummary,
|
||||
repositoryProvider: string | null | undefined,
|
||||
): string {
|
||||
const selector = workdir.current_selector?.trim() || null;
|
||||
const reference = workdir.current_ref?.trim() || null;
|
||||
|
||||
if (repositoryProvider?.toLowerCase() === "git") {
|
||||
const hash = reference ? shortGitHash(reference) : null;
|
||||
if (selector && hash) return `${selector}@${hash}`;
|
||||
return selector ?? hash ?? "—";
|
||||
}
|
||||
|
||||
if (selector && reference) return `${selector} · ${reference}`;
|
||||
return selector ?? reference ?? "—";
|
||||
}
|
||||
|
||||
function shortGitHash(reference: string): string {
|
||||
return reference.length > 12 ? reference.slice(0, 12) : reference;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||
import { workerConsoleHref } from '$lib/workspace/console/model';
|
||||
import {
|
||||
workspaceWorkersStore,
|
||||
type SidebarWorker,
|
||||
} from './worker-subscription';
|
||||
import { canShowWorkerInSidebar } from './workers';
|
||||
import type { ListResponse, Worker } from './types';
|
||||
|
||||
const MAX_VISIBLE_WORKERS = 6;
|
||||
|
||||
@@ -12,61 +14,20 @@
|
||||
};
|
||||
|
||||
let { currentPath = '/', workspaceId }: Props = $props();
|
||||
|
||||
function workerApiPath(path: string): string {
|
||||
return workspaceApiPath(workspaceId, path);
|
||||
}
|
||||
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let workers = $state<Worker[]>([]);
|
||||
let placeholder = $state<string | null>(null);
|
||||
let workers = $state<SidebarWorker[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (!workspaceId) {
|
||||
loading = false;
|
||||
workers = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
void loadWorkers(controller.signal);
|
||||
return () => controller.abort();
|
||||
const subscription = workspaceWorkersStore(workspaceId);
|
||||
return subscription.subscribe((state) => {
|
||||
loading = state.loading;
|
||||
error = state.error;
|
||||
workers = state.workers
|
||||
.filter(canShowWorkerInSidebar)
|
||||
.slice(0, MAX_VISIBLE_WORKERS);
|
||||
});
|
||||
});
|
||||
|
||||
async function loadWorkers(signal?: AbortSignal) {
|
||||
loading = true;
|
||||
error = null;
|
||||
placeholder = null;
|
||||
try {
|
||||
const response = await fetch(workerApiPath('/workers'), { signal });
|
||||
if (response.status === 404) {
|
||||
workers = [];
|
||||
placeholder = 'Worker API is not integrated in this build yet.';
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`workers request failed (${response.status})`);
|
||||
}
|
||||
const payload = (await response.json()) as ListResponse<Worker>;
|
||||
workers = Array.isArray(payload.items)
|
||||
? payload.items.filter(canShowWorkerInSidebar).slice(0, MAX_VISIBLE_WORKERS)
|
||||
: [];
|
||||
if (workers.length === 0) {
|
||||
placeholder = 'No workers reported by the current API.';
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
error = err instanceof Error ? err.message : 'workers request failed';
|
||||
workers = [];
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="nav-section" aria-labelledby="workers-heading">
|
||||
@@ -94,24 +55,31 @@
|
||||
|
||||
{#if loading}
|
||||
<p class="section-state">Checking workers…</p>
|
||||
{:else if error}
|
||||
<p class="section-state error">{error}</p>
|
||||
{:else if workers.length === 0}
|
||||
<p class="section-state">{placeholder ?? 'Workers will appear here when an API is connected.'}</p>
|
||||
<p class="section-state" class:error={Boolean(error)}>{error ?? 'No Workers are active.'}</p>
|
||||
{:else}
|
||||
{#if error}<p class="section-state error">{error}</p>{/if}
|
||||
<ul class="nav-list" aria-label="Workers">
|
||||
{#each workers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
|
||||
{@const href = workerConsoleHref(worker, workspaceId)}
|
||||
<li>
|
||||
<a href={href} class="nav-item worker-nav-item" class:active={currentPath === href} aria-current={currentPath === href ? 'page' : undefined}>
|
||||
<span class="worker-title-row">
|
||||
<span class="item-title">{worker.display_name || worker.label}</span>
|
||||
<span class="worker-task-title">-</span>
|
||||
</span>
|
||||
<span class="item-meta">
|
||||
worker {worker.worker_id} · {worker.profile ? `${worker.profile} · ` : ''}{worker.state} · 🖥 {worker.host_id}
|
||||
{worker.working_directory ? ` · wd:${worker.working_directory.repository_id}@${worker.working_directory.resolved_commit.slice(0, 8)}` : ''}
|
||||
<a
|
||||
href={href}
|
||||
class="worker-nav-link"
|
||||
class:active={currentPath === href}
|
||||
aria-current={currentPath === href ? 'page' : undefined}
|
||||
>
|
||||
<span class="worker-status-indicator">
|
||||
{#if worker.state === 'idle'}
|
||||
<span class="worker-status-dot" aria-label="Idle"></span>
|
||||
{:else if worker.state === 'running'}
|
||||
<span class="worker-status-spinner" aria-label="Running"></span>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="worker-nav-label">{worker.display_name || worker.label}</span>
|
||||
<small class="worker-nav-meta">
|
||||
{worker.repository_id ?? '—'}・{worker.working_directory_id ?? '—'}
|
||||
</small>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
@@ -211,30 +211,82 @@
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.worker-nav-item {
|
||||
gap: 2px;
|
||||
}
|
||||
.worker-nav-item.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.62;
|
||||
}
|
||||
.worker-nav-item.disabled .item-title {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.worker-title-row {
|
||||
.worker-nav-link {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
.worker-task-title {
|
||||
overflow: hidden;
|
||||
grid-template-columns: 0.75rem minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: var(--space-2);
|
||||
row-gap: 0.1rem;
|
||||
margin: 0.0625rem 0;
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-soft);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
.worker-nav-link:hover {
|
||||
background: var(--interactive-hover);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.worker-nav-link.active {
|
||||
background: var(--interactive-selected);
|
||||
color: var(--accent);
|
||||
}
|
||||
.worker-status-indicator {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
display: grid;
|
||||
width: 0.75rem;
|
||||
min-height: 1.1rem;
|
||||
place-items: center;
|
||||
}
|
||||
.worker-status-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
}
|
||||
.worker-status-spinner {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
border: 0.125rem solid color-mix(in oklch, var(--accent) 25%, transparent);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: worker-status-spin 0.8s linear infinite;
|
||||
}
|
||||
.worker-nav-label {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.worker-nav-meta {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.66rem;
|
||||
line-height: 1rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@keyframes worker-status-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.worker-status-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.sidebar-frame,
|
||||
|
||||
@@ -128,10 +128,11 @@ export type WorkingDirectoryOccupancy = {
|
||||
export type WorkingDirectorySummary = {
|
||||
working_directory_id: string;
|
||||
repository_id: string;
|
||||
requested_selector?: string | null;
|
||||
creation_selector?: string | null;
|
||||
creation_ref?: string | null;
|
||||
current_selector?: string | null;
|
||||
current_ref?: string | null;
|
||||
materializer_kind: string;
|
||||
resolved_commit: string;
|
||||
resolved_tree?: string | null;
|
||||
status: string;
|
||||
cleanliness?: string | null;
|
||||
primary_worker_id?: number | null;
|
||||
@@ -252,7 +253,6 @@ export type WorkerInputResult = {
|
||||
state: WorkerOperationState;
|
||||
runtime_id: string;
|
||||
worker_id: string;
|
||||
event_id?: number | null;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
|
||||
|
||||
@@ -50,9 +50,11 @@ const options: WorkerLaunchOptionsResponse = {
|
||||
{
|
||||
working_directory_id: "wd-1-repo",
|
||||
repository_id: "repo",
|
||||
requested_selector: "HEAD",
|
||||
creation_selector: "HEAD",
|
||||
creation_ref: "0123456789abcdef",
|
||||
current_selector: null,
|
||||
current_ref: "0123456789abcdef",
|
||||
materializer_kind: "local_git_worktree",
|
||||
resolved_commit: "0123456789abcdef",
|
||||
status: "active",
|
||||
cleanliness: "clean",
|
||||
primary_worker_id: null,
|
||||
@@ -159,7 +161,7 @@ Deno.test("defaultWorkerLaunchForm preserves a Ticket repository target", () =>
|
||||
...options.working_directories[0],
|
||||
working_directory_id: "ticket-workdir",
|
||||
repository_id: "ticket-repo",
|
||||
requested_selector: "work/ticket",
|
||||
creation_selector: "work/ticket",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -55,7 +55,8 @@ export function defaultWorkerLaunchForm(
|
||||
Boolean(current.working_directory_repository_id) &&
|
||||
directory.repository_id === current.working_directory_repository_id &&
|
||||
(!current.working_directory_selector ||
|
||||
directory.requested_selector === current.working_directory_selector)
|
||||
(directory.current_selector ?? directory.creation_selector) ===
|
||||
current.working_directory_selector)
|
||||
) ?? availableWorkingDirectories.find((directory) =>
|
||||
Boolean(current.working_directory_repository_id) &&
|
||||
directory.repository_id === current.working_directory_repository_id
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
SubscriptionEventPayload,
|
||||
SubscriptionFrame,
|
||||
SubscriptionWorker,
|
||||
} from '$lib/generated/protocol';
|
||||
|
||||
export type WorkspaceWorkersProjection = {
|
||||
workers: Map<string, SubscriptionWorker>;
|
||||
revisions: Map<string, number>;
|
||||
};
|
||||
|
||||
export function createWorkspaceWorkersProjection(): WorkspaceWorkersProjection {
|
||||
return { workers: new Map(), revisions: new Map() };
|
||||
}
|
||||
|
||||
export function applyWorkspaceWorkersFrame(
|
||||
projection: WorkspaceWorkersProjection,
|
||||
frame: SubscriptionFrame,
|
||||
): void {
|
||||
if (frame.protocol_version !== 1) throw new Error('unsupported Worker subscription protocol');
|
||||
if (frame.frame === 'response' && frame.message.result === 'subscribed') {
|
||||
if (frame.message.payload.selector.topic !== 'workspace_workers') return;
|
||||
const snapshot = frame.message.payload.snapshot;
|
||||
if (snapshot.topic !== 'workers') throw new Error('workspace_workers returned a non-Worker snapshot');
|
||||
projection.workers.clear();
|
||||
projection.revisions.clear();
|
||||
for (const worker of snapshot.data.workers) {
|
||||
const key = workerKey(worker.runtime_id, worker.worker_id);
|
||||
projection.workers.set(key, worker);
|
||||
projection.revisions.set(key, worker.subject_revision);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (frame.frame !== 'event' || frame.message.event !== 'event') return;
|
||||
applyPayload(projection, frame.message.data.subject_revision, frame.message.data.payload);
|
||||
}
|
||||
|
||||
function applyPayload(
|
||||
projection: WorkspaceWorkersProjection,
|
||||
subjectRevision: number,
|
||||
payload: SubscriptionEventPayload,
|
||||
): void {
|
||||
if (payload.event === 'worker_upserted') {
|
||||
const worker = payload.data.worker;
|
||||
const key = workerKey(worker.runtime_id, worker.worker_id);
|
||||
if (subjectRevision <= (projection.revisions.get(key) ?? 0)) return;
|
||||
projection.revisions.set(key, subjectRevision);
|
||||
projection.workers.set(key, worker);
|
||||
} else if (payload.event === 'worker_removed') {
|
||||
const key = workerKey(payload.data.runtime_id, payload.data.worker_id);
|
||||
if (subjectRevision <= (projection.revisions.get(key) ?? 0)) return;
|
||||
projection.revisions.set(key, subjectRevision);
|
||||
projection.workers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function workerKey(runtimeId: string | null | undefined, workerId: string): string {
|
||||
if (!runtimeId) throw new Error('Workspace Worker projection is missing runtime_id');
|
||||
return `${runtimeId}:${workerId}`;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { SubscriptionFrame, SubscriptionWorker } from '$lib/generated/protocol';
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
import {
|
||||
applyWorkspaceWorkersFrame,
|
||||
createWorkspaceWorkersProjection,
|
||||
} from './worker-subscription-model';
|
||||
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
function worker(runtimeId: string, workerId: string, revision: number): SubscriptionWorker {
|
||||
return {
|
||||
worker_id: workerId,
|
||||
runtime_id: runtimeId,
|
||||
subject_revision: revision,
|
||||
state: 'idle',
|
||||
workspace_id: 'workspace-test',
|
||||
display_name: null,
|
||||
profile: null,
|
||||
working_directory_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test('workspace Worker snapshot keeps equal local ids from different Runtimes', () => {
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
const frame: SubscriptionFrame = {
|
||||
protocol_version: 1,
|
||||
frame: 'response',
|
||||
message: {
|
||||
result: 'subscribed',
|
||||
payload: {
|
||||
request_id: 'request-1',
|
||||
subscription_id: 'subscription-1',
|
||||
selector: { topic: 'workspace_workers' },
|
||||
snapshot_revision: 1,
|
||||
snapshot: {
|
||||
topic: 'workers',
|
||||
data: { workers: [worker('runtime-a', '1', 1), worker('runtime-b', '1', 1)] },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
applyWorkspaceWorkersFrame(projection, frame);
|
||||
assertEquals([...projection.workers.keys()].sort(), ['runtime-a:1', 'runtime-b:1']);
|
||||
});
|
||||
|
||||
Deno.test('workspace Worker reducer ignores stale events and removes composite subject', () => {
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
projection.workers.set('runtime-a:1', worker('runtime-a', '1', 3));
|
||||
projection.revisions.set('runtime-a:1', 3);
|
||||
|
||||
applyWorkspaceWorkersFrame(projection, {
|
||||
protocol_version: 1,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 2,
|
||||
payload: { event: 'worker_upserted', data: { worker: worker('runtime-a', '1', 2) } },
|
||||
},
|
||||
},
|
||||
});
|
||||
assertEquals(projection.revisions.get('runtime-a:1'), 3);
|
||||
|
||||
applyWorkspaceWorkersFrame(projection, {
|
||||
protocol_version: 1,
|
||||
frame: 'event',
|
||||
message: {
|
||||
event: 'event',
|
||||
data: {
|
||||
subscription_id: 'subscription-1',
|
||||
subject_revision: 4,
|
||||
payload: {
|
||||
event: 'worker_removed',
|
||||
data: { worker_id: '1', runtime_id: 'runtime-a' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assertEquals(projection.workers.size, 0);
|
||||
assertEquals(projection.revisions.get('runtime-a:1'), 4);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { readable, type Readable } from 'svelte/store';
|
||||
import type { SubscriptionWorker } from '$lib/generated/protocol';
|
||||
import { workspaceMultiplexer } from '$lib/workspace/multiplexer';
|
||||
import {
|
||||
applyWorkspaceWorkersFrame,
|
||||
createWorkspaceWorkersProjection,
|
||||
} from './worker-subscription-model';
|
||||
import { compareWorkersForSidebar } from './workers';
|
||||
import type { Worker } from './types';
|
||||
|
||||
export type SidebarWorker = Worker & {
|
||||
repository_id: string | null;
|
||||
working_directory_id: string | null;
|
||||
};
|
||||
|
||||
export type WorkspaceWorkersState = {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
workers: SidebarWorker[];
|
||||
};
|
||||
|
||||
const stores = new Map<string, Readable<WorkspaceWorkersState>>();
|
||||
|
||||
export function workspaceWorkersStore(workspaceId: string): Readable<WorkspaceWorkersState> {
|
||||
const cached = stores.get(workspaceId);
|
||||
if (cached) return cached;
|
||||
const store = readable<WorkspaceWorkersState>(
|
||||
{ loading: true, error: null, workers: [] },
|
||||
(set) => {
|
||||
if (!workspaceId) {
|
||||
set({ loading: false, error: null, workers: [] });
|
||||
return;
|
||||
}
|
||||
const projection = createWorkspaceWorkersProjection();
|
||||
const publish = (loading = false, error: string | null = null) => {
|
||||
const workers = [...projection.workers.values()]
|
||||
.map(projectWorker)
|
||||
.sort(compareWorkersForSidebar);
|
||||
set({ loading, error, workers });
|
||||
};
|
||||
const subscription = workspaceMultiplexer(workspaceId).subscribe(
|
||||
{ topic: 'workspace_workers' },
|
||||
{
|
||||
onFrame: (frame) => {
|
||||
try {
|
||||
if (frame.frame === 'event' && frame.message.event === 'subscription_closed') {
|
||||
throw new Error(frame.message.data.message);
|
||||
}
|
||||
if (
|
||||
frame.frame === 'response' &&
|
||||
frame.message.result === 'subscription_rejected'
|
||||
) {
|
||||
throw new Error(frame.message.payload.message);
|
||||
}
|
||||
applyWorkspaceWorkersFrame(projection, frame);
|
||||
publish(false, null);
|
||||
} catch (error) {
|
||||
publish(false, error instanceof Error ? error.message : 'invalid Worker subscription frame');
|
||||
}
|
||||
},
|
||||
onStatus: (status, message) => {
|
||||
if (status === 'connecting') publish(projection.workers.size === 0, null);
|
||||
if (status === 'closed') publish(projection.workers.size === 0, message ?? null);
|
||||
},
|
||||
},
|
||||
);
|
||||
return () => subscription.close();
|
||||
},
|
||||
);
|
||||
stores.set(workspaceId, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
function projectWorker(worker: SubscriptionWorker): SidebarWorker {
|
||||
if (!worker.runtime_id) throw new Error('Workspace Worker projection is missing runtime_id');
|
||||
const displayName = worker.display_name ?? `Worker ${worker.worker_id}`;
|
||||
return {
|
||||
runtime_id: worker.runtime_id,
|
||||
worker_id: worker.worker_id,
|
||||
host_id: worker.runtime_id,
|
||||
display_name: displayName,
|
||||
label: displayName,
|
||||
profile: worker.profile ?? null,
|
||||
tags: [],
|
||||
workspace: { visibility: 'workspace', identity: 'runtime_subscription_worker' },
|
||||
state: worker.state,
|
||||
pinned: false,
|
||||
retention_state: 'transient',
|
||||
implementation: {
|
||||
kind: 'runtime_subscription_worker',
|
||||
display_hint: 'Workspace-authorized Runtime Worker',
|
||||
},
|
||||
capabilities: {
|
||||
can_stop: worker.state !== 'stopped' && worker.state !== 'cancelled',
|
||||
can_spawn_followup: false,
|
||||
},
|
||||
repository_id: worker.repository_id ?? null,
|
||||
working_directory_id: worker.working_directory_id ?? null,
|
||||
working_directory: null,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { canOpenWorkerConsole, canShowWorkerInSidebar } from "./workers.ts";
|
||||
import {
|
||||
canOpenWorkerConsole,
|
||||
canShowWorkerInSidebar,
|
||||
compareWorkersForSidebar,
|
||||
} from "./workers.ts";
|
||||
import type { Worker } from "./types.ts";
|
||||
|
||||
declare const Deno: {
|
||||
@@ -61,3 +65,14 @@ Deno.test("live runtime workers are sidebar targets and console targets", () =>
|
||||
assertEquals(canShowWorkerInSidebar(liveWorker), true);
|
||||
assertEquals(canOpenWorkerConsole(liveWorker), true);
|
||||
});
|
||||
|
||||
Deno.test("sidebar workers sort idle then running then stopped", () => {
|
||||
const workers = [
|
||||
worker({ worker_id: "3", display_name: "Stopped", state: "stopped" }),
|
||||
worker({ worker_id: "2", display_name: "Running", state: "running" }),
|
||||
worker({ worker_id: "4", display_name: "Idle B", state: "idle" }),
|
||||
worker({ worker_id: "1", display_name: "Idle A", state: "idle" }),
|
||||
];
|
||||
workers.sort(compareWorkersForSidebar);
|
||||
assertEquals(workers.map((candidate) => candidate.worker_id).join(","), "1,4,2,3");
|
||||
});
|
||||
|
||||
@@ -1,9 +1,39 @@
|
||||
import type { Worker } from "./types";
|
||||
import type { Worker } from './types';
|
||||
|
||||
export function canShowWorkerInSidebar(worker: Worker): boolean {
|
||||
return worker.implementation.kind !== "backend_worker_registry";
|
||||
return worker.implementation.kind !== 'backend_worker_registry';
|
||||
}
|
||||
|
||||
export function canOpenWorkerConsole(worker: Worker): boolean {
|
||||
return canShowWorkerInSidebar(worker);
|
||||
}
|
||||
|
||||
type SortableWorker = Pick<
|
||||
Worker,
|
||||
'state' | 'display_name' | 'runtime_id' | 'worker_id'
|
||||
>;
|
||||
|
||||
function workerStateRank(state: Worker['state']): number {
|
||||
switch (state) {
|
||||
case 'idle':
|
||||
return 0;
|
||||
case 'running':
|
||||
return 1;
|
||||
case 'stopped':
|
||||
return 2;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
export function compareWorkersForSidebar(
|
||||
left: SortableWorker,
|
||||
right: SortableWorker,
|
||||
): number {
|
||||
const stateOrder = workerStateRank(left.state) - workerStateRank(right.state);
|
||||
if (stateOrder !== 0) return stateOrder;
|
||||
return (left.display_name ?? left.worker_id).localeCompare(
|
||||
right.display_name ?? right.worker_id,
|
||||
) || left.runtime_id.localeCompare(right.runtime_id) ||
|
||||
left.worker_id.localeCompare(right.worker_id);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { setContext } from 'svelte';
|
||||
import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte';
|
||||
import { provideHeaderController, type HeaderController } from '$lib/workspace/header/context';
|
||||
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
|
||||
import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte';
|
||||
import { SIDEBAR_CONTEXT, type SidebarSnippet } from '$lib/workspace/sidebar/context';
|
||||
@@ -10,7 +11,9 @@
|
||||
|
||||
let { children }: LayoutProps = $props();
|
||||
let sidebar = $state<SidebarSnippet | null>(null);
|
||||
const headerController = $state<HeaderController>({ content: null });
|
||||
|
||||
provideHeaderController(headerController);
|
||||
setContext(SIDEBAR_CONTEXT, {
|
||||
setSidebar(snippet: SidebarSnippet) {
|
||||
sidebar = snippet;
|
||||
@@ -32,6 +35,9 @@
|
||||
{/if}
|
||||
</SidebarFrame>
|
||||
<header class="workspace-topbar">
|
||||
<div class="workspace-topbar-location">
|
||||
{#if headerController.content}{@render headerController.content()}{/if}
|
||||
</div>
|
||||
<nav class="workspace-topbar-actions" aria-label="Global navigation">
|
||||
<a class="topbar-icon-button" href="/account" aria-label="Open Account" title="Account">
|
||||
<svg class="topbar-icon" aria-hidden="true" viewBox="0 0 24 24">
|
||||
@@ -64,7 +70,8 @@
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
min-height: 3.25rem;
|
||||
padding: 0 var(--space-5);
|
||||
@@ -73,6 +80,11 @@
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.workspace-topbar-location {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-topbar-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
|
||||
import WorkspaceBreadcrumbs from '$lib/workspace/header/WorkspaceBreadcrumbs.svelte';
|
||||
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
|
||||
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
|
||||
import '$lib/workspace/styles/workspace-pages.css';
|
||||
@@ -10,6 +12,10 @@
|
||||
let { data, children }: LayoutProps = $props();
|
||||
</script>
|
||||
|
||||
{#snippet workspaceHeader()}
|
||||
<WorkspaceBreadcrumbs workspaceId={page.params.workspaceId ?? data.workspace?.workspace_id ?? ''} />
|
||||
{/snippet}
|
||||
|
||||
{#snippet workspaceSidebar()}
|
||||
<WorkspaceSidebar
|
||||
workspace={data.workspace ?? null}
|
||||
@@ -20,6 +26,7 @@
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
<HeaderOverride content={workspaceHeader} />
|
||||
<SidebarOverride sidebar={workspaceSidebar} />
|
||||
|
||||
{@render children()}
|
||||
|
||||
@@ -42,7 +42,6 @@
|
||||
<div class="objective-title-row detail">
|
||||
<div>
|
||||
<h3>{data.objective.title}</h3>
|
||||
<p><code>{data.objective.id}</code></p>
|
||||
</div>
|
||||
<span class="state-pill">{data.objective.state}</span>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
<div class="repository-detail-heading">
|
||||
<div>
|
||||
<h3>{data.repository.item.display_name}</h3>
|
||||
<p><code>{data.repository.item.id}</code></p>
|
||||
</div>
|
||||
<span class="status-pill" class:warn={data.repository.item.git?.status !== 'clean'}>{data.repository.item.git?.status ?? 'not observed'}</span>
|
||||
</div>
|
||||
|
||||
+89
-86
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { tick, untrack } from "svelte";
|
||||
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
|
||||
import ConsoleTimeline from "$lib/workspace/console/ConsoleTimeline.svelte";
|
||||
import { chatSubmit } from "$lib/workspace/console/chat-submit";
|
||||
@@ -24,6 +24,7 @@
|
||||
} from "$lib/workspace/console/model";
|
||||
import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol";
|
||||
import { workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import { workspaceMultiplexer, type WorkspaceMultiplexerSubscription } from "$lib/workspace/multiplexer";
|
||||
import type {
|
||||
Diagnostic,
|
||||
Worker,
|
||||
@@ -35,6 +36,8 @@
|
||||
workspaceId: string;
|
||||
runtimeId: string;
|
||||
workerId: string;
|
||||
worker: Worker | null;
|
||||
workerError: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -84,9 +87,11 @@
|
||||
client: number;
|
||||
};
|
||||
|
||||
let worker = $state<Worker | null>(null);
|
||||
let liveWorkerState = $state<string | null>(null);
|
||||
let workerError = $state<string | null>(null);
|
||||
let worker = $state<Worker | null>(untrack(() => data.worker));
|
||||
let liveWorkerState = $state<string | null>(
|
||||
untrack(() => data.worker?.state ?? null),
|
||||
);
|
||||
let workerError = $state<string | null>(untrack(() => data.workerError));
|
||||
let draft = $state("");
|
||||
let completionEntries = $state<ComposerCompletionEntry[]>([]);
|
||||
let completionToken = $state<ComposerCompletionToken | null>(null);
|
||||
@@ -101,7 +106,7 @@
|
||||
let protocolState = $state<"connecting" | "open" | "closed" | "error">(
|
||||
"connecting",
|
||||
);
|
||||
let protocolSocket: WebSocket | null = null;
|
||||
let protocolSubscription: WorkspaceMultiplexerSubscription | null = null;
|
||||
let pendingCompletionRequest: {
|
||||
resolve: (entries: ComposerCompletionEntry[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
@@ -135,11 +140,12 @@
|
||||
let reloadToken = $state(0);
|
||||
|
||||
type ConsoleTarget = {
|
||||
workspaceId: string;
|
||||
runtimeId: string;
|
||||
workerId: string;
|
||||
};
|
||||
|
||||
const consoleTarget = $derived({ runtimeId, workerId });
|
||||
const consoleTarget = $derived({ workspaceId, runtimeId, workerId });
|
||||
|
||||
const lines = $derived(consoleProjection.lines);
|
||||
const timelineLayout = $derived(
|
||||
@@ -174,17 +180,20 @@
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function loadWorker(target: ConsoleTarget) {
|
||||
async function loadWorker(target: ConsoleTarget, token: number) {
|
||||
workerError = null;
|
||||
try {
|
||||
const payload = await getJson<Worker>(
|
||||
workerApiPath(
|
||||
workspaceApiPath(
|
||||
target.workspaceId,
|
||||
`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`,
|
||||
),
|
||||
);
|
||||
if (token !== reloadToken) return;
|
||||
worker = payload;
|
||||
liveWorkerState = payload.state;
|
||||
} catch (error) {
|
||||
if (token !== reloadToken) return;
|
||||
workerError =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
worker = null;
|
||||
@@ -192,10 +201,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConsoleData(target: ConsoleTarget) {
|
||||
await loadWorker(target);
|
||||
}
|
||||
|
||||
function advanceReloadToken(): number {
|
||||
nextReloadToken += 1;
|
||||
reloadToken = nextReloadToken;
|
||||
@@ -510,80 +515,76 @@
|
||||
return;
|
||||
}
|
||||
protocolState = "connecting";
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsPath = workerApiPath(
|
||||
`/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(
|
||||
target.workerId,
|
||||
)}/protocol/ws`,
|
||||
const subscription = workspaceMultiplexer(target.workspaceId).subscribe(
|
||||
{
|
||||
topic: "worker_protocol",
|
||||
worker_id: target.workerId,
|
||||
runtime_id: target.runtimeId,
|
||||
},
|
||||
{
|
||||
onFrame: (frame) => {
|
||||
if (token !== reloadToken) return;
|
||||
try {
|
||||
if (
|
||||
frame.frame === "response" &&
|
||||
frame.message.result === "subscribed" &&
|
||||
frame.message.payload.snapshot.topic === "worker_protocol"
|
||||
) {
|
||||
for (const event of frame.message.payload.snapshot.data.events) {
|
||||
handleIncomingProtocolEvent(event);
|
||||
}
|
||||
protocolState = "open";
|
||||
} else if (
|
||||
frame.frame === "event" &&
|
||||
frame.message.event === "event" &&
|
||||
frame.message.data.payload.event === "worker_protocol"
|
||||
) {
|
||||
handleIncomingProtocolEvent(frame.message.data.payload.data.event);
|
||||
} else if (
|
||||
frame.frame === "event" &&
|
||||
frame.message.event === "subscription_closed"
|
||||
) {
|
||||
protocolState = "closed";
|
||||
rejectPendingCompletion(new Error(frame.message.data.message));
|
||||
} else if (
|
||||
frame.frame === "response" &&
|
||||
frame.message.result === "subscription_rejected"
|
||||
) {
|
||||
protocolState = "error";
|
||||
throw new Error(frame.message.payload.message);
|
||||
}
|
||||
} catch (error) {
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
{
|
||||
code: "worker_protocol_frame_invalid",
|
||||
severity: "warning",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
onStatus: (status) => {
|
||||
if (token !== reloadToken) return;
|
||||
protocolState = status === "open" ? "connecting" : status;
|
||||
if (status === "closed") {
|
||||
rejectPendingCompletion(new Error("Worker protocol WebSocket closed."));
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
const ws = new WebSocket(
|
||||
`${protocol}//${window.location.host}${wsPath}`,
|
||||
);
|
||||
protocolSocket = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (token === reloadToken) {
|
||||
protocolState = "open";
|
||||
}
|
||||
};
|
||||
ws.onmessage = (message) => {
|
||||
if (token !== reloadToken) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handleIncomingProtocolEvent(
|
||||
JSON.parse(String(message.data)) as ProtocolEvent,
|
||||
);
|
||||
} catch (error) {
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
{
|
||||
code: "worker_protocol_frame_invalid",
|
||||
severity: "warning",
|
||||
message:
|
||||
error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
if (token === reloadToken) {
|
||||
protocolState = "error";
|
||||
streamDiagnostics = [
|
||||
...streamDiagnostics,
|
||||
{
|
||||
code: "worker_protocol_ws_error",
|
||||
severity: "error",
|
||||
message: "Worker protocol WebSocket failed.",
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (protocolSocket === ws) {
|
||||
protocolSocket = null;
|
||||
}
|
||||
if (token === reloadToken && protocolState !== "error") {
|
||||
protocolState = "closed";
|
||||
}
|
||||
rejectPendingCompletion(
|
||||
new Error("Worker protocol WebSocket closed."),
|
||||
);
|
||||
};
|
||||
|
||||
protocolSubscription = subscription;
|
||||
return () => {
|
||||
if (protocolSocket === ws) {
|
||||
protocolSocket = null;
|
||||
}
|
||||
ws.close();
|
||||
if (protocolSubscription === subscription) protocolSubscription = null;
|
||||
subscription.close();
|
||||
};
|
||||
}
|
||||
|
||||
function sendProtocolMethod(method: ProtocolMethod) {
|
||||
if (!protocolSocket || protocolSocket.readyState !== WebSocket.OPEN) {
|
||||
if (!protocolSubscription || protocolState !== "open") {
|
||||
throw new Error("Worker protocol WebSocket is not open.");
|
||||
}
|
||||
protocolSocket.send(JSON.stringify(method));
|
||||
protocolSubscription.sendWorkerMethod(method);
|
||||
}
|
||||
|
||||
function handleProtocolCommandEvent(event: ProtocolEvent) {
|
||||
@@ -1082,11 +1083,16 @@
|
||||
|
||||
$effect(() => {
|
||||
const target = consoleTarget;
|
||||
const targetWorker = data.worker;
|
||||
const targetWorkerError = data.workerError;
|
||||
resetObservedEvents();
|
||||
liveWorkerState = null;
|
||||
worker = targetWorker;
|
||||
workerError = targetWorkerError;
|
||||
liveWorkerState = targetWorker?.state ?? null;
|
||||
streamDiagnostics = [];
|
||||
advanceReloadToken();
|
||||
void loadConsoleData(target);
|
||||
protocolState = "connecting";
|
||||
const token = advanceReloadToken();
|
||||
if (!targetWorker) void loadWorker(target, token);
|
||||
});
|
||||
|
||||
$effect(() => connectProtocolTransport(worker, reloadToken, consoleTarget));
|
||||
@@ -1101,10 +1107,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="console-shell worker-console-shell">
|
||||
<section class="console-header card">
|
||||
<div>
|
||||
<h2>{worker?.label ?? workerId}</h2>
|
||||
</div>
|
||||
<section class="console-header card" aria-label="Worker controls">
|
||||
<div class="console-header-actions">
|
||||
<div
|
||||
class="console-status-pill"
|
||||
@@ -1413,7 +1416,7 @@
|
||||
.console-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
|
||||
+18
-6
@@ -1,11 +1,23 @@
|
||||
export function load(
|
||||
{ params }: {
|
||||
params: { workspaceId: string; runtimeId: string; workerId: string };
|
||||
},
|
||||
) {
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import type { Worker } from "$lib/workspace/sidebar/types";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const worker = await loadJson<Worker>(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
params.workspaceId,
|
||||
`/runtimes/${encodeURIComponent(params.runtimeId)}/workers/${
|
||||
encodeURIComponent(params.workerId)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
runtimeId: params.runtimeId,
|
||||
workerId: params.workerId,
|
||||
worker: worker.data,
|
||||
workerError: worker.error,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user