30 Commits
Author SHA1 Message Date
Hare e6a2da548f fs: extract provider operations into shared crate 2026-08-04 02:03:22 +09:00
Hare e5f0c4168f workdir: add authenticated runtime session transport 2026-08-03 23:37:15 +09:00
Hare 05e8b00bf0 workdir: separate identity from worker session 2026-08-03 18:12:47 +09:00
Hare 0ffaa6c741 workspace: remove worker credential refresh flow 2026-08-03 17:08:23 +09:00
Hare ddadc830ac workdir: add network-capable operation boundary 2026-08-03 16:14:01 +09:00
Hare 0fa36395e7 web: show worker status in sidebar 2026-08-02 04:18:16 +09:00
Hare 606cd5fa31 web: subscribe console on route changes 2026-08-02 03:43:53 +09:00
Hare e5f3c20f64 server: restore weak workspace web access 2026-08-02 03:24:54 +09:00
Hare e530150e43 auth: bootstrap legacy workspace ownership 2026-08-02 00:26:28 +09:00
Hare 0f9f06048a runtime: remove legacy event polling authority 2026-08-01 22:55:58 +09:00
Hare 24f7267d55 web: share workspace multiplexer with console 2026-08-01 21:07:45 +09:00
Hare 11f26a1090 server: multiplex worker protocol subscriptions 2026-08-01 21:07:45 +09:00
Hare 72cef5ed9e web: subscribe sidebar to workspace workers 2026-08-01 20:12:27 +09:00
Hare b977c4cbad server: stream workspace worker subscriptions 2026-08-01 20:12:27 +09:00
Hare 5d4deb258a server: expose subscription runtime catalog 2026-08-01 19:34:50 +09:00
Hare f46c82d171 server: broker embedded runtime subscriptions 2026-08-01 19:23:49 +09:00
Hare 42c69f9f6c protocol: identify workspace subscription runtimes 2026-08-01 19:11:41 +09:00
Hare 9308f93de7 server: broker runtime event subscriptions 2026-08-01 19:04:05 +09:00
Hare ecd5751a67 docs: report spawn worker delegation scope 2026-08-01 18:34:32 +09:00
Hare ec262f0238 runtime: serve selective event subscriptions 2026-08-01 18:34:32 +09:00
Hare 21cd672f64 protocol: define multiplexer subscriptions 2026-08-01 18:34:32 +09:00
Hare 9dfcddf40b docs: report spawned worker launcher mismatch 2026-08-01 17:24:10 +09:00
Hare 81e631e640 auth: enforce workspace worker credentials over ticket REST 2026-08-01 17:23:59 +09:00
Hare 3412f1c0ed merge: integrate ticket assignment notifications
# Conflicts:
#	crates/workspace-server/src/store.rs
2026-07-31 23:59:09 +09:00
Hare 48556e1a2c feat: add workspace breadcrumbs 2026-07-31 23:48:46 +09:00
Hare 06a98fe30c workdir: report current selector and ref 2026-07-31 23:48:46 +09:00
Hare 4cf110b28e runtime: replay Worker creation before workdir conflicts 2026-07-31 23:28:07 +09:00
Hare df34d43a23 server: close Ticket lifecycle crash gaps 2026-07-31 23:12:05 +09:00
Hare e9a6269d9f server: address Ticket orchestration review 2026-07-31 22:15:55 +09:00
Hare 005f6cb498 server: route Ticket mutation notifications 2026-07-31 20:15:11 +09:00
105 changed files with 16909 additions and 5045 deletions
Generated
+37 -5
View File
@@ -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",
]
+6
View File
@@ -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" }
+7 -5
View File
@@ -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)]
+19
View File
@@ -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
+56
View File
@@ -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,
})
}
+282
View File
@@ -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(&current).ok()?;
if !metadata.file_type().is_symlink() {
continue;
}
let raw_target = std::fs::read_link(&current).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"));
}
}
+330
View File
@@ -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,
},
}
}
+212
View File
@@ -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,
}
+404
View File
@@ -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)
}
}
+4 -4
View File
@@ -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 -3
View File
@@ -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
View File
@@ -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
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+2 -5
View File
@@ -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
View File
@@ -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(&params.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
View File
@@ -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(&params.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(&params.file_path, &ctx).await;
// Load current content and verify it matches the recorded hash.
let current_bytes = self.fs.read_bytes(&params.file_path)?;
self.tracker.verify(&params.file_path, &current_bytes)?;
let current_text = std::str::from_utf8(&current_bytes).map_err(|_| {
ToolsError::InvalidArgument(format!(
"file is not valid UTF-8: {}",
params.file_path.display()
))
})?;
let count = current_text.matches(&params.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(&params.old_string, &params.new_string)
} else {
current_text.replacen(&params.old_string, &params.new_string, 1)
};
let occurrences = if params.replace_all { count } else { 1 };
self.fs.write(&params.file_path, new_text.as_bytes())?;
self.tracker.record(&params.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, &params.new_string);
let preview = make_preview(&params.new_string, &params.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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(&params.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(&params.file_path)?;
// Record the raw bytes under the read-history so subsequent Edit /
// Write can detect external modification.
self.tracker.record(&params.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())
-719
View File
@@ -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(_)
));
}
}
+40 -6
View File
@@ -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
View File
@@ -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(&params.file_path).map_err(ToolsError::from)?;
tracing::debug!(path = %path, bytes = params.content.len(), "Write");
let _mutation_permit = self.tracker.acquire_mutation(&params.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(&params.file_path)?;
self.tracker.verify(&params.file_path, &current)?;
}
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(&params.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(&params.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",
});
+33 -30
View File
@@ -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": "ラスト",
})
+33 -56
View File
@@ -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(
&reg.get("Read"),
json!({ "file_path": a.to_str().unwrap() }),
)
.await;
call(&reg.get("Read"), json!({ "file_path": "a.txt" })).await;
// Write `b` (new file) — should appear ahead of `a`.
call(
&reg.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(
&reg.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(&reg.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
+24
View File
@@ -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
+499
View File
@@ -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"));
}
}
+293
View File
@@ -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
+39
View File
@@ -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,
}
+1
View File
@@ -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
+29 -9
View File
@@ -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,
}
+20
View File
@@ -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,
+15 -150
View File
@@ -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
-1
View File
@@ -52,5 +52,4 @@ impl WorkerInput {
pub struct WorkerInteractionAck {
pub worker_ref: WorkerRef,
pub status: WorkerStatus,
pub event_id: u64,
}
+1 -1
View File
@@ -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};
-13
View File
@@ -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:
+1 -26
View File
@@ -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")]
-50
View File
@@ -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
+126 -21
View File
@@ -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)]
);
}
+56 -8
View File
@@ -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();
+1
View File
@@ -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"
+32 -18
View File
@@ -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(&params.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(_))));
+69 -58
View File
@@ -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 WorkerWorkdir 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
+77 -103
View File
@@ -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()));
+81 -54
View File
@@ -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],
+368 -79
View File
@@ -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
View File
@@ -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());
}
}
+1 -1
View File
@@ -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 => {
+5 -2
View File
@@ -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,
};
+4 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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 WorkerWorkdir 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]
+72
View File
@@ -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 {
+2 -1
View File
@@ -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
+437 -47
View File
@@ -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" }
})
}
}
+6
View File
@@ -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();
}
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.
+1 -1
View File
@@ -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"
},
+134 -269
View File
@@ -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);
}
+13 -1
View File
@@ -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>
@@ -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);
}
@@ -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