Author SHA1 Message Date
Hare 925100fb82 fix: stabilize configuration editor completion 2026-09-02 14:18:51 +09:00
Hare a664e72488 feat: merge Composer paste handling from develop 2026-09-01 22:06:01 +09:00
Hare 21317123a4 fix: deduplicate CodeMirror completion state 2026-09-01 22:03:25 +09:00
Hare 409245cb52 fix: fence disabled Composer mutations 2026-09-01 21:00:26 +09:00
Hare 1aeb6fdb35 fix: enforce Composer read-only state 2026-09-01 20:48:16 +09:00
Hare 816fa96e07 fix: restore workers with remote workdir attachments 2026-09-01 20:41:37 +09:00
Hare 8fbe4218c6 fix: delete complete Composer selections 2026-09-01 20:34:23 +09:00
Hare 00c8df0fc9 fix: preserve exact short paste text 2026-09-01 20:19:53 +09:00
Hare c52c7ead19 feat: add atomic paste chips to Web Composer 2026-09-01 19:50:26 +09:00
Hare d1e8a827c2 fix: preserve configuration completion context 2026-09-01 18:59:16 +09:00
Hare 12d96fb03d fix: preserve short paste line endings 2026-09-01 18:49:44 +09:00
Hare 171a191873 test: run shared composer paste contract 2026-09-01 18:27:21 +09:00
Hare 47dabd8793 fix: keep short composer pastes as text 2026-09-01 18:23:23 +09:00
Hare 2bb661f1cf fix: complete paste artifact storage contract 2026-09-01 17:51:24 +09:00
Hare 04e296a4ef feat: store large paste inputs as artifacts 2026-09-01 17:26:58 +09:00
Hare 4bba227af5 fix: remove legacy resume flag 2026-09-01 16:10:59 +09:00
Hare 5cc78d63c6 fix: merge Companion repository response validation 2026-09-01 13:21:13 +09:00
Hare 323f5dc09c fix: validate repository settings wire response 2026-09-01 13:13:01 +09:00
Hare fb97edfe95 fix: validate workspace schema before owner migration 2026-09-01 12:41:14 +09:00
Hare 070f62ef12 fix: harden workspace owner migration preflight 2026-09-01 12:22:29 +09:00
Hare 981749aa3d feat: require account owners for workspaces 2026-09-01 12:10:52 +09:00
Hare e01b46b30a chore: shard Cargo build cache by workspace 2026-09-01 09:33:47 +09:00
Hare a1b659c45d chore: merge develop into hare/develop
# Conflicts:
#	crates/client/src/lib.rs
#	web/workspace/deno.json
2026-09-01 09:15:10 +09:00
Hare 37a012ef92 chore: configure shared Cargo build caching 2026-09-01 09:04:00 +09:00
Hare 4927e8a843 chore: merge current develop into workspace catalog DTO source
# Conflicts:
#	crates/workspace-api/src/lib.rs
#	crates/workspace-server/src/server.rs
#	web/workspace/deno.json
#	web/workspace/src/lib/workspace/sidebar/types.ts
2026-09-01 08:17:25 +09:00
Hare 025d6ddb47 refactor: make workspace API own workdir projections 2026-09-01 03:16:31 +09:00
Hare 01a4dfd5d3 fix: remove unused workspace API dependency 2026-09-01 02:52:11 +09:00
Hare 1d7158a0bf chore: merge current develop into T-563 source 2026-09-01 02:39:08 +09:00
Hare 9de2afbfc6 fix: validate ticket repository summaries 2026-09-01 02:37:44 +09:00
Hare 9013754a3a refactor: name the workspace catalog list response 2026-09-01 02:24:11 +09:00
Hare 21eea0b104 fix: consume shared workdir create contract 2026-09-01 02:14:57 +09:00
Hare 15e8d7365c test: run workspace DTO boundary regressions 2026-09-01 02:05:07 +09:00
Hare 88e3bf7065 fix: preserve optional workdir wire fields 2026-09-01 02:00:19 +09:00
Hare 2765138bf3 chore: merge current develop into Companion DTO work
# Conflicts:
#	web/workspace/deno.json
2026-09-01 01:59:39 +09:00
Hare 6b20ceac46 fix: validate workspace repository browser payloads 2026-09-01 01:49:38 +09:00
Hare d748274905 refactor: centralize workspace repository response DTOs 2026-09-01 01:49:25 +09:00
Hare e1578217d5 fix: preserve workdir status display contract 2026-09-01 01:45:32 +09:00
Hare 3481682cb4 feat: validate shared workdir REST contracts in web 2026-09-01 01:42:17 +09:00
Hare 996b7f2468 refactor: centralize workdir REST DTOs 2026-09-01 01:42:05 +09:00
Hare 879993b9b1 fix: accept zero-length Companion transcripts 2026-09-01 01:41:16 +09:00
Hare 6604154e3f test: cover repository access loader failures 2026-09-01 01:39:55 +09:00
Hare a9ad42a970 fix: align Companion REST DTO contracts 2026-09-01 01:29:04 +09:00
Hare 8b3d1302c6 fix: share repository access API contracts 2026-09-01 01:22:27 +09:00
Hare ac9269d6ce fix: omit local bash spill path from remote commands 2026-08-31 23:14:33 +09:00
Hare 8ffb716817 fix: preserve workdir operation error classifications 2026-08-31 23:14:23 +09:00
Hare 23f671fa48 fix: remove redundant settings overview 2026-08-31 20:09:29 +09:00
Hare d7cdcde443 fix: preserve standalone scope conflict details 2026-08-31 19:28:09 +09:00
Hare 95a81faf63 chore: merge hare/develop into companion work 2026-08-31 18:45:32 +09:00
Hare bb8bb6d099 feat: restore interactive standalone profile selection 2026-08-31 18:43:46 +09:00
Hare 310801a29b feat: add repository settings registration 2026-08-31 18:20:17 +09:00
Hare 7d09b20445 feat: add workspace repository registration API 2026-08-31 18:12:08 +09:00
Hare 456a06f194 fix: propagate browser actor to workspace routes 2026-08-31 16:55:45 +09:00
Hare 62eaefb1fa feat: spill long bash output to worker temp storage 2026-08-31 16:52:27 +09:00
Hare 10264b4019 Merge branch 'work/companion' into hare/develop 2026-08-31 15:51:36 +09:00
Hare ab9765d91d feat: refine workspace navigation chrome 2026-08-31 15:50:49 +09:00
Hare e7f4c6864f fix: make WorkerId the standalone primary identity 2026-08-31 14:37:46 +09:00
Hare bde1dea2a5 fix: require confirmation before Ctrl-X shutdown 2026-08-31 13:07:39 +09:00
Hare 13a021c480 feat: add TUI run status spinner 2026-08-31 12:46:47 +09:00
Hare a7f09fad98 fix: route standalone protocol through client transports 2026-08-31 12:11:35 +09:00
Hare 10eaf4a5fb refactor: name worker event channels by role 2026-08-31 11:21:14 +09:00
Hare bcada300e3 fix: mark standalone TUI connection ready 2026-08-31 09:44:13 +09:00
Hare 9756174676 chore: merge standalone feature into companion integration
# Conflicts:
#	crates/client/src/target.rs
#	crates/client/src/ticket_role.rs
#	crates/manifest/src/profile.rs
#	crates/tui/src/dashboard/tests.rs
#	crates/tui/src/worker_list.rs
#	crates/workspace-server/src/hosts.rs
#	crates/yoi/src/main.rs
2026-08-31 02:01:38 +09:00
Hare 6dd8461a46 chore: merge develop into companion integration
# Conflicts:
#	crates/workspace-server/src/server.rs
2026-08-31 01:51:13 +09:00
Hare 12cc2eb0e9 feat: unify workspace settings and runtime resources 2026-08-31 01:32:31 +09:00
Hare 38dd717aa6 test: lock discovery control grant separation 2026-08-30 23:07:27 +09:00
Hare 6640c902de feat: add privileged workspace worker discovery 2026-08-30 22:48:26 +09:00
Hare 583c343d08 fix: reject duplicate backend selectors 2026-08-30 21:57:50 +09:00
Hare dfc48f7a05 fix: preserve login backend selection 2026-08-30 21:48:07 +09:00
Hare cd9d854595 fix: reject standalone runtime selectors 2026-08-30 21:36:33 +09:00
Hare 766cbd17e5 refactor: centralize worker control routing 2026-08-30 21:26:29 +09:00
Hare 6df95bf981 fix: make workers help backend specific 2026-08-30 21:26:27 +09:00
Hare 6398ca0893 docs: clarify standalone resume help 2026-08-30 21:16:45 +09:00
Hare 62372a48cc fix: parse product help before target resolution 2026-08-30 21:08:08 +09:00
Hare 733632509a fix: keep product help target independent 2026-08-30 20:57:16 +09:00
Hare e1c13ec314 chore: merge final develop into T-556 2026-08-30 20:49:02 +09:00
Hare 928ff0eabe fix: isolate standalone target resolution 2026-08-30 20:45:40 +09:00
Hare 406559b13d refactor: remove legacy local client authority 2026-08-30 20:35:26 +09:00
Hare 20c16aa6fd chore: merge current develop into T-556 2026-08-30 20:32:20 +09:00
Hare 44ba5fd6d4 fix: expose canonical SubWorker control tools 2026-08-30 20:25:17 +09:00
Hare 745c6adbf2 fix: apply Grep filters to direct files 2026-08-30 20:15:01 +09:00
Hare a9aa09636f fix: preserve Workdir operation error classification 2026-08-30 20:07:46 +09:00
Hare 6945986dd1 fix: use canonical standalone session path 2026-08-30 19:53:31 +09:00
Hare 80c1f48f0e fix: allow Grep to target a file 2026-08-30 19:50:57 +09:00
Hare 31e18205f0 fix: fail closed on unknown standalone leases 2026-08-30 19:44:15 +09:00
Hare e84a9d3f9b fix: synchronize Resume busy state from protocol events 2026-08-30 14:11:12 +09:00
Hare 133feb8c76 feat: add standalone resume picker 2026-08-30 13:46:24 +09:00
Hare c5fd9c01e5 feat: persist standalone sessions for restore 2026-08-30 13:46:16 +09:00
Hare a7bf5ceac3 test: update client session snapshot fixture 2026-08-30 13:23:56 +09:00
Hare 74139aeb7e chore: merge develop into hare/develop 2026-08-30 13:19:24 +09:00
Hare 0cae4fd05c fix: retry device login code collisions 2026-08-30 12:55:12 +09:00
Hare 4b3b4fda61 feat: connect standalone host to local TUI 2026-08-30 12:40:22 +09:00
Hare adb684a6bf fix: preserve typed user input snapshots 2026-08-30 12:33:15 +09:00
Hare 8493472983 refactor: require annotated session log history 2026-08-30 12:18:44 +09:00
Hare 862eeb7add fix: route runtime workers through shared bootstrap 2026-08-30 11:54:20 +09:00
Hare 4d9b211d69 feat: add in-process standalone worker host 2026-08-30 11:29:59 +09:00
Hare ebb272324c Merge branch 'work/T-552-ticket-relation-projection' into hare/develop 2026-08-30 10:15:33 +09:00
Hare c0290512b3 fix: align Ticket relation projection 2026-08-30 10:15:23 +09:00
Hare 4ec56fe41e Merge remote-tracking branch 'origin/develop' into work/T-545-canonical-session-snapshot 2026-08-30 09:42:57 +09:00
Hare f8a7c46cf9 fix(session): preflight migration and retain timestamps 2026-08-30 09:42:46 +09:00
Hare 2bab8a9bb6 fix: distinguish Ticket activity notifications 2026-08-30 09:00:48 +09:00
Hare 89e6a6215a Merge remote-tracking branch 'origin/develop' into work/T-545-canonical-session-snapshot
# Conflicts:
#	web/workspace/src/lib/workspace/console/model.ts
2026-08-30 00:42:29 +09:00
Hare 88be87e03e feat(session): expose canonical public snapshots 2026-08-30 00:41:51 +09:00
Hare 22867faa9c test: align Profile consumers with explicit default 2026-08-30 00:37:52 +09:00
Hare 16c0fc704d feat: unify builtin Profile resolution authority 2026-08-30 00:28:58 +09:00
Hare 32fdd076bf feat: authenticate Backend target requests 2026-08-29 23:41:55 +09:00
245 changed files with 24120 additions and 25064 deletions
+3
View File
@@ -0,0 +1,3 @@
[build]
build-dir = "{cargo-cache-home}/build/{workspace-path-hash}"
incremental = false
Generated
+31 -5
View File
@@ -637,8 +637,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
name = "client" name = "client"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait",
"chrono",
"futures", "futures",
"manifest",
"protocol", "protocol",
"reqwest", "reqwest",
"serde", "serde",
@@ -649,7 +650,6 @@ dependencies = [
"tokio", "tokio",
"tokio-tungstenite 0.29.0", "tokio-tungstenite 0.29.0",
"uuid", "uuid",
"workdir",
"workspace-api", "workspace-api",
] ]
@@ -2629,6 +2629,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"agen", "agen",
"arc-swap", "arc-swap",
"decodal",
"protocol", "protocol",
"secrets", "secrets",
"serde", "serde",
@@ -3506,6 +3507,7 @@ dependencies = [
"schemars", "schemars",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0",
"tokio", "tokio",
"ts-rs", "ts-rs",
"uuid", "uuid",
@@ -4400,10 +4402,12 @@ dependencies = [
"agen", "agen",
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
"fs4",
"futures", "futures",
"protocol", "protocol",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.11.0",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
@@ -4615,6 +4619,27 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "standalone"
version = "0.1.0"
dependencies = [
"agen",
"async-trait",
"client",
"fs4",
"futures",
"manifest",
"protocol",
"serde",
"serde_json",
"session-store",
"tempfile",
"thiserror 2.0.18",
"tokio",
"uuid",
"worker",
]
[[package]] [[package]]
name = "static_assertions" name = "static_assertions"
version = "1.1.0" version = "1.1.0"
@@ -5304,7 +5329,6 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"client", "client",
"crossterm 0.28.1", "crossterm 0.28.1",
"fs4",
"manifest", "manifest",
"protocol", "protocol",
"pulldown-cmark", "pulldown-cmark",
@@ -5313,13 +5337,14 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"session-store", "session-store",
"standalone",
"tempfile", "tempfile",
"thiserror 2.0.18",
"ticket", "ticket",
"tokio", "tokio",
"toml", "toml",
"unicode-width", "unicode-width",
"uuid", "uuid",
"worker",
] ]
[[package]] [[package]]
@@ -6570,6 +6595,7 @@ dependencies = [
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"workspace-api",
] ]
[[package]] [[package]]
@@ -6617,6 +6643,7 @@ dependencies = [
"wasmtime", "wasmtime",
"wat", "wat",
"workdir", "workdir",
"workspace-api",
"yoi-plugin-pdk", "yoi-plugin-pdk",
] ]
@@ -6662,7 +6689,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"ts-rs", "ts-rs",
"workdir",
] ]
[[package]] [[package]]
+3
View File
@@ -5,6 +5,7 @@ members = [
"crates/agen", "crates/agen",
"crates/agen-macros", "crates/agen-macros",
"crates/session-store", "crates/session-store",
"crates/standalone",
"crates/secrets", "crates/secrets",
"crates/manifest", "crates/manifest",
"crates/mcp", "crates/mcp",
@@ -36,6 +37,7 @@ default-members = [
"crates/agen", "crates/agen",
"crates/agen-macros", "crates/agen-macros",
"crates/session-store", "crates/session-store",
"crates/standalone",
"crates/secrets", "crates/secrets",
"crates/manifest", "crates/manifest",
"crates/mcp", "crates/mcp",
@@ -87,6 +89,7 @@ protocol = { path = "crates/protocol" }
session-metrics = { path = "crates/session-metrics" } session-metrics = { path = "crates/session-metrics" }
session-analytics = { path = "crates/session-analytics" } session-analytics = { path = "crates/session-analytics" }
session-store = { path = "crates/session-store" } session-store = { path = "crates/session-store" }
standalone = { path = "crates/standalone" }
secrets = { path = "crates/secrets" } secrets = { path = "crates/secrets" }
tools = { path = "crates/tools" } tools = { path = "crates/tools" }
config-source = { path = "crates/config-source" } config-source = { path = "crates/config-source" }
+3 -3
View File
@@ -5,19 +5,19 @@ edition.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
async-trait.workspace = true
chrono = { version = "0.4", default-features = false, features = ["clock"] }
protocol = { workspace = true } protocol = { workspace = true }
manifest = { workspace = true }
ticket = { workspace = true } ticket = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] } tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time"] }
tokio-tungstenite = { workspace = true } tokio-tungstenite = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
workspace-api.workspace = true workspace-api.workspace = true
workdir = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = { workspace = true } tempfile = { workspace = true }
+839
View File
@@ -0,0 +1,839 @@
use chrono::{DateTime, Utc};
use reqwest::{Method, StatusCode, Url, redirect};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::env;
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
const TOKEN_FILE_NAME: &str = "backend-tokens.json";
const MAX_REDIRECTS: usize = 10;
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BackendOrigin(String);
impl BackendOrigin {
pub fn parse(input: &str) -> Result<Self, BackendApiClientError> {
let url = Url::parse(input.trim()).map_err(|error| {
BackendApiClientError::InvalidBackendOrigin(format!(
"Backend URL is not a valid absolute URL: {error}"
))
})?;
if !url.path().bytes().all(|byte| byte == b'/')
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(BackendApiClientError::InvalidBackendOrigin(
"Backend URL must contain only an origin, without a path, query, or fragment"
.to_string(),
));
}
Self::from_url(url)
}
fn from_url(mut url: Url) -> Result<Self, BackendApiClientError> {
if !matches!(url.scheme(), "http" | "https") {
return Err(BackendApiClientError::InvalidBackendOrigin(
"Backend URL scheme must be http or https".to_string(),
));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(BackendApiClientError::InvalidBackendOrigin(
"Backend URL must not contain user information".to_string(),
));
}
if url.host().is_none() {
return Err(BackendApiClientError::InvalidBackendOrigin(
"Backend URL must contain a host".to_string(),
));
}
let default_port = match url.scheme() {
"http" => 80,
"https" => 443,
_ => unreachable!("validated Backend URL scheme"),
};
if url.port() == Some(default_port) {
url.set_port(None).map_err(|()| {
BackendApiClientError::InvalidBackendOrigin(
"Backend URL contains an invalid port".to_string(),
)
})?;
}
url.set_path("");
url.set_query(None);
url.set_fragment(None);
let normalized = url.as_str().trim_end_matches('/').to_string();
Ok(Self(normalized))
}
pub fn as_str(&self) -> &str {
&self.0
}
fn url(&self, path_and_query: &str) -> Result<Url, BackendApiClientError> {
if !path_and_query.starts_with('/') || path_and_query.starts_with("//") {
return Err(BackendApiClientError::InvalidRequestPath(
"Backend API request path must start with one `/`".to_string(),
));
}
Url::parse(&format!("{}{path_and_query}", self.0)).map_err(|error| {
BackendApiClientError::InvalidRequestPath(format!(
"Backend API request path is invalid: {error}"
))
})
}
}
impl fmt::Debug for BackendOrigin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("BackendOrigin").field(&self.0).finish()
}
}
impl fmt::Display for BackendOrigin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone)]
struct BackendAccessToken(String);
impl fmt::Debug for BackendAccessToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("BackendAccessToken([REDACTED])")
}
}
#[derive(Clone)]
pub struct BackendApiClient {
origin: BackendOrigin,
access_token: BackendAccessToken,
asynchronous: reqwest::Client,
}
impl fmt::Debug for BackendApiClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BackendApiClient")
.field("origin", &self.origin)
.field("access_token", &self.access_token)
.finish_non_exhaustive()
}
}
impl BackendApiClient {
pub fn from_stored_token(base_url: &str) -> Result<Self, BackendApiClientError> {
let path = backend_token_file_path()?;
Self::from_token_file(base_url, &path)
}
fn from_token_file(base_url: &str, path: &Path) -> Result<Self, BackendApiClientError> {
let origin = BackendOrigin::parse(base_url)?;
let token_file = read_token_file(path)?;
let entry = token_file.tokens.get(origin.as_str()).ok_or_else(|| {
BackendApiClientError::TokenEntryMissing {
origin: origin.clone(),
path: path.to_path_buf(),
}
})?;
validate_token_entry(entry, &origin, path)?;
Self::new(origin, BackendAccessToken(entry.access_token.clone()))
}
fn new(
origin: BackendOrigin,
access_token: BackendAccessToken,
) -> Result<Self, BackendApiClientError> {
let asynchronous = reqwest::Client::builder()
.redirect(redirect_policy(origin.clone()))
.build()
.map_err(BackendApiClientError::Http)?;
Ok(Self {
origin,
access_token,
asynchronous,
})
}
pub fn origin(&self) -> &BackendOrigin {
&self.origin
}
pub fn request(
&self,
method: Method,
path_and_query: &str,
) -> Result<reqwest::RequestBuilder, BackendApiClientError> {
let url = self.origin.url(path_and_query)?;
Ok(self
.asynchronous
.request(method, url)
.bearer_auth(&self.access_token.0))
}
pub fn blocking_request(
&self,
method: Method,
path_and_query: &str,
) -> Result<reqwest::blocking::RequestBuilder, BackendApiClientError> {
let url = self.origin.url(path_and_query)?;
let client = reqwest::blocking::Client::builder()
.redirect(redirect_policy(self.origin.clone()))
.build()
.map_err(BackendApiClientError::Http)?;
Ok(client
.request(method, url)
.bearer_auth(&self.access_token.0))
}
pub(crate) fn authorization_header_value(&self) -> String {
format!("Bearer {}", self.access_token.0)
}
pub async fn require_success(
&self,
response: reqwest::Response,
) -> Result<reqwest::Response, BackendApiClientError> {
let status = response.status();
match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
self.check_status(status)?;
}
status if !status.is_success() => {
let detail = response
.bytes()
.await
.ok()
.and_then(|body| backend_error_detail(&body));
return Err(BackendApiClientError::BackendResponse {
origin: self.origin.clone(),
status: status.as_u16(),
detail,
});
}
_ => {}
}
Ok(response)
}
pub fn check_status(&self, status: StatusCode) -> Result<(), BackendApiClientError> {
match status {
StatusCode::UNAUTHORIZED => Err(BackendApiClientError::Unauthorized {
origin: self.origin.clone(),
}),
StatusCode::FORBIDDEN => Err(BackendApiClientError::Forbidden {
origin: self.origin.clone(),
}),
status if !status.is_success() => Err(BackendApiClientError::BackendStatus {
origin: self.origin.clone(),
status: status.as_u16(),
}),
_ => Ok(()),
}
}
#[cfg(test)]
pub(crate) fn from_access_token_for_test(
base_url: &str,
access_token: &str,
) -> Result<Self, BackendApiClientError> {
Self::new(
BackendOrigin::parse(base_url)?,
BackendAccessToken(access_token.to_string()),
)
}
}
fn redirect_policy(origin: BackendOrigin) -> redirect::Policy {
redirect::Policy::custom(move |attempt| {
if attempt.previous().len() >= MAX_REDIRECTS {
return attempt.error("Backend request exceeded the redirect limit");
}
match BackendOrigin::from_url(attempt.url().clone()) {
Ok(target_origin) if target_origin == origin => attempt.follow(),
Ok(target_origin) => attempt.error(format!(
"Backend request refused a cross-origin redirect from {origin} to {target_origin}"
)),
Err(error) => attempt.error(error.to_string()),
}
})
}
#[derive(Deserialize)]
struct BackendErrorBody {
message: String,
}
fn backend_error_detail(body: &[u8]) -> Option<String> {
serde_json::from_slice::<BackendErrorBody>(body)
.ok()
.map(|body| body.message)
.filter(|message| !message.trim().is_empty())
}
#[derive(Debug)]
pub enum BackendApiClientError {
InvalidBackendOrigin(String),
InvalidRequestPath(String),
ConfigDirectoryUnavailable,
TokenFileMissing {
path: PathBuf,
},
TokenFileMalformed {
path: PathBuf,
message: String,
},
TokenEntryMissing {
origin: BackendOrigin,
path: PathBuf,
},
TokenExpired {
origin: BackendOrigin,
expired_at: String,
},
Http(reqwest::Error),
Unauthorized {
origin: BackendOrigin,
},
Forbidden {
origin: BackendOrigin,
},
BackendStatus {
origin: BackendOrigin,
status: u16,
},
BackendResponse {
origin: BackendOrigin,
status: u16,
detail: Option<String>,
},
Io {
path: PathBuf,
source: std::io::Error,
},
}
impl fmt::Display for BackendApiClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidBackendOrigin(message) | Self::InvalidRequestPath(message) => {
f.write_str(message)
}
Self::ConfigDirectoryUnavailable => f.write_str(
"cannot locate the client configuration directory for backend-tokens.json",
),
Self::TokenFileMissing { path } => write!(
f,
"Backend token file {} is missing; run `yoi login --backend <BACKEND>` first",
path.display()
),
Self::TokenFileMalformed { path, message } => write!(
f,
"Backend token file {} is malformed: {message}; run `yoi login --backend <BACKEND>` again",
path.display()
),
Self::TokenEntryMissing { origin, path } => write!(
f,
"no Backend token for {origin} exists in {}; login URLs are matched by normalized origin, so run `yoi login --backend {origin}`",
path.display()
),
Self::TokenExpired { origin, expired_at } => write!(
f,
"Backend token for {origin} expired at {expired_at}; run `yoi login --backend {origin}` again"
),
Self::Http(error) => write!(f, "Backend request failed: {error}"),
Self::Unauthorized { origin } => write!(
f,
"Backend {origin} returned HTTP 401 for the saved token; it may be expired or revoked, so run `yoi login --backend {origin}` again"
),
Self::Forbidden { origin } => write!(
f,
"Backend {origin} returned HTTP 403; the saved token is authenticated but is not authorized for this operation"
),
Self::BackendStatus { origin, status } => {
write!(f, "Backend {origin} returned HTTP {status}")
}
Self::BackendResponse {
origin,
status,
detail,
} => {
write!(f, "Backend {origin} returned HTTP {status}")?;
if let Some(detail) = detail {
write!(f, ": {detail}")?;
}
Ok(())
}
Self::Io { path, source } => {
write!(f, "failed to access {}: {source}", path.display())
}
}
}
}
impl std::error::Error for BackendApiClientError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Http(error) => Some(error),
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct BackendTokenFile {
tokens: BTreeMap<String, BackendTokenEntry>,
}
#[derive(Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct BackendTokenEntry {
token_type: String,
access_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
expires_at: Option<String>,
}
pub fn save_backend_token(
base_url: &str,
token_type: &str,
access_token: &str,
) -> Result<PathBuf, BackendApiClientError> {
save_backend_token_with_expiry(base_url, token_type, access_token, None)
}
fn save_backend_token_with_expiry(
base_url: &str,
token_type: &str,
access_token: &str,
expires_at: Option<String>,
) -> Result<PathBuf, BackendApiClientError> {
let path = backend_token_file_path()?;
save_backend_token_to_file(base_url, token_type, access_token, expires_at, &path)?;
Ok(path)
}
fn save_backend_token_to_file(
base_url: &str,
token_type: &str,
access_token: &str,
expires_at: Option<String>,
path: &Path,
) -> Result<(), BackendApiClientError> {
let origin = BackendOrigin::parse(base_url)?;
let mut token_file = if path.exists() {
read_token_file(&path)?
} else {
BackendTokenFile {
tokens: BTreeMap::new(),
}
};
let entry = BackendTokenEntry {
token_type: token_type.to_string(),
access_token: access_token.to_string(),
expires_at,
};
validate_token_entry(&entry, &origin, path)?;
token_file.tokens.insert(origin.to_string(), entry);
write_token_file(path, &token_file)?;
Ok(())
}
pub fn backend_token_file_path() -> Result<PathBuf, BackendApiClientError> {
if let Some(config_home) = env::var_os("XDG_CONFIG_HOME") {
return Ok(PathBuf::from(config_home).join("yoi").join(TOKEN_FILE_NAME));
}
let Some(home) = env::var_os("HOME") else {
return Err(BackendApiClientError::ConfigDirectoryUnavailable);
};
Ok(PathBuf::from(home)
.join(".config")
.join("yoi")
.join(TOKEN_FILE_NAME))
}
fn read_token_file(path: &Path) -> Result<BackendTokenFile, BackendApiClientError> {
let bytes = fs::read(path).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
BackendApiClientError::TokenFileMissing {
path: path.to_path_buf(),
}
} else {
BackendApiClientError::Io {
path: path.to_path_buf(),
source,
}
}
})?;
let raw: BackendTokenFile = serde_json::from_slice(&bytes).map_err(|error| {
BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: error.to_string(),
}
})?;
normalize_token_file(raw, path)
}
fn normalize_token_file(
token_file: BackendTokenFile,
path: &Path,
) -> Result<BackendTokenFile, BackendApiClientError> {
let mut normalized = BTreeMap::new();
for (raw_origin, entry) in token_file.tokens {
let origin = BackendOrigin::parse(&raw_origin).map_err(|error| {
BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: format!("token key `{raw_origin}` is invalid: {error}"),
}
})?;
if normalized.insert(origin.to_string(), entry).is_some() {
return Err(BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: format!("more than one token entry normalizes to `{origin}`"),
});
}
}
Ok(BackendTokenFile { tokens: normalized })
}
fn validate_token_entry(
entry: &BackendTokenEntry,
origin: &BackendOrigin,
path: &Path,
) -> Result<(), BackendApiClientError> {
if !entry.token_type.eq_ignore_ascii_case("Bearer") {
return Err(BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: format!("token for `{origin}` does not use the Bearer token type"),
});
}
if entry.access_token.trim().is_empty()
|| entry.access_token.contains('\r')
|| entry.access_token.contains('\n')
{
return Err(BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: format!("token for `{origin}` is empty or contains an invalid line break"),
});
}
if reqwest::header::HeaderValue::from_str(&format!("Bearer {}", entry.access_token)).is_err() {
return Err(BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: format!("token for `{origin}` cannot be represented as an HTTP header"),
});
}
if let Some(expires_at) = entry.expires_at.as_deref() {
let expiration = DateTime::parse_from_rfc3339(expires_at).map_err(|error| {
BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: format!("token for `{origin}` has invalid expires_at: {error}"),
}
})?;
if expiration <= Utc::now() {
return Err(BackendApiClientError::TokenExpired {
origin: origin.clone(),
expired_at: expires_at.to_string(),
});
}
}
Ok(())
}
fn write_token_file(
path: &Path,
token_file: &BackendTokenFile,
) -> Result<(), BackendApiClientError> {
let parent = path
.parent()
.ok_or(BackendApiClientError::ConfigDirectoryUnavailable)?;
fs::create_dir_all(parent).map_err(|source| BackendApiClientError::Io {
path: parent.to_path_buf(),
source,
})?;
let payload = serde_json::to_vec_pretty(token_file).map_err(|error| {
BackendApiClientError::TokenFileMalformed {
path: path.to_path_buf(),
message: error.to_string(),
}
})?;
let temp_path = parent.join(format!(".{TOKEN_FILE_NAME}.tmp-{}", std::process::id()));
let mut options = OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(&temp_path)
.map_err(|source| BackendApiClientError::Io {
path: temp_path.clone(),
source,
})?;
file.write_all(&payload)
.and_then(|()| file.write_all(b"\n"))
.and_then(|()| file.sync_all())
.map_err(|source| BackendApiClientError::Io {
path: temp_path.clone(),
source,
})?;
fs::rename(&temp_path, path).map_err(|source| BackendApiClientError::Io {
path: path.to_path_buf(),
source,
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn temp_path(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
env::temp_dir().join(format!(
"yoi-client-{label}-{}-{nonce}.json",
std::process::id()
))
}
fn write_fixture(path: &Path, value: serde_json::Value) {
fs::write(path, serde_json::to_vec(&value).unwrap()).unwrap();
}
#[test]
fn backend_origin_normalizes_safe_equivalents() {
let variants = [
"HTTP://Example.COM",
"http://example.com/",
"http://example.com:80////",
];
for variant in variants {
assert_eq!(
BackendOrigin::parse(variant).unwrap().as_str(),
"http://example.com"
);
}
assert_eq!(
BackendOrigin::parse("https://EXAMPLE.com:443/")
.unwrap()
.as_str(),
"https://example.com"
);
assert_eq!(
BackendOrigin::parse("https://example.com:8443/")
.unwrap()
.as_str(),
"https://example.com:8443"
);
}
#[test]
fn backend_error_detail_preserves_public_server_message() {
let detail = backend_error_detail(
br#"{"error":"Bad Request","message":"working_directory_runtime_mismatch: Working directory is owned by a different Runtime","diagnostics":[{"code":"working_directory_runtime_mismatch"}]}"#,
);
let error = BackendApiClientError::BackendResponse {
origin: BackendOrigin::parse("http://127.0.0.1:8787").unwrap(),
status: 400,
detail,
};
assert_eq!(
error.to_string(),
"Backend http://127.0.0.1:8787 returned HTTP 400: working_directory_runtime_mismatch: Working directory is owned by a different Runtime"
);
}
#[test]
fn backend_origin_rejects_unsafe_authority_changes() {
for invalid in [
"ftp://example.com",
"https://user@example.com",
"https://example.com/api",
"https://example.com/?query=1",
"https://example.com/#fragment",
] {
assert!(BackendOrigin::parse(invalid).is_err(), "accepted {invalid}");
}
assert_ne!(
BackendOrigin::parse("http://localhost:8787").unwrap(),
BackendOrigin::parse("http://127.0.0.1:8787").unwrap()
);
}
#[test]
fn token_lookup_distinguishes_missing_malformed_mismatch_and_expired() {
let missing = temp_path("missing");
assert!(matches!(
BackendApiClient::from_token_file("http://localhost:8787", &missing),
Err(BackendApiClientError::TokenFileMissing { .. })
));
let malformed = temp_path("malformed");
fs::write(&malformed, b"not json").unwrap();
assert!(matches!(
BackendApiClient::from_token_file("http://localhost:8787", &malformed),
Err(BackendApiClientError::TokenFileMalformed { .. })
));
let mismatch = temp_path("mismatch");
write_fixture(
&mismatch,
serde_json::json!({"tokens": {"http://localhost:8787": {
"token_type": "Bearer", "access_token": "secret"
}}}),
);
assert!(matches!(
BackendApiClient::from_token_file("http://127.0.0.1:8787", &mismatch),
Err(BackendApiClientError::TokenEntryMissing { .. })
));
let expired = temp_path("expired");
write_fixture(
&expired,
serde_json::json!({"tokens": {"http://localhost:8787": {
"token_type": "Bearer",
"access_token": "secret",
"expires_at": "2000-01-01T00:00:00Z"
}}}),
);
assert!(matches!(
BackendApiClient::from_token_file("http://localhost:8787", &expired),
Err(BackendApiClientError::TokenExpired { .. })
));
for path in [malformed, mismatch, expired] {
let _ = fs::remove_file(path);
}
}
#[test]
fn token_write_and_lookup_share_origin_normalization() {
let path = temp_path("normalized-write");
save_backend_token_to_file(
"HTTP://Example.COM:80////",
"Bearer",
"normalized-secret",
None,
&path,
)
.unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("\"http://example.com\""));
let client = BackendApiClient::from_token_file("http://example.com/", &path).unwrap();
assert_eq!(
client.authorization_header_value(),
"Bearer normalized-secret"
);
fs::remove_file(path).unwrap();
}
#[test]
fn client_debug_and_errors_never_include_token_value() {
let client = BackendApiClient::from_access_token_for_test(
"http://localhost:8787",
"never-print-this-token",
)
.unwrap();
assert!(!format!("{client:?}").contains("never-print-this-token"));
assert!(
!BackendApiClientError::Unauthorized {
origin: client.origin().clone()
}
.to_string()
.contains("never-print-this-token")
);
}
#[test]
fn authenticated_requests_follow_only_same_origin_redirects() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let origin = format!("http://{}", listener.local_addr().unwrap());
let handle = thread::spawn(move || {
for response in [
"HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
] {
let (mut stream, _) = listener.accept().unwrap();
let mut request = vec![0; 4096];
let read = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
assert!(request.contains("authorization: bearer redirect-secret\r\n"));
stream.write_all(response.as_bytes()).unwrap();
}
});
let client =
BackendApiClient::from_access_token_for_test(&origin, "redirect-secret").unwrap();
let response = client
.blocking_request(Method::GET, "/start")
.unwrap()
.send()
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
handle.join().unwrap();
}
#[test]
fn authenticated_requests_reject_cross_origin_redirects_without_leaking_token() {
let source = TcpListener::bind("127.0.0.1:0").unwrap();
let target = TcpListener::bind("127.0.0.1:0").unwrap();
target.set_nonblocking(true).unwrap();
let source_origin = format!("http://{}", source.local_addr().unwrap());
let target_origin = format!("http://{}", target.local_addr().unwrap());
let location = format!("{target_origin}/capture");
let handle = thread::spawn(move || {
let (mut stream, _) = source.accept().unwrap();
let mut request = vec![0; 4096];
let read = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
assert!(request.contains("authorization: bearer redirect-secret\r\n"));
let response = format!(
"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
);
stream.write_all(response.as_bytes()).unwrap();
});
let client =
BackendApiClient::from_access_token_for_test(&source_origin, "redirect-secret")
.unwrap();
let error = client
.blocking_request(Method::GET, "/start")
.unwrap()
.send()
.unwrap_err();
let message = error.to_string();
assert!(message.contains("redirect"));
assert!(!message.contains("redirect-secret"));
handle.join().unwrap();
thread::sleep(Duration::from_millis(20));
assert!(matches!(
target.accept(),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock
));
}
#[test]
fn status_diagnostics_distinguish_unauthorized_and_forbidden() {
let client =
BackendApiClient::from_access_token_for_test("http://localhost:8787", "secret")
.unwrap();
assert!(matches!(
client.check_status(StatusCode::UNAUTHORIZED),
Err(BackendApiClientError::Unauthorized { .. })
));
assert!(matches!(
client.check_status(StatusCode::FORBIDDEN),
Err(BackendApiClientError::Forbidden { .. })
));
}
}
+6 -3
View File
@@ -1,3 +1,4 @@
use crate::BackendOrigin;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use std::time::Duration; use std::time::Duration;
@@ -9,9 +10,11 @@ pub struct BackendAuthTarget {
impl BackendAuthTarget { impl BackendAuthTarget {
pub fn new(base_url: impl Into<String>) -> Self { pub fn new(base_url: impl Into<String>) -> Self {
Self { let base_url = base_url.into();
base_url: base_url.into(), let base_url = BackendOrigin::parse(&base_url)
} .map(|origin| origin.to_string())
.unwrap_or(base_url);
Self { base_url }
} }
fn api_url(&self, path: &str) -> String { fn api_url(&self, path: &str) -> String {
+133 -193
View File
@@ -1,12 +1,10 @@
use futures::{SinkExt, StreamExt}; use crate::transport::websocket::{Socket as WebSocket, SocketError as WebSocketError};
use protocol::stream::{decode_event, encode_method}; use crate::{BackendApiClient, BackendApiClientError, Client};
use protocol::{ErrorCode, Event, Method}; use reqwest::Method as HttpMethod;
use std::collections::VecDeque;
use std::fmt; use std::fmt;
use tokio::sync::mpsc; use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
pub use workspace_api::{ pub use workspace_api::{
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity, Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary, ListResponse as BackendRuntimeListResponse, RuntimeSummary as BackendRuntimeSummary,
@@ -15,6 +13,11 @@ pub use workspace_api::{
WorkerRestoreResponse as BackendWorkerRestoreResponse, WorkerRestoreResponse as BackendWorkerRestoreResponse,
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary, WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary, WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
WorkingDirectoryCreateRequest as BackendWorkingDirectoryCreateRequest,
WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse,
WorkingDirectoryDetailResponse as BackendWorkingDirectoryDetailResponse,
WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse,
WorkingDirectorySummary as BackendWorkingDirectorySummary,
}; };
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -101,32 +104,33 @@ impl BackendRuntimeListTarget {
} }
} }
#[derive(Debug)]
pub struct BackendRuntimeClient {
target: BackendRuntimeTarget,
command_tx: mpsc::UnboundedSender<Method>,
events: mpsc::UnboundedReceiver<Event>,
diagnostics: VecDeque<Event>,
_protocol_task: tokio::task::JoinHandle<()>,
}
#[derive(Debug)] #[derive(Debug)]
pub enum BackendRuntimeClientError { pub enum BackendRuntimeClientError {
InvalidTarget(String), InvalidTarget(String),
Api(BackendApiClientError),
Http(reqwest::Error), Http(reqwest::Error),
Protocol(String),
} }
impl fmt::Display for BackendRuntimeClientError { impl fmt::Display for BackendRuntimeClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::InvalidTarget(message) => f.write_str(message), Self::InvalidTarget(message) => f.write_str(message),
Self::Api(error) => write!(f, "{error}"),
Self::Http(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"),
Self::Protocol(message) => f.write_str(message),
} }
} }
} }
impl std::error::Error for BackendRuntimeClientError {} impl std::error::Error for BackendRuntimeClientError {}
impl From<BackendApiClientError> for BackendRuntimeClientError {
fn from(error: BackendApiClientError) -> Self {
Self::Api(error)
}
}
impl From<reqwest::Error> for BackendRuntimeClientError { impl From<reqwest::Error> for BackendRuntimeClientError {
fn from(error: reqwest::Error) -> Self { fn from(error: reqwest::Error) -> Self {
Self::Http(error) Self::Http(error)
@@ -137,7 +141,7 @@ pub async fn list_backend_workers(
target: &BackendRuntimeListTarget, target: &BackendRuntimeListTarget,
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> { ) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
validate_list_target(target)?; validate_list_target(target)?;
let http = reqwest::Client::new(); let api = BackendApiClient::from_stored_token(&target.base_url)?;
if let Some(runtime_id) = target.runtime_id.as_deref() { if let Some(runtime_id) = target.runtime_id.as_deref() {
let path = backend_runtime_workers_path( let path = backend_runtime_workers_path(
target target
@@ -146,12 +150,9 @@ pub async fn list_backend_workers(
.expect("validated Backend Workspace scope"), .expect("validated Backend Workspace scope"),
runtime_id, runtime_id,
); );
let url = join_base_and_path(&target.base_url, &path); let response = api.request(HttpMethod::GET, &path)?.send().await?;
return Ok(http api.check_status(response.status())?;
.get(url) return Ok(response
.send()
.await?
.error_for_status()?
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>() .json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.await?); .await?);
} }
@@ -162,12 +163,9 @@ pub async fn list_backend_workers(
.as_deref() .as_deref()
.expect("validated Backend Workspace scope"), .expect("validated Backend Workspace scope"),
); );
let runtime_url = join_base_and_path(&target.base_url, &runtime_path); let response = api.request(HttpMethod::GET, &runtime_path)?.send().await?;
let runtimes = http api.check_status(response.status())?;
.get(runtime_url) let runtimes = response
.send()
.await?
.error_for_status()?
.json::<BackendRuntimeListResponse<BackendRuntimeSummary>>() .json::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
.await?; .await?;
@@ -181,29 +179,43 @@ pub async fn list_backend_workers(
.expect("validated Backend Workspace scope"), .expect("validated Backend Workspace scope"),
&runtime.runtime_id, &runtime.runtime_id,
); );
let url = join_base_and_path(&target.base_url, &path); let response = match api.request(HttpMethod::GET, &path)?.send().await {
match http Ok(response) => response,
.get(url) Err(error) => {
.send() diagnostics.push(BackendDiagnostic {
.await
.and_then(|response| response.error_for_status())
{
Ok(response) => {
let response = response
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.await?;
diagnostics.extend(response.diagnostics);
items.extend(response.items);
}
Err(error) => diagnostics.push(BackendDiagnostic {
code: "runtime_worker_list_failed".to_string(), code: "runtime_worker_list_failed".to_string(),
severity: BackendDiagnosticSeverity::Error, severity: BackendDiagnosticSeverity::Error,
message: format!( message: format!(
"failed to list workers for runtime {}: {error}", "failed to list workers for runtime {}: {error}",
runtime.runtime_id runtime.runtime_id
), ),
}), });
continue;
} }
};
if matches!(
response.status(),
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
) {
api.check_status(response.status())?;
}
if !response.status().is_success() {
diagnostics.push(BackendDiagnostic {
code: "runtime_worker_list_failed".to_string(),
severity: BackendDiagnosticSeverity::Error,
message: format!(
"failed to list workers for runtime {}: Backend returned HTTP {}",
runtime.runtime_id,
response.status().as_u16()
),
});
continue;
}
let response = response
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.await?;
diagnostics.extend(response.diagnostics);
items.extend(response.items);
} }
Ok(BackendRuntimeListResponse { Ok(BackendRuntimeListResponse {
@@ -224,7 +236,7 @@ pub async fn list_backend_stopped_workers(
"stopped worker listing requires a runtime id".to_string(), "stopped worker listing requires a runtime id".to_string(),
)); ));
}; };
let http = reqwest::Client::new(); let api = BackendApiClient::from_stored_token(&target.base_url)?;
let path = backend_runtime_workers_path( let path = backend_runtime_workers_path(
target target
.workspace_id .workspace_id
@@ -232,12 +244,12 @@ pub async fn list_backend_stopped_workers(
.expect("validated Backend Workspace scope"), .expect("validated Backend Workspace scope"),
runtime_id, runtime_id,
); );
let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped")); let response = api
Ok(http .request(HttpMethod::GET, &format!("{path}?status=stopped"))?
.get(url)
.send() .send()
.await? .await?;
.error_for_status()? api.check_status(response.status())?;
Ok(response
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>() .json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.await?) .await?)
} }
@@ -246,166 +258,61 @@ pub async fn restore_backend_worker(
target: &BackendRuntimeTarget, target: &BackendRuntimeTarget,
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> { ) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
validate_target(target)?; validate_target(target)?;
let http = reqwest::Client::new(); let api = BackendApiClient::from_stored_token(&target.base_url)?;
let path = backend_runtime_worker_restore_path( let path = backend_runtime_worker_restore_path(
&target.workspace_id, &target.workspace_id,
&target.runtime_id, &target.runtime_id,
&target.worker_id, &target.worker_id,
); );
let url = join_base_and_path(&target.base_url, &path); let response = api
Ok(http .request(HttpMethod::POST, &path)?
.post(url)
.json(&serde_json::json!({})) .json(&serde_json::json!({}))
.send() .send()
.await? .await?;
.error_for_status()? let response = api.require_success(response).await?;
.json::<BackendWorkerRestoreResponse>() Ok(response.json::<BackendWorkerRestoreResponse>().await?)
.await?)
} }
impl BackendRuntimeClient { pub async fn connect_backend_runtime(
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> { target: BackendRuntimeTarget,
) -> Result<Client<WebSocket>, BackendRuntimeClientError> {
validate_target(&target)?; validate_target(&target)?;
let (event_tx, rx) = mpsc::unbounded_channel(); let api = BackendApiClient::from_stored_token(&target.base_url)?;
let (command_tx, command_rx) = mpsc::unbounded_channel(); let request = protocol_ws_request(&target, &api).map_err(|error| {
BackendRuntimeClientError::Protocol(format!(
let protocol_target = target.clone(); "Backend protocol request could not be constructed for {}: {error}",
let protocol_event_tx = event_tx.clone(); target.display_label()
let protocol_task = tokio::spawn(async move {
run_worker_protocol_transport(protocol_target, command_rx, protocol_event_tx).await;
});
Ok(Self {
target,
command_tx,
events: rx,
diagnostics: VecDeque::new(),
_protocol_task: protocol_task,
})
}
pub fn try_next_event(&mut self) -> Option<Event> {
if let Some(event) = self.diagnostics.pop_front() {
return Some(event);
}
self.events.try_recv().ok()
}
pub async fn next_event(&mut self) -> Option<Event> {
if let Some(event) = self.diagnostics.pop_front() {
return Some(event);
}
self.events.recv().await
}
pub async fn send(&mut self, method: &Method) -> Result<(), BackendRuntimeClientError> {
self.command_tx.send(method.clone()).map_err(|_| {
BackendRuntimeClientError::InvalidTarget(format!(
"Backend protocol command stream is closed for {}",
self.target.display_label()
)) ))
})?; })?;
Ok(()) match WebSocket::connect(request).await {
Ok(socket) => Ok(Client::new(socket)),
Err(WebSocketError::WebSocket(error)) => Err(BackendRuntimeClientError::Protocol(
protocol_connect_error_message(&target, &api, &error),
)),
} }
} }
impl Drop for BackendRuntimeClient { fn protocol_connect_error_message(
fn drop(&mut self) { target: &BackendRuntimeTarget,
self._protocol_task.abort(); api: &BackendApiClient,
} error: &tokio_tungstenite::tungstenite::Error,
} ) -> String {
if let tokio_tungstenite::tungstenite::Error::Http(response) = error {
async fn run_worker_protocol_transport( if let Ok(status) = reqwest::StatusCode::from_u16(response.status().as_u16()) {
target: BackendRuntimeTarget, if matches!(
mut commands: mpsc::UnboundedReceiver<Method>, status,
tx: mpsc::UnboundedSender<Event>, reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
) { ) {
let url = protocol_ws_url(&target); if let Err(error) = api.check_status(status) {
match connect_async(&url).await { return error.to_string();
Ok((ws, _)) => {
let (mut sink, mut stream) = ws.split();
loop {
tokio::select! {
maybe_method = commands.recv() => {
let Some(method) = maybe_method else {
break;
};
match encode_method(&method) {
Ok(text) => {
if let Err(error) = sink.send(TungsteniteMessage::Text(text.into())).await {
let _ = tx.send(diagnostic_event(format!(
"Backend protocol command send failed for {}: {error}",
target.display_label()
)));
break;
}
}
Err(error) => {
let _ = tx.send(diagnostic_event(format!(
"Backend protocol command could not serialize method for {}: {error}",
target.display_label()
)));
}
}
}
frame = stream.next() => {
match frame {
Some(Ok(TungsteniteMessage::Text(text))) => {
match decode_event(&text) {
Ok(event) => {
let _ = tx.send(event);
}
Err(error) => {
let _ = tx.send(diagnostic_event(format!(
"Backend protocol response was not valid Event JSON for {}: {error}",
target.display_label()
)));
}
}
}
Some(Ok(TungsteniteMessage::Close(_))) | None => {
let _ = tx.send(diagnostic_event(format!(
"Backend protocol command stream closed for {}",
target.display_label()
)));
break;
}
Some(Ok(TungsteniteMessage::Ping(_)))
| Some(Ok(TungsteniteMessage::Pong(_)))
| Some(Ok(TungsteniteMessage::Binary(_)))
| Some(Ok(TungsteniteMessage::Frame(_))) => {}
Some(Err(error)) => {
let _ = tx.send(diagnostic_event(format!(
"Backend protocol WebSocket error for {}: {error}",
target.display_label()
)));
break;
} }
} }
} }
} }
} format!(
}
Err(error) => {
let _ = tx.send(diagnostic_event(format!(
"Backend protocol WebSocket connect failed for {}: {error}", "Backend protocol WebSocket connect failed for {}: {error}",
target.display_label() target.display_label()
))); )
while commands.recv().await.is_some() {
let _ = tx.send(diagnostic_event(format!(
"Backend protocol command was not sent because command stream is unavailable for {}",
target.display_label()
)));
}
}
}
}
fn diagnostic_event(message: impl Into<String>) -> Event {
Event::Error {
code: ErrorCode::Internal,
message: message.into(),
}
} }
fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeClientError> { fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeClientError> {
@@ -496,6 +403,19 @@ fn backend_runtime_worker_restore_path(
) )
} }
fn protocol_ws_request(
target: &BackendRuntimeTarget,
api: &BackendApiClient,
) -> Result<tokio_tungstenite::tungstenite::http::Request<()>, String> {
let mut request = protocol_ws_url(target)
.into_client_request()
.map_err(|error| error.to_string())?;
let value = HeaderValue::from_str(&api.authorization_header_value())
.map_err(|_| "saved Backend token is not a valid Authorization header".to_string())?;
request.headers_mut().insert(AUTHORIZATION, value);
Ok(request)
}
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String { fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
let path = format!( let path = format!(
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws", "/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
@@ -557,6 +477,26 @@ mod tests {
); );
} }
#[test]
fn protocol_request_attaches_saved_bearer_authorization() {
let target = BackendRuntimeTarget::new(
"http://127.0.0.1:8787/",
"workspace alpha",
"runtime/one",
"worker one",
);
let api = BackendApiClient::from_access_token_for_test(
"http://127.0.0.1:8787",
"websocket-secret",
)
.unwrap();
let request = protocol_ws_request(&target, &api).unwrap();
assert_eq!(
request.headers().get(AUTHORIZATION).unwrap(),
"Bearer websocket-secret"
);
}
#[test] #[test]
fn backend_worker_summary_decodes_current_occupied_workdir_contract() { fn backend_worker_summary_decodes_current_occupied_workdir_contract() {
let payload = serde_json::json!({ let payload = serde_json::json!({
@@ -590,8 +530,8 @@ mod tests {
.unwrap() .unwrap()
.occupied_by .occupied_by
.expect("occupied Workdir"); .expect("occupied Workdir");
assert_eq!(occupied_by.worker.runtime_id, "arcadia"); assert_eq!(occupied_by.runtime_id, "arcadia");
assert_eq!(occupied_by.worker.worker_id, "worker-opaque-64"); assert_eq!(occupied_by.worker_id, "worker-opaque-64");
let mut stale = payload; let mut stale = payload;
stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64); stale["working_directory"]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64);
+68 -73
View File
@@ -1,18 +1,17 @@
use crate::{BackendApiClient, BackendApiClientError};
use reqwest::Method;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
use workspace_api::{RepositoryObservedStatus, RepositorySource}; use workspace_api::{
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord,
WorkspaceSummary,
};
const DEFAULT_WORKSPACE_LIMIT: usize = 200; const DEFAULT_WORKSPACE_LIMIT: usize = 200;
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub type BackendWorkspace = WorkspaceSummary;
pub struct BackendWorkspace { pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
pub workspace_id: String, pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord;
pub owner_account_id: Option<String>,
pub display_name: String,
pub state: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -30,30 +29,6 @@ pub struct CreateBackendWorkspaceRepository {
pub default_ref: Option<String>, pub default_ref: Option<String>,
} }
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CreateBackendWorkspaceResponse {
pub workspace: BackendWorkspace,
pub repository: CreateBackendWorkspaceRepositoryRecord,
pub config_revision: u64,
pub request_fingerprint: String,
pub replayed: bool,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct CreateBackendWorkspaceRepositoryRecord {
pub workspace_id: String,
pub repository_id: String,
pub name: String,
pub kind: String,
pub provider: Option<String>,
pub source: RepositorySource,
pub default_ref: Option<String>,
pub source_revision: u64,
pub source_fingerprint: String,
pub observed_status: RepositoryObservedStatus,
pub observed_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendWorkspaceCatalogTarget { pub struct BackendWorkspaceCatalogTarget {
pub base_url: String, pub base_url: String,
@@ -70,7 +45,7 @@ impl BackendWorkspaceCatalogTarget {
#[derive(Debug)] #[derive(Debug)]
pub enum BackendWorkspaceClientError { pub enum BackendWorkspaceClientError {
InvalidTarget(String), InvalidTarget(String),
RequestFailed { status: u16, message: String }, Api(BackendApiClientError),
Http(reqwest::Error), Http(reqwest::Error),
} }
@@ -78,9 +53,7 @@ impl fmt::Display for BackendWorkspaceClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::InvalidTarget(message) => f.write_str(message), Self::InvalidTarget(message) => f.write_str(message),
Self::RequestFailed { status, message } => { Self::Api(error) => write!(f, "{error}"),
write!(f, "Backend request failed with HTTP {status}: {message}")
}
Self::Http(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"),
} }
} }
@@ -88,6 +61,12 @@ impl fmt::Display for BackendWorkspaceClientError {
impl std::error::Error for BackendWorkspaceClientError {} impl std::error::Error for BackendWorkspaceClientError {}
impl From<BackendApiClientError> for BackendWorkspaceClientError {
fn from(error: BackendApiClientError) -> Self {
Self::Api(error)
}
}
impl From<reqwest::Error> for BackendWorkspaceClientError { impl From<reqwest::Error> for BackendWorkspaceClientError {
fn from(error: reqwest::Error) -> Self { fn from(error: reqwest::Error) -> Self {
Self::Http(error) Self::Http(error)
@@ -97,56 +76,72 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
pub async fn list_backend_workspaces( pub async fn list_backend_workspaces(
target: &BackendWorkspaceCatalogTarget, target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> { ) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
validate_target(target)?; let client = BackendApiClient::from_stored_token(&target.base_url)?;
let url = format!( list_backend_workspaces_with_client(&client).await
"{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}", }
target.base_url.trim_end_matches('/')
); async fn list_backend_workspaces_with_client(
let response = reqwest::Client::new().get(url).send().await?; client: &BackendApiClient,
let response = require_success(response).await?; ) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
Ok(response.json::<Vec<BackendWorkspace>>().await?) let response = client
.request(
Method::GET,
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
)?
.send()
.await?;
client.check_status(response.status())?;
Ok(response.json::<WorkspaceCatalogListResponse>().await?.0)
} }
pub async fn create_backend_workspace( pub async fn create_backend_workspace(
target: &BackendWorkspaceCatalogTarget, target: &BackendWorkspaceCatalogTarget,
request: &CreateBackendWorkspaceRequest, request: &CreateBackendWorkspaceRequest,
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> { ) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
validate_target(target)?; let client = BackendApiClient::from_stored_token(&target.base_url)?;
let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/')); let response = client
let response = reqwest::Client::new() .request(Method::POST, "/api/workspaces")?
.post(url)
.json(request) .json(request)
.send() .send()
.await?; .await?;
let response = require_success(response).await?; client.check_status(response.status())?;
Ok(response.json::<CreateBackendWorkspaceResponse>().await?) Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
} }
async fn require_success(
response: reqwest::Response,
) -> Result<reqwest::Response, BackendWorkspaceClientError> {
if response.status().is_success() {
return Ok(response);
}
let status = response.status().as_u16();
let message = response.text().await.unwrap_or_default();
Err(BackendWorkspaceClientError::RequestFailed { status, message })
}
fn validate_target(
target: &BackendWorkspaceCatalogTarget,
) -> Result<(), BackendWorkspaceClientError> {
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
return Err(BackendWorkspaceClientError::InvalidTarget(
"Backend API base URL must start with http:// or https://".to_string(),
));
}
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
#[tokio::test]
async fn workspace_catalog_request_uses_shared_bearer_client() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = vec![0; 4096];
let read = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
assert!(request.starts_with("get /api/workspaces?limit=200 "));
assert!(request.contains("authorization: bearer catalog-secret\r\n"));
stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
)
.unwrap();
});
let client =
BackendApiClient::from_access_token_for_test(&base_url, "catalog-secret").unwrap();
assert!(
list_backend_workspaces_with_client(&client)
.await
.unwrap()
.is_empty()
);
handle.join().unwrap();
}
#[test] #[test]
fn create_request_keeps_operation_key_for_exact_retry() { fn create_request_keeps_operation_key_for_exact_retry() {
+137
View File
@@ -0,0 +1,137 @@
use std::error::Error;
use std::fmt;
use protocol::stream::{decode_event, encode_method};
use protocol::{Event, Method};
use crate::transport::Socket;
/// Typed Worker protocol client over an injected message transport.
pub struct Client<T> {
socket: T,
}
#[derive(Debug)]
pub enum ClientError<E> {
Transport(E),
Protocol(serde_json::Error),
}
impl<T> Client<T> {
pub fn new(socket: T) -> Self {
Self { socket }
}
pub fn into_inner(self) -> T {
self.socket
}
}
impl<T: Socket> Client<T> {
pub async fn send(&mut self, method: &Method) -> Result<(), ClientError<T::Error>> {
let message = encode_method(method).map_err(ClientError::Protocol)?;
self.socket
.send(message)
.await
.map_err(ClientError::Transport)
}
pub async fn next_event(&mut self) -> Result<Option<Event>, ClientError<T::Error>> {
self.socket
.next()
.await
.map_err(ClientError::Transport)?
.map(|message| decode_event(&message).map_err(ClientError::Protocol))
.transpose()
}
pub fn try_next_event(&mut self) -> Result<Option<Event>, ClientError<T::Error>> {
self.socket
.try_next()
.map_err(ClientError::Transport)?
.map(|message| decode_event(&message).map_err(ClientError::Protocol))
.transpose()
}
}
impl<E: fmt::Display> fmt::Display for ClientError<E> {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Transport(error) => write!(formatter, "Worker transport error: {error}"),
Self::Protocol(error) => write!(formatter, "Worker protocol error: {error}"),
}
}
}
impl<E: Error + 'static> Error for ClientError<E> {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Transport(error) => Some(error),
Self::Protocol(error) => Some(error),
}
}
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::convert::Infallible;
use async_trait::async_trait;
use protocol::stream::{decode_method, encode_event};
use protocol::{Event, Method, WorkerStatus};
use super::Client;
use crate::transport::Socket;
#[derive(Default)]
struct TestSocket {
sent: Vec<String>,
incoming: VecDeque<String>,
}
#[async_trait]
impl Socket for TestSocket {
type Error = Infallible;
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
self.sent.push(message);
Ok(())
}
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
Ok(self.incoming.pop_front())
}
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
Ok(self.incoming.pop_front())
}
}
#[tokio::test]
async fn encodes_methods_and_decodes_events_above_transport() {
let mut socket = TestSocket::default();
socket.incoming.push_back(
encode_event(&Event::Status {
status: WorkerStatus::Idle,
})
.expect("encode event"),
);
let mut client = Client::new(socket);
client
.send(&Method::run_text("hello"))
.await
.expect("send method");
assert!(matches!(
decode_method(&client.socket.sent[0]),
Ok(Method::Run { .. })
));
assert!(matches!(
client.next_event().await,
Ok(Some(Event::Status {
status: WorkerStatus::Idle
}))
));
}
}
+23 -32
View File
@@ -1,57 +1,48 @@
//! Worker プロトコルを喋るクライアント。 //! Backend Workspace/Runtime と既存 Worker protocol へ接続するクライアント。
//! //!
//! - [`WorkerClient`]: 既存 worker の Unix ソケットへ接続して `Method` を送り、 //! Standalone execution is owned by the `standalone` crate and does not spawn
//! `Event` を受け取る低レベル接続。 //! a Worker subprocess through this crate.
//! - [`spawn`]: worker バイナリをサブプロセスとして起動し、`YOI-READY`
//! ハンドシェイクが終わるまで待つフロー。subprocess を立ち上げる必要が
//! ない呼び出し側 (=既存 worker に attach する場合) は使わなくてよい。
//!
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
pub mod backend_auth; pub mod backend_api;
mod backend_auth;
pub mod backend_runtime; pub mod backend_runtime;
pub mod backend_workspace; pub mod backend_workspace;
pub mod runtime_command; mod client;
pub mod spawn;
pub mod target; pub mod target;
pub mod ticket_role; pub mod transport;
mod worker_client;
mod workspace_product; mod workspace_product;
pub use backend_api::{
BackendApiClient, BackendApiClientError, BackendOrigin, backend_token_file_path,
save_backend_token,
};
pub use backend_auth::{ pub use backend_auth::{
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse, BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
poll_device_login, start_device_login, wait_for_device_login, poll_device_login, start_device_login, wait_for_device_login,
}; };
pub use backend_runtime::{ pub use backend_runtime::{
BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClient, BackendRuntimeClientError, BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClientError,
BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary,
BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary, BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers, BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime,
list_backend_workers, restore_backend_worker, list_backend_stopped_workers, list_backend_workers, restore_backend_worker,
}; };
pub use backend_workspace::{ pub use backend_workspace::{
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError, BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest, CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces, CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
}; };
pub use runtime_command::WorkerRuntimeCommand; pub use client::{Client, ClientError};
pub use target::{ pub use target::{
BackendTarget, Dashboard, LocalTarget, ResolvedTarget, Target, TargetError, TargetKind, BackendTarget, Dashboard, ResolvedTarget, StandaloneTarget, StandaloneWorkerListIntent,
WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection,
WorkerResume, WorkerSpawn, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
}; };
pub use workspace_api::{
pub use spawn::{ CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition,
SpawnConfig, SpawnError, SpawnReady, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions, CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse,
spawn_worker, spawn_worker_with_options, CompanionTranscriptItem, CompanionTranscriptProjection, CompanionTranscriptRole,
CompanionTransportSummary, ObjectiveDetail, ObjectiveSummary,
}; };
pub use ticket_role::{
TicketRef, TicketRoleLaunchContext, TicketRoleLaunchError, TicketRoleLaunchOptions,
TicketRoleLaunchPlan, TicketRoleLaunchResult, TicketRolePreRunWarning,
launch_ticket_role_worker, launch_ticket_role_worker_with_options, plan_ticket_role_launch,
plan_ticket_role_launch_with_config,
};
pub use worker_client::WorkerClient;
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary};
pub use workspace_product::BackendWorkspaceProductClient; pub use workspace_product::BackendWorkspaceProductClient;
-435
View File
@@ -1,435 +0,0 @@
//! Worker runtime command をサブプロセスとして立ち上げ、`YOI-READY` を待つ
//! ハンドシェイク。
//!
//! - 親プロセス (TUI / GUI / E2E) は profile/default/typed restore flags を
//! 指定してこの関数に渡す。worker はそれを受けて socket を bind し、stderr に
//! `YOI-READY\t<name>\t<socket>` を吐く。
//! - 待機中の stderr 行は `progress` コールバック越しに呼び出し側へ流す。
//! UI の進捗表示や E2E のログ収集はここで賄う。
//! - `kill_on_drop = false` + `process_group(0)` により、親プロセス
//! ライフサイクルから切り離した detached worker を作る。ready 後の lifecycle
//! 管理は runtime ディレクトリ / socket を介して行う。
use std::io;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use crate::WorkerRuntimeCommand;
use tokio::process::Command;
use uuid::Uuid;
const READY_PREFIX: &str = "YOI-READY\t";
const READY_TIMEOUT: Duration = Duration::from_secs(20);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerProcessLaunchConfig {
pub runtime_command: WorkerRuntimeCommand,
/// `worker.name` として使う識別子。runtime ディレクトリ
/// (`manifest::paths::worker_runtime_dir`) の解決と、ready 行に乗る
/// 名前との突き合わせに使う。
pub worker_name: String,
/// Optional reusable Profile selector. Worker identity is always supplied
/// separately with `--worker`; profile selection must not imply a name.
pub profile: Option<String>,
/// Explicit runtime workspace root. The child receives it via
/// `--workspace` so startup does not infer workspace identity from the
/// parent process cwd.
pub workspace_root: PathBuf,
/// Optional child process cwd. This is not runtime workspace identity and
/// is not passed as a CLI argument; the child observes it as its ordinary
/// process current directory.
pub cwd: Option<PathBuf>,
/// `Some(id)` のとき `--session <id>` を付与し、当該セッションから
/// resume させる。
pub resume_from: Option<Uuid>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct WorkerProcessLaunchOptions {
/// Extra child CLI arguments supplied by an upper resolver layer. The
/// low-level launch config intentionally does not model Ticket IDs,
/// Ticket roles, orchestration roles, executable authority, or raw
/// browser-provided profile/cwd/workspace inputs.
pub extra_args: Vec<String>,
}
impl WorkerProcessLaunchOptions {
pub fn with_hidden_arg(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.extra_args.extend([name.into(), value.into()]);
self
}
pub fn is_empty(&self) -> bool {
self.extra_args.is_empty()
}
}
pub type SpawnConfig = WorkerProcessLaunchConfig;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnReady {
pub worker_name: String,
pub socket_path: PathBuf,
}
#[derive(Debug)]
pub enum SpawnError {
Io(io::Error),
/// runtime ディレクトリが解決できなかった (環境変数未設定等)。
RuntimeDirUnavailable,
WorkerLaunchFailed {
command: WorkerRuntimeCommand,
source: io::Error,
},
WorkerExitedEarly {
stderr_tail: String,
},
Timeout,
}
impl std::fmt::Display for SpawnError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "io error: {e}"),
Self::RuntimeDirUnavailable => write!(
f,
"could not resolve runtime directory (set YOI_HOME, YOI_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)"
),
Self::WorkerLaunchFailed { command, source } => write!(
f,
"failed to launch worker runtime command `{command}`: {source}"
),
Self::WorkerExitedEarly { stderr_tail } => {
if stderr_tail.is_empty() {
write!(f, "worker exited before becoming ready")
} else {
write!(f, "worker exited before becoming ready: {stderr_tail}")
}
}
Self::Timeout => write!(
f,
"worker did not become ready within {}s",
READY_TIMEOUT.as_secs()
),
}
}
}
impl std::error::Error for SpawnError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(error) | Self::WorkerLaunchFailed { source: error, .. } => Some(error),
Self::RuntimeDirUnavailable | Self::WorkerExitedEarly { .. } | Self::Timeout => None,
}
}
}
impl From<io::Error> for SpawnError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
fn runtime_args(
config: &WorkerProcessLaunchConfig,
options: &WorkerProcessLaunchOptions,
) -> Vec<String> {
let mut args = vec![
"--workspace".to_string(),
config.workspace_root.display().to_string(),
];
if let Some(id) = config.resume_from {
args.extend([
"--session".to_string(),
id.to_string(),
"--worker".to_string(),
config.worker_name.clone(),
]);
} else {
args.extend(["--worker".to_string(), config.worker_name.clone()]);
if let Some(profile) = &config.profile {
args.extend(["--profile".to_string(), profile.clone()]);
}
}
args.extend(options.extra_args.clone());
args
}
/// worker を spawn し、`YOI-READY` ハンドシェイクが終わるまで待つ。
///
/// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる
/// (ready 行自体は除外される)。UI の表示更新や E2E ログ取得に使う。
pub async fn spawn_worker<F>(
config: WorkerProcessLaunchConfig,
progress: F,
) -> Result<SpawnReady, SpawnError>
where
F: FnMut(&str),
{
spawn_worker_with_options(config, WorkerProcessLaunchOptions::default(), progress).await
}
pub async fn spawn_worker_with_options<F>(
config: WorkerProcessLaunchConfig,
options: WorkerProcessLaunchOptions,
mut progress: F,
) -> Result<SpawnReady, SpawnError>
where
F: FnMut(&str),
{
let worker_runtime_dir = manifest::paths::worker_runtime_dir(&config.worker_name)
.ok_or(SpawnError::RuntimeDirUnavailable)?;
std::fs::create_dir_all(&worker_runtime_dir).map_err(SpawnError::Io)?;
let stderr_path = worker_runtime_dir.join("stderr.log");
let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?;
let mut command = Command::new(config.runtime_command.program());
command
.args(config.runtime_command.prefix_args())
.current_dir(config.cwd.as_ref().unwrap_or(&config.workspace_root))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::from(stderr_file))
.process_group(0);
for arg in runtime_args(&config, &options) {
command.arg(arg);
}
let mut child = command
.spawn()
.map_err(|source| SpawnError::WorkerLaunchFailed {
command: config.runtime_command.clone(),
source,
})?;
// Default `kill_on_drop = false` plus `process_group(0)` makes this
// a detached Worker once startup succeeds: dropping the handle does not
// terminate it, and terminal-generated signals for the parent's
// process group do not hit the Worker. Runtime state/socket files are
// the source of truth after that point.
let ready = match wait_for_ready_file(&mut progress, &stderr_path, &mut child).await {
Ok(ready) => ready,
Err(e) => {
let _ = child.start_kill();
let _ = child.wait().await;
return Err(e);
}
};
tokio::spawn(async move {
let _ = child.wait().await;
});
Ok(ready)
}
async fn wait_for_ready_file<F>(
progress: &mut F,
stderr_path: &Path,
child: &mut tokio::process::Child,
) -> Result<SpawnReady, SpawnError>
where
F: FnMut(&str),
{
let mut tail = StderrTail::new();
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
let mut offset = 0usize;
loop {
let content = match tokio::fs::read_to_string(stderr_path).await {
Ok(content) => content,
Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(SpawnError::Io(e)),
};
if content.len() > offset {
for line in content[offset..].lines() {
if let Some(rest) = line.strip_prefix(READY_PREFIX) {
let mut parts = rest.splitn(2, '\t');
let worker_name = parts.next().unwrap_or("").to_string();
let socket_str = parts.next().unwrap_or("").to_string();
if worker_name.is_empty() || socket_str.is_empty() {
return Err(SpawnError::WorkerExitedEarly {
stderr_tail: format!("malformed ready line: {line}"),
});
}
let socket_path = PathBuf::from(socket_str);
wait_for_socket(
&socket_path,
deadline,
child,
stderr_path,
&mut tail,
&mut offset,
)
.await?;
return Ok(SpawnReady {
worker_name,
socket_path,
});
}
tail.push(line);
progress(line);
}
offset = content.len();
}
if tokio::time::Instant::now() >= deadline {
return Err(SpawnError::Timeout);
}
tokio::select! {
status = child.wait() => {
let _ = status;
// Worker は exit 直前に最終 stderr 行を flush することがある。
// child.wait() が解決した後に再読みして、原因行を取りこ
// ぼさず WorkerExitedEarly に載せる。
drain_stderr_into_tail(stderr_path, &mut tail, &mut offset).await;
return Err(SpawnError::WorkerExitedEarly {
stderr_tail: tail.into_string(),
});
}
_ = tokio::time::sleep(Duration::from_millis(100)) => {}
}
}
}
async fn wait_for_socket(
socket_path: &Path,
deadline: tokio::time::Instant,
child: &mut tokio::process::Child,
stderr_path: &Path,
tail: &mut StderrTail,
offset: &mut usize,
) -> Result<(), SpawnError> {
loop {
match tokio::net::UnixStream::connect(socket_path).await {
Ok(_) => return Ok(()),
Err(e)
if e.kind() == io::ErrorKind::NotFound
|| e.kind() == io::ErrorKind::ConnectionRefused => {}
Err(e) => return Err(SpawnError::Io(e)),
}
if tokio::time::Instant::now() >= deadline {
return Err(SpawnError::Timeout);
}
tokio::select! {
status = child.wait() => {
let _ = status;
drain_stderr_into_tail(stderr_path, tail, offset).await;
return Err(SpawnError::WorkerExitedEarly {
stderr_tail: tail.as_string(),
});
}
_ = tokio::time::sleep(Duration::from_millis(50)) => {}
}
}
}
async fn drain_stderr_into_tail(stderr_path: &Path, tail: &mut StderrTail, offset: &mut usize) {
let Ok(content) = tokio::fs::read_to_string(stderr_path).await else {
return;
};
if content.len() <= *offset {
return;
}
for line in content[*offset..].lines() {
if !line.starts_with(READY_PREFIX) {
tail.push(line);
}
}
*offset = content.len();
}
struct StderrTail {
lines: std::collections::VecDeque<String>,
}
impl StderrTail {
fn new() -> Self {
Self {
lines: std::collections::VecDeque::with_capacity(8),
}
}
fn push(&mut self, line: &str) {
if self.lines.len() == 8 {
self.lines.pop_front();
}
self.lines.push_back(line.to_string());
}
fn as_string(&self) -> String {
self.lines.iter().cloned().collect::<Vec<_>>().join(" | ")
}
fn into_string(self) -> String {
self.lines.into_iter().collect::<Vec<_>>().join(" | ")
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
fn base_config() -> WorkerProcessLaunchConfig {
WorkerProcessLaunchConfig {
runtime_command: WorkerRuntimeCommand::new("/bin/yoi", vec![OsString::from("worker")]),
worker_name: "explicit-worker".to_string(),
profile: Some("project:companion".to_string()),
workspace_root: PathBuf::from("/work/other-project"),
cwd: None,
resume_from: None,
}
}
#[test]
fn runtime_args_keep_workspace_worker_and_profile_separate() {
assert_eq!(
runtime_args(&base_config(), &WorkerProcessLaunchOptions::default()),
vec![
"--workspace",
"/work/other-project",
"--worker",
"explicit-worker",
"--profile",
"project:companion",
]
);
}
#[test]
fn runtime_args_use_session_mode_without_profile_identity_alias() {
let mut config = base_config();
config.resume_from = Some(Uuid::nil());
assert_eq!(
runtime_args(&config, &WorkerProcessLaunchOptions::default()),
vec![
"--workspace",
"/work/other-project",
"--session",
"00000000-0000-0000-0000-000000000000",
"--worker",
"explicit-worker",
]
);
}
#[test]
fn runtime_args_include_upper_resolver_extra_args_without_child_cwd() {
let mut config = base_config();
config.cwd = Some(PathBuf::from("/work/main/.worktree/orchestration/yoi"));
assert_eq!(
runtime_args(
&config,
&WorkerProcessLaunchOptions::default()
.with_hidden_arg("--ticket-role", "orchestrator"),
),
vec![
"--workspace",
"/work/other-project",
"--worker",
"explicit-worker",
"--profile",
"project:companion",
"--ticket-role",
"orchestrator",
]
);
}
}
+157 -190
View File
@@ -1,16 +1,20 @@
use std::fmt; use std::{fmt, path::PathBuf};
use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand}; use crate::{
BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget,
BackendRuntimeTarget,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetKind { pub enum TargetKind {
Local, /// One-process Standalone authority with no Runtime or Workspace backend.
Standalone,
Backend, Backend,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedTarget { pub enum ResolvedTarget {
Local, Standalone,
Backend { Backend {
base_url: String, base_url: String,
workspace_id: String, workspace_id: String,
@@ -20,7 +24,7 @@ pub enum ResolvedTarget {
impl ResolvedTarget { impl ResolvedTarget {
pub fn kind(&self) -> TargetKind { pub fn kind(&self) -> TargetKind {
match self { match self {
Self::Local => TargetKind::Local, Self::Standalone => TargetKind::Standalone,
Self::Backend { .. } => TargetKind::Backend, Self::Backend { .. } => TargetKind::Backend,
} }
} }
@@ -29,31 +33,12 @@ impl ResolvedTarget {
impl fmt::Display for TargetKind { impl fmt::Display for TargetKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::Local => f.write_str("local"), Self::Standalone => f.write_str("Standalone"),
Self::Backend => f.write_str("Backend"), Self::Backend => f.write_str("Backend"),
} }
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LocalTarget;
impl LocalTarget {
pub fn new() -> Self {
Self
}
fn runtime_command(&self) -> Result<WorkerRuntimeCommand, TargetError> {
WorkerRuntimeCommand::resolve().map_err(TargetError::local_runtime_command)
}
}
impl Default for LocalTarget {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendTarget { pub struct BackendTarget {
pub base_url: String, pub base_url: String,
@@ -62,11 +47,19 @@ pub struct BackendTarget {
impl BackendTarget { impl BackendTarget {
pub fn new(base_url: impl Into<String>, workspace_id: Option<impl Into<String>>) -> Self { pub fn new(base_url: impl Into<String>, workspace_id: Option<impl Into<String>>) -> Self {
let base_url = base_url.into();
let base_url = BackendOrigin::parse(&base_url)
.map(|origin| origin.to_string())
.unwrap_or(base_url);
Self { Self {
base_url: base_url.into(), base_url,
workspace_id: workspace_id.map(Into::into), workspace_id: workspace_id.map(Into::into),
} }
} }
pub fn authenticated_client(&self) -> Result<BackendApiClient, BackendApiClientError> {
BackendApiClient::from_stored_token(&self.base_url)
}
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -108,34 +101,31 @@ impl WorkerConnectionSelector {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerSpawn { pub struct WorkerSpawn {
pub runtime_command: WorkerRuntimeCommand, pub state_dir: PathBuf,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerByName { pub struct StandaloneWorkerListIntent {
pub runtime_command: WorkerRuntimeCommand, pub state_dir: PathBuf,
pub cwd: PathBuf,
pub include_all: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerResume { pub struct StandaloneWorkerResumeIntent {
pub runtime_command: WorkerRuntimeCommand, pub state_dir: PathBuf,
pub worker_id: String,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Dashboard { pub struct Dashboard {
Local { pub base_url: String,
runtime_command: WorkerRuntimeCommand, pub workspace_id: String,
},
Backend {
base_url: String,
workspace_id: String,
},
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkerList { pub struct WorkerList {
pub local_runtime_command: Option<WorkerRuntimeCommand>, pub backend_target: BackendRuntimeListTarget,
pub backend_target: Option<BackendRuntimeListTarget>,
pub include_stopped: bool, pub include_stopped: bool,
} }
@@ -161,12 +151,6 @@ impl TargetError {
message: format!("invalid {target} target: {}", message.into()), message: format!("invalid {target} target: {}", message.into()),
} }
} }
fn local_runtime_command(error: std::io::Error) -> Self {
Self {
message: format!("failed to resolve local Worker runtime command: {error}"),
}
}
} }
impl fmt::Display for TargetError { impl fmt::Display for TargetError {
@@ -183,71 +167,40 @@ pub trait Target: fmt::Debug + Send + Sync {
/// Resolve the target once for Workspace product-state operations. /// Resolve the target once for Workspace product-state operations.
/// ///
/// Backend targets must carry an explicit Workspace identity. Callers use /// Backend targets must carry an explicit Workspace identity. Callers use
/// this value instead of rediscovering Backend/local authority from cwd or /// this value instead of rediscovering authority from cwd or process
/// process configuration after command dispatch. /// configuration after command dispatch.
fn resolve(&self) -> Result<ResolvedTarget, TargetError>; fn resolve(&self) -> Result<ResolvedTarget, TargetError>;
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError>;
fn worker_by_name(&self) -> Result<WorkerByName, TargetError>;
fn resume_worker(&self) -> Result<WorkerResume, TargetError>;
fn dashboard(&self) -> Result<Dashboard, TargetError>;
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError>;
fn connect_worker(
&self,
selector: WorkerConnectionSelector,
) -> Result<WorkerConnection, TargetError>;
}
impl Target for LocalTarget {
fn kind(&self) -> TargetKind {
TargetKind::Local
}
fn resolve(&self) -> Result<ResolvedTarget, TargetError> {
Ok(ResolvedTarget::Local)
}
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> { fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
Ok(WorkerSpawn { Err(TargetError::unsupported("Worker spawn", self.kind()))
runtime_command: self.runtime_command()?,
})
} }
fn worker_by_name(&self) -> Result<WorkerByName, TargetError> { fn standalone_worker_list(
Ok(WorkerByName { &self,
runtime_command: self.runtime_command()?, _include_all: bool,
}) ) -> Result<StandaloneWorkerListIntent, TargetError> {
Err(TargetError::unsupported(
"standalone Worker listing",
self.kind(),
))
} }
fn resume_worker(&self) -> Result<WorkerResume, TargetError> { fn standalone_worker_resume(
Ok(WorkerResume { &self,
runtime_command: self.runtime_command()?, _worker_id: String,
}) ) -> Result<StandaloneWorkerResumeIntent, TargetError> {
Err(TargetError::unsupported(
"standalone Worker restore",
self.kind(),
))
} }
fn dashboard(&self) -> Result<Dashboard, TargetError> { fn dashboard(&self) -> Result<Dashboard, TargetError> {
Ok(Dashboard::Local { Err(TargetError::unsupported("Worker dashboard", self.kind()))
runtime_command: self.runtime_command()?,
})
} }
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> { fn list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
if request.runtime_id.is_some() { Err(TargetError::unsupported("Worker listing", self.kind()))
return Err(TargetError::unsupported(
"Explicit runtime id for local worker listing",
self.kind(),
));
}
Ok(WorkerList {
local_runtime_command: Some(self.runtime_command()?),
backend_target: None,
include_stopped: request.include_stopped,
})
} }
fn connect_worker( fn connect_worker(
@@ -261,6 +214,59 @@ impl Target for LocalTarget {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StandaloneTarget {
state_dir: PathBuf,
}
impl StandaloneTarget {
#[must_use]
pub fn new(state_dir: impl Into<PathBuf>) -> Self {
Self {
state_dir: state_dir.into(),
}
}
}
impl Target for StandaloneTarget {
fn kind(&self) -> TargetKind {
TargetKind::Standalone
}
fn resolve(&self) -> Result<ResolvedTarget, TargetError> {
Ok(ResolvedTarget::Standalone)
}
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
Ok(WorkerSpawn {
state_dir: self.state_dir.clone(),
})
}
fn standalone_worker_list(
&self,
include_all: bool,
) -> Result<StandaloneWorkerListIntent, TargetError> {
let cwd = std::env::current_dir()
.map_err(|error| TargetError::invalid(self.kind(), error.to_string()))?;
Ok(StandaloneWorkerListIntent {
state_dir: self.state_dir.clone(),
cwd,
include_all,
})
}
fn standalone_worker_resume(
&self,
worker_id: String,
) -> Result<StandaloneWorkerResumeIntent, TargetError> {
Ok(StandaloneWorkerResumeIntent {
state_dir: self.state_dir.clone(),
worker_id,
})
}
}
impl Target for BackendTarget { impl Target for BackendTarget {
fn kind(&self) -> TargetKind { fn kind(&self) -> TargetKind {
TargetKind::Backend TargetKind::Backend
@@ -279,42 +285,27 @@ impl Target for BackendTarget {
}) })
} }
fn spawn_worker(&self) -> Result<WorkerSpawn, TargetError> {
Err(TargetError::unsupported("Worker spawn", self.kind()))
}
fn worker_by_name(&self) -> Result<WorkerByName, TargetError> {
Err(TargetError::unsupported(
"Worker name attachment",
self.kind(),
))
}
fn resume_worker(&self) -> Result<WorkerResume, TargetError> {
Err(TargetError::unsupported("Worker resume", self.kind()))
}
fn dashboard(&self) -> Result<Dashboard, TargetError> { fn dashboard(&self) -> Result<Dashboard, TargetError> {
match self.resolve()? { let ResolvedTarget::Backend {
ResolvedTarget::Backend {
base_url, base_url,
workspace_id, workspace_id,
} => Ok(Dashboard::Backend { } = self.resolve()?
else {
unreachable!("BackendTarget resolves only Backend authority")
};
Ok(Dashboard {
base_url, base_url,
workspace_id, workspace_id,
}), })
ResolvedTarget::Local => unreachable!("BackendTarget cannot resolve as Local"),
}
} }
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> { fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
Ok(WorkerList { Ok(WorkerList {
local_runtime_command: None, backend_target: BackendRuntimeListTarget::new(
backend_target: Some(BackendRuntimeListTarget::new(
self.base_url.clone(), self.base_url.clone(),
self.workspace_id.clone(), self.workspace_id.clone(),
request.runtime_id, request.runtime_id,
)), ),
include_stopped: request.include_stopped, include_stopped: request.include_stopped,
}) })
} }
@@ -371,8 +362,34 @@ mod tests {
} }
#[test] #[test]
fn local_target_resolves_local_product_state_authority() { fn standalone_target_carries_in_process_state_without_runtime_command() {
assert_eq!(LocalTarget::new().resolve().unwrap(), ResolvedTarget::Local); let target = StandaloneTarget::new("/tmp/yoi-standalone-state");
assert_eq!(target.kind(), TargetKind::Standalone);
assert_eq!(target.resolve().unwrap(), ResolvedTarget::Standalone);
assert_eq!(
target.spawn_worker().unwrap(),
WorkerSpawn {
state_dir: PathBuf::from("/tmp/yoi-standalone-state"),
}
);
}
#[test]
fn standalone_target_never_exposes_workspace_worker_operations() {
let target = StandaloneTarget::new("/tmp/yoi-standalone-state");
assert_eq!(
target
.list_workers(WorkerListRequest::new(None))
.unwrap_err()
.to_string(),
"Worker listing is not supported by Standalone target"
);
assert_eq!(
target.dashboard().unwrap_err().to_string(),
"Worker dashboard is not supported by Standalone target"
);
} }
#[test] #[test]
@@ -381,26 +398,13 @@ mod tests {
assert_eq!( assert_eq!(
target.dashboard().unwrap(), target.dashboard().unwrap(),
Dashboard::Backend { Dashboard {
base_url: "http://127.0.0.1:8787".to_string(), base_url: "http://127.0.0.1:8787".to_string(),
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_string(),
} }
); );
} }
#[test]
fn backend_target_rejects_dashboard_without_workspace_selection() {
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
assert!(
target
.dashboard()
.unwrap_err()
.to_string()
.contains("workspace selection is required")
);
}
#[test] #[test]
fn backend_target_builds_worker_list() { fn backend_target_builds_worker_list() {
let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a")); let target = BackendTarget::new("http://127.0.0.1:8787", Some("workspace-a"));
@@ -408,26 +412,13 @@ mod tests {
.list_workers(WorkerListRequest::new(Some("runtime-a".to_string()))) .list_workers(WorkerListRequest::new(Some("runtime-a".to_string())))
.unwrap(); .unwrap();
assert_eq!(workers.backend_target.base_url, "http://127.0.0.1:8787");
assert_eq!( assert_eq!(
workers.backend_target.as_ref().unwrap().base_url, workers.backend_target.workspace_id.as_deref(),
"http://127.0.0.1:8787"
);
assert_eq!(
workers
.backend_target
.as_ref()
.unwrap()
.workspace_id
.as_deref(),
Some("workspace-a") Some("workspace-a")
); );
assert_eq!( assert_eq!(
workers workers.backend_target.runtime_id.as_deref(),
.backend_target
.as_ref()
.unwrap()
.runtime_id
.as_deref(),
Some("runtime-a") Some("runtime-a")
); );
} }
@@ -446,41 +437,17 @@ mod tests {
} }
#[test] #[test]
fn backend_target_rejects_worker_connection_before_workspace_selection() { fn standalone_target_builds_explicit_worker_intents() {
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>); let target = StandaloneTarget::new("/tmp/yoi-client-workers");
let error = let list = target.standalone_worker_list(true).unwrap();
match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-b")) { assert_eq!(list.state_dir, PathBuf::from("/tmp/yoi-client-workers"));
Ok(_) => panic!("unscoped connection must fail"), assert!(list.include_all);
Err(error) => error, assert!(list.cwd.is_absolute());
};
assert!( let resume = target
error .standalone_worker_resume("019d1234-0000-7000-8000-000000000000".to_string())
.to_string()
.contains("workspace selection is required")
);
}
#[test]
fn backend_target_rejects_local_worker_operations() {
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
let err = target.spawn_worker().unwrap_err();
assert_eq!(
err.to_string(),
"Worker spawn is not supported by Backend target"
);
}
#[test]
fn local_target_builds_local_worker_list() {
let target = LocalTarget::new();
let workers = target
.list_workers(WorkerListRequest::with_stopped(None))
.unwrap(); .unwrap();
assert_eq!(resume.state_dir, list.state_dir);
assert!(workers.local_runtime_command.is_some()); assert_eq!(resume.worker_id, "019d1234-0000-7000-8000-000000000000");
assert!(workers.backend_target.is_none());
assert!(workers.include_stopped);
} }
} }
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
use async_trait::async_trait;
use thiserror::Error;
use tokio::sync::mpsc;
use super::Socket as SocketContract;
const CHANNEL_CAPACITY: usize = 256;
pub struct Socket {
outgoing: mpsc::Sender<String>,
incoming: mpsc::Receiver<String>,
}
/// Host-side endpoint paired with an in-process client transport.
pub struct Peer {
incoming: mpsc::Receiver<String>,
outgoing: mpsc::Sender<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum SocketError {
#[error("in-process Worker protocol transport closed")]
Closed,
}
impl Socket {
pub fn pair() -> (Self, Peer) {
let (client_tx, peer_rx) = mpsc::channel(CHANNEL_CAPACITY);
let (peer_tx, client_rx) = mpsc::channel(CHANNEL_CAPACITY);
(
Self {
outgoing: client_tx,
incoming: client_rx,
},
Peer {
incoming: peer_rx,
outgoing: peer_tx,
},
)
}
}
#[async_trait]
impl SocketContract for Socket {
type Error = SocketError;
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
self.outgoing
.send(message)
.await
.map_err(|_| SocketError::Closed)
}
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
Ok(self.incoming.recv().await)
}
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
match self.incoming.try_recv() {
Ok(message) => Ok(Some(message)),
Err(mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected) => {
Ok(None)
}
}
}
}
impl Peer {
pub async fn next(&mut self) -> Option<String> {
self.incoming.recv().await
}
pub async fn send(&self, message: String) -> Result<(), String> {
self.outgoing.send(message).await.map_err(|error| error.0)
}
}
#[cfg(test)]
mod tests {
use protocol::stream::{decode_method, encode_event};
use protocol::{Event, Method, WorkerStatus};
use super::Socket;
use crate::Client;
#[tokio::test]
async fn pair_carries_typed_protocol_through_generic_client() {
let (socket, mut peer) = Socket::pair();
let mut client = Client::new(socket);
client
.send(&Method::run_text("hello"))
.await
.expect("send method");
assert!(matches!(
peer.next().await.as_deref().map(decode_method),
Some(Ok(Method::Run { .. }))
));
peer.send(
encode_event(&Event::Status {
status: WorkerStatus::Idle,
})
.expect("encode event"),
)
.await
.expect("send event");
assert!(matches!(
client.next_event().await,
Ok(Some(Event::Status {
status: WorkerStatus::Idle
}))
));
}
}
+22
View File
@@ -0,0 +1,22 @@
use std::error::Error;
use async_trait::async_trait;
pub mod in_process;
pub mod unix_socket;
pub mod websocket;
/// Message-oriented transport for one Worker protocol connection.
///
/// Implementations own physical framing. `client::Client` owns the typed
/// Method/Event protocol encoding layered on top of these UTF-8 messages.
#[async_trait]
pub trait Socket {
type Error: Error + Send + Sync + 'static;
async fn send(&mut self, message: String) -> Result<(), Self::Error>;
async fn next(&mut self) -> Result<Option<String>, Self::Error>;
fn try_next(&mut self) -> Result<Option<String>, Self::Error>;
}
+172
View File
@@ -0,0 +1,172 @@
use std::io;
use std::path::Path;
use async_trait::async_trait;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use super::Socket as SocketContract;
pub struct Socket {
writer: tokio::io::WriteHalf<UnixStream>,
messages: mpsc::Receiver<io::Result<String>>,
reader_task: JoinHandle<()>,
}
impl Socket {
pub async fn connect(path: &Path) -> io::Result<Self> {
let stream = UnixStream::connect(path).await?;
let (reader, writer) = tokio::io::split(stream);
let (message_tx, messages) = mpsc::channel(256);
let reader_task = tokio::spawn(async move {
let mut lines = BufReader::new(reader).lines();
loop {
match lines.next_line().await {
Ok(Some(message)) if message.trim().is_empty() => {}
Ok(Some(message)) => {
if message_tx.send(Ok(message)).await.is_err() {
return;
}
}
Ok(None) => return,
Err(error) => {
let _ = message_tx.send(Err(error)).await;
return;
}
}
}
});
Ok(Self {
writer,
messages,
reader_task,
})
}
}
#[async_trait]
impl SocketContract for Socket {
type Error = io::Error;
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
self.writer.write_all(message.as_bytes()).await?;
self.writer.write_all(b"\n").await?;
self.writer.flush().await
}
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
match self.messages.recv().await {
Some(message) => message.map(Some),
None => Ok(None),
}
}
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
match self.messages.try_recv() {
Ok(message) => message.map(Some),
Err(mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected) => {
Ok(None)
}
}
}
}
impl Drop for Socket {
fn drop(&mut self) {
self.reader_task.abort();
}
}
#[cfg(test)]
mod tests {
use std::io::ErrorKind;
use std::time::Duration;
use protocol::stream::{decode_method, encode_event};
use protocol::{Event, Method, WorkerStatus};
use tempfile::tempdir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixListener;
use super::*;
use crate::Client;
async fn assert_peer_closed(stream: &mut UnixStream, reason: &str) {
let mut buf = [0_u8; 1];
match tokio::time::timeout(Duration::from_secs(1), stream.read(&mut buf))
.await
.expect(reason)
{
Ok(0) => {}
Err(error) if error.kind() == ErrorKind::ConnectionReset => {}
Ok(n) => panic!("server should observe peer close, read {n} byte(s)"),
Err(error) => panic!("server read failed unexpectedly: {error}"),
}
}
#[tokio::test]
async fn client_receives_events_over_unix_socket() {
let socket_dir = tempdir().unwrap();
let socket_path = socket_dir.path().join("events.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let event = encode_event(&Event::Status {
status: WorkerStatus::Idle,
})
.unwrap();
stream.write_all(event.as_bytes()).await.unwrap();
stream.write_all(b"\n").await.unwrap();
});
let mut client = Client::new(Socket::connect(&socket_path).await.unwrap());
let event = tokio::time::timeout(Duration::from_secs(1), client.next_event())
.await
.expect("client should receive event while alive")
.expect("transport should succeed");
assert!(matches!(
event,
Some(Event::Status {
status: WorkerStatus::Idle
})
));
server.await.unwrap();
}
#[tokio::test]
async fn client_sends_methods_over_unix_socket() {
let socket_dir = tempdir().unwrap();
let socket_path = socket_dir.path().join("send.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move {
let (reader, _) = listener.accept().await.unwrap();
BufReader::new(reader).lines().next_line().await.unwrap()
});
let mut client = Client::new(Socket::connect(&socket_path).await.unwrap());
client
.send(&Method::run_text("hello"))
.await
.expect("send method");
let received = server.await.unwrap().expect("method message");
assert!(matches!(decode_method(&received), Ok(Method::Run { .. })));
}
#[tokio::test]
async fn dropping_socket_closes_server_connection() {
let socket_dir = tempdir().unwrap();
let socket_path = socket_dir.path().join("drop.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
assert_peer_closed(&mut stream, "dropped socket should close promptly").await;
});
let socket = Socket::connect(&socket_path).await.unwrap();
drop(socket);
server.await.unwrap();
}
}
+140
View File
@@ -0,0 +1,140 @@
use async_trait::async_trait;
use futures::{SinkExt, StreamExt};
use thiserror::Error;
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::{self, Message};
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
use super::Socket as SocketContract;
type Writer = futures::stream::SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>;
pub struct Socket {
writer: Writer,
messages: mpsc::Receiver<Result<String, SocketError>>,
reader_task: JoinHandle<()>,
}
#[derive(Debug, Error)]
pub enum SocketError {
#[error("WebSocket transport failed: {0}")]
WebSocket(#[from] tungstenite::Error),
}
impl Socket {
pub async fn connect(request: Request<()>) -> Result<Self, SocketError> {
let (stream, _) = connect_async(request).await?;
let (writer, mut reader) = stream.split();
let (message_tx, messages) = mpsc::channel(256);
let reader_task = tokio::spawn(async move {
loop {
match reader.next().await {
Some(Ok(Message::Text(message))) => {
if message_tx.send(Ok(message.to_string())).await.is_err() {
return;
}
}
Some(Ok(Message::Close(_))) | None => return,
Some(Ok(
Message::Binary(_)
| Message::Ping(_)
| Message::Pong(_)
| Message::Frame(_),
)) => {}
Some(Err(error)) => {
let _ = message_tx.send(Err(SocketError::WebSocket(error))).await;
return;
}
}
}
});
Ok(Self {
writer,
messages,
reader_task,
})
}
}
#[async_trait]
impl SocketContract for Socket {
type Error = SocketError;
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
self.writer.send(Message::Text(message.into())).await?;
Ok(())
}
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
match self.messages.recv().await {
Some(message) => message.map(Some),
None => Ok(None),
}
}
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
match self.messages.try_recv() {
Ok(message) => message.map(Some),
Err(mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected) => {
Ok(None)
}
}
}
}
impl Drop for Socket {
fn drop(&mut self) {
self.reader_task.abort();
}
}
#[cfg(test)]
mod tests {
use futures::{SinkExt, StreamExt};
use protocol::stream::{decode_method, encode_event};
use protocol::{Event, Method, WorkerStatus};
use tokio::net::TcpListener;
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use super::*;
use crate::Client;
#[tokio::test]
async fn carries_typed_protocol_through_generic_client() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut socket = accept_async(stream).await.unwrap();
let message = socket.next().await.unwrap().unwrap();
assert!(matches!(
message,
Message::Text(ref text)
if matches!(decode_method(text), Ok(Method::Run { .. }))
));
let event = encode_event(&Event::Status {
status: WorkerStatus::Idle,
})
.unwrap();
socket.send(Message::Text(event.into())).await.unwrap();
});
let request = format!("ws://{address}").into_client_request().unwrap();
let mut client = Client::new(Socket::connect(request).await.unwrap());
client
.send(&Method::run_text("hello"))
.await
.expect("send method");
assert!(matches!(
client.next_event().await,
Ok(Some(Event::Status {
status: WorkerStatus::Idle
}))
));
server.await.unwrap();
}
}
-186
View File
@@ -1,186 +0,0 @@
use std::io;
use std::path::Path;
use protocol::stream::{JsonLineReader, JsonLineWriter};
use protocol::{Event, Method};
use tokio::net::UnixStream;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
pub struct WorkerClient {
writer: JsonLineWriter<tokio::io::WriteHalf<UnixStream>>,
event_rx: mpsc::Receiver<Event>,
reader_task: JoinHandle<()>,
}
impl WorkerClient {
pub async fn connect(path: &Path) -> Result<Self, io::Error> {
let stream = UnixStream::connect(path).await?;
let (reader, writer) = tokio::io::split(stream);
let writer = JsonLineWriter::new(writer);
let (event_tx, event_rx) = mpsc::channel::<Event>(256);
let reader_task = tokio::spawn(async move {
let mut reader = JsonLineReader::new(reader);
while let Ok(Some(event)) = reader.next::<Event>().await {
if event_tx.send(event).await.is_err() {
break;
}
}
});
Ok(Self {
writer,
event_rx,
reader_task,
})
}
pub async fn send(&mut self, method: &Method) -> Result<(), io::Error> {
self.writer.write(method).await
}
pub fn try_next_event(&mut self) -> Option<Event> {
self.event_rx.try_recv().ok()
}
pub async fn next_event(&mut self) -> Option<Event> {
self.event_rx.recv().await
}
}
impl Drop for WorkerClient {
fn drop(&mut self) {
self.reader_task.abort();
}
}
#[cfg(test)]
mod tests {
use std::io::ErrorKind;
use std::time::Duration;
use protocol::{Segment, WorkerStatus};
use tempfile::tempdir;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixListener;
use super::*;
async fn assert_peer_closed(stream: &mut UnixStream, reason: &str) {
let mut buf = [0_u8; 1];
match tokio::time::timeout(Duration::from_secs(1), stream.read(&mut buf))
.await
.expect(reason)
{
Ok(0) => {}
Err(error) if error.kind() == ErrorKind::ConnectionReset => {}
Ok(n) => panic!("server should observe peer close, read {n} byte(s)"),
Err(error) => panic!("server read failed unexpectedly: {error}"),
}
}
#[tokio::test]
async fn receives_events_while_client_is_alive() {
let socket_dir = tempdir().unwrap();
let socket_path = socket_dir.path().join("events.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut writer = JsonLineWriter::new(stream);
writer
.write(&Event::Status {
status: WorkerStatus::Idle,
})
.await
.unwrap();
});
let mut client = WorkerClient::connect(&socket_path).await.unwrap();
let event = tokio::time::timeout(Duration::from_secs(1), client.next_event())
.await
.expect("client should receive event while alive");
assert!(matches!(
event,
Some(Event::Status {
status: WorkerStatus::Idle
})
));
server.await.unwrap();
}
#[tokio::test]
async fn send_writes_methods_while_client_is_alive() {
let socket_dir = tempdir().unwrap();
let socket_path = socket_dir.path().join("send.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut reader = JsonLineReader::new(stream);
reader.next::<Method>().await.unwrap()
});
let mut client = WorkerClient::connect(&socket_path).await.unwrap();
let method = Method::Run {
input: vec![Segment::text("hello")],
};
client.send(&method).await.unwrap();
let received = tokio::time::timeout(Duration::from_secs(1), server)
.await
.expect("server should receive method while client is alive")
.unwrap();
match received {
Some(Method::Run { input }) => assert_eq!(input, vec![Segment::text("hello")]),
other => panic!("expected Run method, got {other:?}"),
}
}
#[tokio::test]
async fn dropping_repeated_clients_closes_server_connections() {
let socket_dir = tempdir().unwrap();
let socket_path = socket_dir.path().join("drop.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move {
for _ in 0..16 {
let (mut stream, _) = listener.accept().await.unwrap();
assert_peer_closed(
&mut stream,
"dropped client should close its socket promptly",
)
.await;
}
});
for _ in 0..16 {
let client = WorkerClient::connect(&socket_path).await.unwrap();
drop(client);
}
server.await.unwrap();
}
#[tokio::test]
async fn dropping_client_aborts_blocked_reader_task() {
let socket_dir = tempdir().unwrap();
let socket_path = socket_dir.path().join("blocked-reader.sock");
let listener = UnixListener::bind(&socket_path).unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
stream.write_all(b"{\"event\"").await.unwrap();
assert_peer_closed(
&mut stream,
"aborting the blocked client reader should close the socket",
)
.await;
});
let client = WorkerClient::connect(&socket_path).await.unwrap();
tokio::task::yield_now().await;
drop(client);
server.await.unwrap();
}
}
+77 -47
View File
@@ -14,7 +14,7 @@ use workspace_api::{
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
}; };
use crate::BackendWorkspaceClientError; use crate::{BackendApiClient, BackendWorkspaceClientError};
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000; const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
@@ -47,9 +47,9 @@ struct BackendWorkspaceOrchestratorResponse {
/// Construction requires both the selected Backend URL and Workspace identity. /// Construction requires both the selected Backend URL and Workspace identity.
/// Callers should derive these once from `Target::resolve()` and must not retry /// Callers should derive these once from `Target::resolve()` and must not retry
/// failed requests against repository-local state. /// failed requests against repository-local state.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone)]
pub struct BackendWorkspaceProductClient { pub struct BackendWorkspaceProductClient {
base_url: String, api: BackendApiClient,
workspace_id: String, workspace_id: String,
} }
@@ -58,22 +58,32 @@ impl BackendWorkspaceProductClient {
base_url: impl Into<String>, base_url: impl Into<String>,
workspace_id: impl Into<String>, workspace_id: impl Into<String>,
) -> Result<Self, BackendWorkspaceClientError> { ) -> Result<Self, BackendWorkspaceClientError> {
let base_url = base_url.into().trim_end_matches('/').to_string(); let base_url = base_url.into();
if base_url.is_empty() { let api = BackendApiClient::from_stored_token(&base_url)?;
return Err(BackendWorkspaceClientError::InvalidTarget(
"Backend base URL must not be empty".into(),
));
}
let workspace_id = workspace_id.into(); let workspace_id = workspace_id.into();
if workspace_id.trim().is_empty() { if workspace_id.trim().is_empty() {
return Err(BackendWorkspaceClientError::InvalidTarget( return Err(BackendWorkspaceClientError::InvalidTarget(
"Backend Workspace identity must not be empty".into(), "Backend Workspace identity must not be empty".into(),
)); ));
} }
Ok(Self { Ok(Self { api, workspace_id })
base_url, }
workspace_id,
}) #[cfg(test)]
fn new_with_access_token(
base_url: impl Into<String>,
workspace_id: impl Into<String>,
access_token: &str,
) -> Result<Self, BackendWorkspaceClientError> {
let base_url = base_url.into();
let api = BackendApiClient::from_access_token_for_test(&base_url, access_token)?;
let workspace_id = workspace_id.into();
if workspace_id.trim().is_empty() {
return Err(BackendWorkspaceClientError::InvalidTarget(
"Backend Workspace identity must not be empty".into(),
));
}
Ok(Self { api, workspace_id })
} }
pub fn workspace_id(&self) -> &str { pub fn workspace_id(&self) -> &str {
@@ -316,7 +326,7 @@ impl BackendWorkspaceProductClient {
body: Option<&B>, body: Option<&B>,
) -> Result<R, BackendWorkspaceClientError> { ) -> Result<R, BackendWorkspaceClientError> {
let response = self.request(method, path, body)?.send()?; let response = self.request(method, path, body)?.send()?;
let response = ensure_success(response)?; self.api.check_status(response.status())?;
response.json().map_err(BackendWorkspaceClientError::Http) response.json().map_err(BackendWorkspaceClientError::Http)
} }
@@ -326,7 +336,8 @@ impl BackendWorkspaceProductClient {
path: &str, path: &str,
body: Option<&B>, body: Option<&B>,
) -> Result<(), BackendWorkspaceClientError> { ) -> Result<(), BackendWorkspaceClientError> {
ensure_success(self.request(method, path, body)?.send()?)?; let response = self.request(method, path, body)?.send()?;
self.api.check_status(response.status())?;
Ok(()) Ok(())
} }
@@ -336,14 +347,12 @@ impl BackendWorkspaceProductClient {
path: &str, path: &str,
body: Option<&B>, body: Option<&B>,
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> { ) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
let client = reqwest::blocking::Client::builder().build()?; let path = format!(
let url = format!( "/api/w/{}/{}",
"{}/api/w/{}/{}",
self.base_url,
encode_path_segment(&self.workspace_id), encode_path_segment(&self.workspace_id),
path.trim_start_matches('/') path.trim_start_matches('/')
); );
let request = client.request(method, url); let request = self.api.blocking_request(method, &path)?;
Ok(match body { Ok(match body {
Some(body) => request.json(body), Some(body) => request.json(body),
None => request, None => request,
@@ -588,19 +597,6 @@ fn ticket_client_error(error: BackendWorkspaceClientError) -> TicketError {
TicketError::Sqlite(format!("Backend request failed: {error}")) TicketError::Sqlite(format!("Backend request failed: {error}"))
} }
fn ensure_success(
response: reqwest::blocking::Response,
) -> Result<reqwest::blocking::Response, BackendWorkspaceClientError> {
if response.status().is_success() {
return Ok(response);
}
let status = response.status().as_u16();
let message = response
.text()
.unwrap_or_else(|_| "Backend request failed".to_string());
Err(BackendWorkspaceClientError::RequestFailed { status, message })
}
fn ticket_reference(id: &TicketIdOrSlug) -> String { fn ticket_reference(id: &TicketIdOrSlug) -> String {
match id { match id {
TicketIdOrSlug::Id(id) => id.to_string(), TicketIdOrSlug::Id(id) => id.to_string(),
@@ -698,24 +694,32 @@ mod tests {
fn objective_list_uses_workspace_scoped_backend_route() { fn objective_list_uses_workspace_scoped_backend_route() {
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#; let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
let (base_url, request, handle) = one_response_server("200 OK", body); let (base_url, request, handle) = one_response_server("200 OK", body);
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let response = client.list_objectives(1_000).unwrap(); let response = client.list_objectives(1_000).unwrap();
assert!(response.items.is_empty()); assert!(response.items.is_empty());
assert!( let request = request.recv().unwrap();
request assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 "));
.recv() assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
.unwrap()
.starts_with("GET /api/w/workspace-a/objectives?limit=1000 ")
);
handle.join().unwrap(); handle.join().unwrap();
} }
#[test] #[test]
fn backend_mutation_failure_is_returned_without_local_fallback() { fn backend_mutation_failure_is_returned_without_local_fallback() {
let (base_url, request, handle) = one_response_server("403 Forbidden", "denied"); let (base_url, request, handle) =
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); one_response_server("403 Forbidden", "test-backend-token");
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let error = client let error = client
.create_objective(&ObjectiveCreateRequest { .create_objective(&ObjectiveCreateRequest {
@@ -727,6 +731,7 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert!(error.to_string().contains("403")); assert!(error.to_string().contains("403"));
assert!(!error.to_string().contains("test-backend-token"));
assert!( assert!(
request request
.recv() .recv()
@@ -739,7 +744,12 @@ mod tests {
#[test] #[test]
fn ticket_relation_query_uses_workspace_scoped_backend_route() { fn ticket_relation_query_uses_workspace_scoped_backend_route() {
let (base_url, request, handle) = one_response_server("200 OK", "[]"); let (base_url, request, handle) = one_response_server("200 OK", "[]");
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let relations = client let relations = client
.query_ticket_relations( .query_ticket_relations(
@@ -758,7 +768,12 @@ mod tests {
#[test] #[test]
fn orchestration_plan_query_uses_workspace_scoped_backend_route() { fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
let (base_url, request, handle) = one_response_server("200 OK", "[]"); let (base_url, request, handle) = one_response_server("200 OK", "[]");
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap(); let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap();
@@ -784,7 +799,12 @@ mod tests {
r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#, r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#,
), ),
]); ]);
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let status = client.launch_ticket_intake("T-1").unwrap(); let status = client.launch_ticket_intake("T-1").unwrap();
@@ -806,7 +826,12 @@ mod tests {
fn workspace_orchestrator_launch_uses_scoped_backend_route() { fn workspace_orchestrator_launch_uses_scoped_backend_route() {
let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#; let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#;
let (base_url, request, handle) = one_response_server("200 OK", body); let (base_url, request, handle) = one_response_server("200 OK", body);
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let status = client.start_workspace_orchestrator().unwrap(); let status = client.start_workspace_orchestrator().unwrap();
@@ -822,7 +847,12 @@ mod tests {
#[test] #[test]
fn product_client_requires_workspace_identity() { fn product_client_requires_workspace_identity() {
let error = BackendWorkspaceProductClient::new("http://127.0.0.1:8787", "").unwrap_err(); let error = BackendWorkspaceProductClient::new_with_access_token(
"http://127.0.0.1:8787",
"",
"test-backend-token",
)
.unwrap_err();
assert!(error.to_string().contains("Workspace identity")); assert!(error.to_string().contains("Workspace identity"));
} }
+18 -4
View File
@@ -101,9 +101,11 @@ pub fn complete_current(
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?; let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
let result = session_environment(snapshot.clone()) let result = session_environment(snapshot.clone())
.complete_config(&entrypoint, &source, utf8_byte_offset, explicit) .complete_config(&entrypoint, &source, utf8_byte_offset, explicit)
.map_err(|error| JsValue::from_str(&format!("{error:?}")))? .map_err(|error| JsValue::from_str(&format!("{error:?}")))?;
.map(|result| WasmCompletionResult { let result = result
from: result.from, .map(|result| {
Ok::<WasmCompletionResult, JsValue>(WasmCompletionResult {
from: utf8_to_utf16_offset(&source, result.from)?,
items: result items: result
.items .items
.into_iter() .into_iter()
@@ -114,7 +116,9 @@ pub fn complete_current(
priority: item.priority, priority: item.priority,
}) })
.collect(), .collect(),
}); })
})
.transpose()?;
encode(result) encode(result)
}) })
} }
@@ -177,6 +181,16 @@ fn utf16_to_utf8_offset(source: &str, utf16_offset: usize) -> Result<usize, JsVa
} }
} }
fn utf8_to_utf16_offset(source: &str, utf8_offset: usize) -> Result<usize, JsValue> {
if utf8_offset > source.len() {
return Err(JsValue::from_str("UTF-8 offset is outside the source"));
}
if !source.is_char_boundary(utf8_offset) {
return Err(JsValue::from_str("UTF-8 offset splits a character"));
}
Ok(source[..utf8_offset].encode_utf16().count())
}
fn decode<T: serde::de::DeserializeOwned>(value: JsValue) -> Result<T, JsValue> { fn decode<T: serde::de::DeserializeOwned>(value: JsValue) -> Result<T, JsValue> {
from_value(value).map_err(|error| JsValue::from_str(&error.to_string())) from_value(value).map_err(|error| JsValue::from_str(&error.to_string()))
} }
+28
View File
@@ -1203,6 +1203,9 @@ impl SnapshotEnvironment {
{ {
let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}."); let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}.");
member_source.push_str(&context.schema_path.join(".")); member_source.push_str(&context.schema_path.join("."));
if !context.schema_path.is_empty() && context.from == utf8_byte_offset {
member_source.push('.');
}
let mut completion = LanguageService::new(self).complete( let mut completion = LanguageService::new(self).complete(
entrypoint.as_str(), entrypoint.as_str(),
&member_source, &member_source,
@@ -1961,6 +1964,31 @@ mod tests {
.iter() .iter()
.any(|item| item.label == "default_profile") .any(|item| item.label == "default_profile")
); );
let blank_nested_source = "{ profile = { } } as WorkspaceConfigSchema";
let blank_nested_cursor = blank_nested_source.find("{ }").unwrap() + 2;
let blank_nested = environment
.complete_config(
&path("main.dcdl"),
blank_nested_source,
blank_nested_cursor,
true,
)
.unwrap()
.unwrap();
assert_eq!(blank_nested.from, blank_nested_cursor);
assert!(
blank_nested
.items
.iter()
.any(|item| item.label == "default_profile")
);
assert!(
!blank_nested
.items
.iter()
.any(|item| item.label == "profile")
);
} }
#[test] #[test]
+273
View File
@@ -157,10 +157,28 @@ mod tests {
} }
} }
fn grep_request(path: &str, pattern: &str) -> GrepRequest {
GrepRequest {
pattern: pattern.to_string(),
path: FsPath::new(path).unwrap(),
glob: None,
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: GrepOutputMode::Content,
limit: 10,
offset: 0,
}
}
#[test] #[test]
fn logical_paths_reject_absolute_parent_and_backslash_forms() { fn logical_paths_reject_absolute_parent_and_backslash_forms() {
assert!(FsPath::new("src/lib.rs").is_ok()); assert!(FsPath::new("src/lib.rs").is_ok());
assert!(FsPath::new("/tmp/file").is_err()); assert!(FsPath::new("/tmp/file").is_err());
assert!(FsPath::new_scoped("/tmp/file").is_ok());
assert!(FsPath::new_scoped("/tmp/../secret").is_err());
assert!(FsPath::new("../file").is_err()); assert!(FsPath::new("../file").is_err());
assert!(FsPath::new("src\\lib.rs").is_err()); assert!(FsPath::new("src\\lib.rs").is_err());
} }
@@ -280,6 +298,261 @@ mod tests {
assert!(!grep.output.contains("c.txt")); assert!(!grep.output.contains("c.txt"));
} }
#[test]
fn grep_accepts_a_direct_file_without_searching_siblings() {
let temp = tempfile::tempdir().unwrap();
let selected = temp.path().join("selected.txt");
std::fs::write(&selected, "before\nneedle selected\nafter\n").unwrap();
std::fs::write(temp.path().join("sibling.txt"), "needle sibling\n").unwrap();
let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone());
let mut request = grep_request("selected.txt", "needle");
request.before_context = 1;
request.after_context = 1;
let direct = run_grep(&root, selected, request, &readable).unwrap();
assert_eq!(direct.match_count, 1);
assert_eq!(direct.matched_files, 1);
assert_eq!(
direct.output,
concat!(
"selected.txt\n",
" 1 │ before\n",
" > 2 │ needle selected\n",
" 3 │ after\n",
)
);
assert!(!direct.output.contains("sibling"));
let directory = run_grep(
&root,
root.clone(),
GrepRequest {
pattern: "needle".to_string(),
path: FsPath::root(),
glob: None,
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: GrepOutputMode::Content,
limit: 10,
offset: 0,
},
&readable,
)
.unwrap();
assert_eq!(directory.match_count, 2);
assert_eq!(directory.matched_files, 2);
}
#[test]
fn grep_direct_file_applies_glob_and_type_filters_for_every_output_mode() {
let temp = tempfile::tempdir().unwrap();
let nested = temp.path().join("nested");
std::fs::create_dir(&nested).unwrap();
let selected = nested.join("selected.rs");
std::fs::write(&selected, "needle one\nneedle two\n").unwrap();
let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone());
for mode in [
GrepOutputMode::Content,
GrepOutputMode::FilesWithMatches,
GrepOutputMode::Count,
] {
for (glob, file_type) in [(Some("other/*.rs"), None), (None, Some("python"))] {
let mut request = grep_request("nested/selected.rs", "needle");
request.output_mode = mode;
request.glob = glob.map(str::to_string);
request.file_type = file_type.map(str::to_string);
let excluded = run_grep(&root, selected.clone(), request, &readable).unwrap();
assert_eq!(excluded.output, "", "mode {mode:?}");
assert_eq!(excluded.match_count, 0, "mode {mode:?}");
assert_eq!(excluded.matched_files, 0, "mode {mode:?}");
assert!(!excluded.truncated, "mode {mode:?}");
}
let mut request = grep_request("nested/selected.rs", "needle");
request.output_mode = mode;
request.glob = Some("nested/*.rs".to_string());
request.file_type = Some("rust".to_string());
let matched = run_grep(&root, selected.clone(), request, &readable).unwrap();
match mode {
GrepOutputMode::Content => {
assert_eq!(matched.match_count, 2);
assert_eq!(matched.matched_files, 1);
assert!(matched.output.starts_with("nested/selected.rs\n"));
assert!(matched.output.contains("> 1 │ needle one"));
assert!(matched.output.contains("> 2 │ needle two"));
}
GrepOutputMode::FilesWithMatches => {
assert_eq!(matched.match_count, 1);
assert_eq!(matched.matched_files, 1);
assert_eq!(matched.output, "nested/selected.rs\n");
}
GrepOutputMode::Count => {
assert_eq!(matched.match_count, 2);
assert_eq!(matched.matched_files, 1);
assert_eq!(matched.output, "nested/selected.rs:2\n");
}
}
assert!(!matched.truncated, "mode {mode:?}");
}
}
#[test]
fn grep_direct_file_preserves_explicit_hidden_and_gitignored_behavior() {
let temp = tempfile::tempdir().unwrap();
let hidden = temp.path().join(".hidden.rs");
let ignored = temp.path().join("ignored.rs");
std::fs::write(&hidden, "needle hidden\n").unwrap();
std::fs::write(&ignored, "needle ignored\n").unwrap();
std::fs::write(temp.path().join(".gitignore"), "ignored.rs\n").unwrap();
let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone());
for (path, expected) in [
(".hidden.rs", "needle hidden"),
("ignored.rs", "needle ignored"),
] {
let result = run_grep(
&root,
root.join(path),
grep_request(path, "needle"),
&readable,
)
.unwrap();
assert_eq!(result.match_count, 1, "path {path}");
assert!(result.output.contains(expected), "path {path}");
}
}
#[test]
fn grep_direct_file_preserves_case_multiline_and_bounds() {
let temp = tempfile::tempdir().unwrap();
let selected = temp.path().join("selected.txt");
std::fs::write(&selected, "NEEDLE first\nstart\nfinish\nneedle last\n").unwrap();
let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone());
let mut case_request = grep_request("selected.txt", "needle");
case_request.case_insensitive = true;
case_request.offset = 1;
case_request.limit = 1;
let bounded = run_grep(&root, selected.clone(), case_request, &readable).unwrap();
assert_eq!(bounded.match_count, 1);
assert!(!bounded.output.contains("NEEDLE first"));
assert!(bounded.output.contains("needle last"));
assert!(bounded.truncated);
let mut multiline_request = grep_request("selected.txt", "start\\nfinish");
multiline_request.multiline = true;
let multiline = run_grep(&root, selected, multiline_request, &readable).unwrap();
assert_eq!(multiline.match_count, 1);
assert!(multiline.output.contains("start\nfinish"));
}
#[test]
fn grep_returns_not_found_for_a_missing_direct_path() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
let missing = root.join("missing.txt");
let readable = RootAccess(root.clone());
let error = run_grep(
&root,
missing.clone(),
grep_request("missing.txt", "needle"),
&readable,
)
.unwrap_err();
assert!(matches!(error, FsError::NotFound(path) if path == missing));
}
#[cfg(unix)]
#[test]
fn grep_keeps_direct_symlink_directory_and_broken_path_guards() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone());
std::fs::create_dir(root.join("target-dir")).unwrap();
std::fs::write(root.join("target-file.rs"), "needle file\n").unwrap();
symlink(root.join("target-file.rs"), root.join("file-link.rs")).unwrap();
symlink(root.join("target-dir"), root.join("directory-link")).unwrap();
symlink(root.join("missing-target"), root.join("broken-link")).unwrap();
let request = |path: &str| grep_request(path, "needle");
let file_result = run_grep(
&root,
root.join("file-link.rs"),
request("file-link.rs"),
&readable,
)
.unwrap();
assert_eq!(file_result.match_count, 1);
assert!(file_result.output.starts_with("file-link.rs\n"));
let directory_error = run_grep(
&root,
root.join("directory-link"),
request("directory-link"),
&readable,
)
.unwrap_err();
assert!(matches!(
directory_error,
FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path, .. }
if path == root.join("directory-link")
));
let broken_error = run_grep(
&root,
root.join("broken-link"),
request("broken-link"),
&readable,
)
.unwrap_err();
assert!(matches!(
broken_error,
FsError::BrokenSymlink { path, .. } if path == root.join("broken-link")
));
}
#[cfg(unix)]
#[test]
fn grep_rejects_a_direct_special_file_as_invalid_argument() {
use std::os::unix::net::UnixListener;
let temp = tempfile::tempdir().unwrap();
let socket = temp.path().join("grep.sock");
let _listener = UnixListener::bind(&socket).unwrap();
let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone());
let error = run_grep(
&root,
socket,
grep_request("grep.sock", "needle"),
&readable,
)
.unwrap_err();
assert!(matches!(
error,
FsError::InvalidArgument(message)
if message.contains("must be a regular file or directory")
));
}
#[test] #[test]
fn grep_content_groups_lines_by_file_and_marks_matches() { fn grep_content_groups_lines_by_file_and_marks_matches() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
+22 -2
View File
@@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize};
use crate::FsError; use crate::FsError;
/// Logical path relative to the bound Workdir root. /// Scope-checked filesystem path. Relative paths resolve below the bound
/// Workdir root; absolute paths require an explicit matching scope rule.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)] #[serde(transparent)]
pub struct FsPath(String); pub struct FsPath(String);
@@ -16,11 +17,30 @@ impl<'de> Deserialize<'de> for FsPath {
D: serde::Deserializer<'de>, D: serde::Deserializer<'de>,
{ {
let value = String::deserialize(deserializer)?; let value = String::deserialize(deserializer)?;
Self::new(&value).map_err(serde::de::Error::custom) Self::new_scoped(&value).map_err(serde::de::Error::custom)
} }
} }
impl FsPath { impl FsPath {
/// Construct a path for a scope-checked operation that may target an
/// explicitly granted absolute path outside the provider root.
pub fn new_scoped(value: impl Into<String>) -> Result<Self, FsError> {
let value = value.into();
if !Path::new(&value).is_absolute() {
return Self::new(value);
}
if value.contains('\\') {
return Err(FsError::InvalidPath(value));
}
if Path::new(&value)
.components()
.any(|component| component == Component::ParentDir)
{
return Err(FsError::InvalidPath(value));
}
Ok(Self(value))
}
pub fn root() -> Self { pub fn root() -> Self {
Self(String::new()) Self(String::new())
} }
+153 -83
View File
@@ -7,8 +7,8 @@ use grep_regex::RegexMatcherBuilder;
use grep_searcher::sinks::UTF8 as UTF8Sink; use grep_searcher::sinks::UTF8 as UTF8Sink;
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch}; use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch};
use ignore::WalkBuilder; use ignore::WalkBuilder;
use ignore::overrides::OverrideBuilder; use ignore::overrides::{Override, OverrideBuilder};
use ignore::types::TypesBuilder; use ignore::types::{Types, TypesBuilder};
use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink}; use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink};
@@ -126,6 +126,38 @@ fn logical_display(root: &Path, path: &Path) -> String {
const DEFAULT_HEAD_LIMIT: usize = 250; const DEFAULT_HEAD_LIMIT: usize = 250;
fn build_overrides(base: &Path, glob: Option<&str>) -> Result<Option<Override>, FsError> {
let Some(glob) = glob else {
return Ok(None);
};
let mut builder = OverrideBuilder::new(base);
builder
.add(glob)
.map_err(|error| FsError::InvalidGlob(error.to_string()))?;
builder
.build()
.map(Some)
.map_err(|error| FsError::InvalidGlob(error.to_string()))
}
fn build_types(file_type: Option<&str>) -> Result<Option<Types>, FsError> {
let Some(file_type) = file_type else {
return Ok(None);
};
let mut builder = TypesBuilder::new();
builder.add_defaults();
builder.select(file_type);
builder
.build()
.map(Some)
.map_err(|error| FsError::InvalidArgument(format!("invalid type {file_type}: {error}")))
}
fn direct_file_selected(path: &Path, overrides: Option<&Override>, types: Option<&Types>) -> bool {
!overrides.is_some_and(|filter| filter.matched(path, false).is_ignore())
&& !types.is_some_and(|filter| filter.matched(path, false).is_ignore())
}
struct GrepParams { struct GrepParams {
pattern: String, pattern: String,
path: Option<PathBuf>, path: Option<PathBuf>,
@@ -221,13 +253,15 @@ pub fn run_grep(
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()), std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
_ => FsError::io(&base, e), _ => FsError::io(&base, e),
})?; })?;
if !base_meta.is_dir() { if !base_meta.is_file() && !base_meta.is_dir() {
return Err(FsError::InvalidArgument(format!( return Err(FsError::InvalidArgument(format!(
"grep search path is not a directory: {}", "grep search path must be a regular file or directory: {}",
base.display() base.display()
))); )));
} }
if let Some(info) = symlink.as_ref() { if base_meta.is_dir()
&& let Some(info) = symlink.as_ref()
{
return Err(FsError::SymlinkDirectoryNotTraversed { return Err(FsError::SymlinkDirectoryNotTraversed {
tool: "Grep", tool: "Grep",
path: base.clone(), path: base.clone(),
@@ -235,32 +269,9 @@ pub fn run_grep(
}); });
} }
let mut wb = WalkBuilder::new(&base); let filter_base = if base_meta.is_file() { root } else { &base };
wb.hidden(true) let types = build_types(p.file_type.as_deref())?;
.git_ignore(true) let overrides = build_overrides(filter_base, p.glob.as_deref())?;
.git_global(true)
.git_exclude(true)
.ignore(true)
.parents(true)
.follow_links(false);
if let Some(t) = p.file_type.as_deref() {
let mut tb = TypesBuilder::new();
tb.add_defaults();
tb.select(t);
let types = tb
.build()
.map_err(|e| FsError::InvalidArgument(format!("invalid type {t}: {e}")))?;
wb.types(types);
}
if let Some(g) = p.glob.as_deref() {
let mut ob = OverrideBuilder::new(&base);
ob.add(g).map_err(|e| FsError::InvalidGlob(e.to_string()))?;
let ov = ob
.build()
.map_err(|e| FsError::InvalidGlob(e.to_string()))?;
wb.overrides(ov);
}
let mode = p.output_mode.unwrap_or_default(); let mode = p.output_mode.unwrap_or_default();
let head_limit = p.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT); let head_limit = p.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
@@ -275,74 +286,133 @@ pub fn run_grep(
lines: Vec::new(), lines: Vec::new(),
truncated: false, truncated: false,
}; };
let mut matching_files_seen = 0;
let mut matches_seen = 0;
// Per-mode walker state. if base_meta.is_file() {
let mut matching_files_seen: usize = 0; if direct_file_selected(&base, overrides.as_ref(), types.as_ref()) {
let mut matches_seen: usize = 0; scan_path(
&mut searcher,
&matcher,
&base,
mode,
&mut report,
&mut matching_files_seen,
&mut matches_seen,
offset,
head_limit,
)?;
}
return Ok(report.into_result(root));
}
'walker: for entry in wb.build().flatten() { let mut walker = WalkBuilder::new(&base);
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) { walker
.hidden(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.ignore(true)
.parents(true)
.follow_links(false);
if let Some(types) = types {
walker.types(types);
}
if let Some(overrides) = overrides {
walker.overrides(overrides);
}
for entry in walker.build().flatten() {
if !entry
.file_type()
.map(|kind| kind.is_file())
.unwrap_or(false)
{
continue; continue;
} }
let path = entry.path(); let path = entry.path();
if !access.is_readable(path) { if !access.is_readable(path) {
continue; continue;
} }
if scan_path(
match mode { &mut searcher,
GrepOutputMode::FilesWithMatches => { &matcher,
let hit = scan_any_match(&mut searcher, &matcher, path)?; path,
if !hit { mode,
continue; &mut report,
} &mut matching_files_seen,
if matching_files_seen >= offset { &mut matches_seen,
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, offset,
head_limit, head_limit,
}; )? {
searcher break;
.search_path(&matcher, path, &mut sink)
.map_err(|e| FsError::io(path, e))?;
// If we hit head_limit during this file, stop walking.
if matches_seen >= offset.saturating_add(head_limit) && matches_seen > before_count
{
report.truncated = true;
break 'walker;
}
}
} }
} }
Ok(report.into_result(root)) Ok(report.into_result(root))
} }
#[allow(clippy::too_many_arguments)]
fn scan_path(
searcher: &mut Searcher,
matcher: &grep_regex::RegexMatcher,
path: &Path,
mode: GrepOutputMode,
report: &mut GrepReport,
matching_files_seen: &mut usize,
matches_seen: &mut usize,
offset: usize,
head_limit: usize,
) -> Result<bool, FsError> {
match mode {
GrepOutputMode::FilesWithMatches => {
if !scan_any_match(searcher, matcher, path)? {
return Ok(false);
}
if *matching_files_seen >= offset {
report.files.push(path.to_path_buf());
if report.files.len() >= head_limit {
report.truncated = true;
return Ok(true);
}
}
*matching_files_seen += 1;
}
GrepOutputMode::Count => {
let count = scan_count(searcher, matcher, path)?;
if count == 0 {
return Ok(false);
}
if *matching_files_seen >= offset {
report.counts.push((path.to_path_buf(), count));
if report.counts.len() >= head_limit {
report.truncated = true;
return Ok(true);
}
}
*matching_files_seen += 1;
}
GrepOutputMode::Content => {
let before_count = *matches_seen;
let mut sink = ContentSink {
path: path.to_path_buf(),
lines: &mut report.lines,
matches_seen,
offset,
head_limit,
};
searcher
.search_path(matcher, path, &mut sink)
.map_err(|error| FsError::io(path, error))?;
if *matches_seen >= offset.saturating_add(head_limit) && *matches_seen > before_count {
report.truncated = true;
return Ok(true);
}
}
}
Ok(false)
}
fn scan_any_match( fn scan_any_match(
searcher: &mut Searcher, searcher: &mut Searcher,
matcher: &grep_regex::RegexMatcher, matcher: &grep_regex::RegexMatcher,
+1
View File
@@ -7,6 +7,7 @@ license.workspace = true
[dependencies] [dependencies]
arc-swap = "1" arc-swap = "1"
agen = { workspace = true } agen = { workspace = true }
decodal.workspace = true
protocol = { workspace = true } protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
+318
View File
@@ -0,0 +1,318 @@
use std::collections::BTreeMap;
use decodal::{Data, Engine, ImportLoader, LoadedImport};
use serde_json::{Map, Number, Value};
use sha2::{Digest, Sha256};
use crate::profile::ProfileError;
pub const BUILTIN_PROFILE_CATALOG_ID: &str = "builtin-profiles-v2";
pub const BUILTIN_DEFAULT_PROFILE: &str = "builtin:default";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BuiltinProfileImport {
pub specifier: &'static str,
pub resolved_path: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BuiltinProfileResource {
pub selector: Option<&'static str>,
pub path: &'static str,
pub source: &'static str,
pub description: &'static str,
pub imports: &'static [BuiltinProfileImport],
}
const BASE_PATH: &str = "profiles/base.dcdl";
const BASE_IMPORT: &[BuiltinProfileImport] = &[BuiltinProfileImport {
specifier: "./base.dcdl",
resolved_path: BASE_PATH,
}];
const NO_IMPORTS: &[BuiltinProfileImport] = &[];
pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
BuiltinProfileResource {
selector: None,
path: BASE_PATH,
source: include_str!("../../../resources/profiles/base.dcdl"),
description: "Shared built-in Profile defaults.",
imports: NO_IMPORTS,
},
BuiltinProfileResource {
selector: Some(BUILTIN_DEFAULT_PROFILE),
path: "profiles/default.dcdl",
source: include_str!("../../../resources/profiles/default.dcdl"),
description: "Standalone Yoi coding profile.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:coder"),
path: "profiles/coder.dcdl",
source: include_str!("../../../resources/profiles/coder.dcdl"),
description: "Ticket implementation with direct Reviewer SubWorkers.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:companion"),
path: "profiles/companion.dcdl",
source: include_str!("../../../resources/profiles/companion.dcdl"),
description: "General assistance with Workspace tools.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:intake"),
path: "profiles/intake.dcdl",
source: include_str!("../../../resources/profiles/intake.dcdl"),
description: "Read-only intake and planning.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:reviewer"),
path: "profiles/reviewer.dcdl",
source: include_str!("../../../resources/profiles/reviewer.dcdl"),
description: "Independent review of a published Merge Request source.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:orchestrator"),
path: "profiles/orchestrator.dcdl",
source: include_str!("../../../resources/profiles/orchestrator.dcdl"),
description: "Workspace orchestration and Worker control.",
imports: BASE_IMPORT,
},
BuiltinProfileResource {
selector: Some("builtin:memory-consolidation"),
path: "profiles/memory-consolidation.dcdl",
source: include_str!("../../../resources/profiles/memory-consolidation.dcdl"),
description: "Internal Memory consolidation service.",
imports: BASE_IMPORT,
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BuiltinProfileCatalogSnapshot {
pub id: &'static str,
pub sources: BTreeMap<String, String>,
pub entrypoints: BTreeMap<String, String>,
pub imports: BTreeMap<String, String>,
}
impl BuiltinProfileCatalogSnapshot {
pub fn digest(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(self.id.as_bytes());
for (path, source) in &self.sources {
hasher.update((path.len() as u64).to_le_bytes());
hasher.update(path.as_bytes());
hasher.update((source.len() as u64).to_le_bytes());
hasher.update(source.as_bytes());
}
for (selector, path) in &self.entrypoints {
hasher.update((selector.len() as u64).to_le_bytes());
hasher.update(selector.as_bytes());
hasher.update((path.len() as u64).to_le_bytes());
hasher.update(path.as_bytes());
}
for (request, resolved_path) in &self.imports {
hasher.update((request.len() as u64).to_le_bytes());
hasher.update(request.as_bytes());
hasher.update((resolved_path.len() as u64).to_le_bytes());
hasher.update(resolved_path.as_bytes());
}
format!("sha256:{:x}", hasher.finalize())
}
}
pub fn builtin_profile_catalog_snapshot() -> BuiltinProfileCatalogSnapshot {
let mut sources = BTreeMap::new();
let mut entrypoints = BTreeMap::new();
let mut imports = BTreeMap::new();
for resource in BUILTIN_PROFILE_RESOURCES {
sources.insert(resource.path.to_owned(), resource.source.to_owned());
for import in resource.imports {
imports.insert(
format!("{}\0{}", resource.path, import.specifier),
import.resolved_path.to_owned(),
);
}
if let Some(selector) = resource.selector {
entrypoints.insert(selector.to_owned(), resource.path.to_owned());
}
}
BuiltinProfileCatalogSnapshot {
id: BUILTIN_PROFILE_CATALOG_ID,
sources,
entrypoints,
imports,
}
}
pub fn builtin_profile_entrypoints() -> impl Iterator<Item = &'static BuiltinProfileResource> {
BUILTIN_PROFILE_RESOURCES
.iter()
.filter(|resource| resource.selector.is_some())
}
pub(crate) fn resolve_builtin_profile_artifact(
selector: &str,
) -> Result<Option<Value>, ProfileError> {
let catalog = builtin_profile_catalog_snapshot();
let Some(entrypoint) = catalog.entrypoints.get(selector) else {
return Ok(None);
};
let source = catalog
.sources
.get(entrypoint)
.expect("built-in Profile entrypoint must name a source")
.clone();
let mut engine = Engine::new(BuiltinProfileImportLoader {
sources: catalog.sources,
});
let module = engine
.add_root_source(entrypoint, entrypoint, &source)
.map_err(|error| ProfileError::BuiltinProfileEvaluation {
selector: selector.to_owned(),
message: format!("{error:?}"),
})?;
let value =
engine
.eval_module(module)
.map_err(|error| ProfileError::BuiltinProfileEvaluation {
selector: selector.to_owned(),
message: format!("{error:?}"),
})?;
let data =
engine
.materialize(&value)
.map_err(|error| ProfileError::BuiltinProfileEvaluation {
selector: selector.to_owned(),
message: format!("{error:?}"),
})?;
Ok(Some(data_to_json(&data)))
}
#[derive(Debug)]
struct BuiltinProfileImportLoader {
sources: BTreeMap<String, String>,
}
impl ImportLoader for BuiltinProfileImportLoader {
fn load(
&mut self,
current_key: Option<&str>,
specifier: &str,
) -> decodal::Result<LoadedImport> {
let current_key = current_key.ok_or_else(|| {
decodal::Diagnostic::new(
decodal::DiagnosticKind::Import,
decodal::Span::default(),
format!("built-in Profile import `{specifier}` has no source context"),
)
})?;
let resolved = resolve_import_path(current_key, specifier).ok_or_else(|| {
decodal::Diagnostic::new(
decodal::DiagnosticKind::Import,
decodal::Span::default(),
format!("built-in Profile import `{specifier}` from `{current_key}` is invalid"),
)
})?;
let source = self.sources.get(&resolved).ok_or_else(|| {
decodal::Diagnostic::new(
decodal::DiagnosticKind::Import,
decodal::Span::default(),
format!("built-in Profile import `{specifier}` from `{current_key}` was not found"),
)
})?;
Ok(LoadedImport::source(
resolved.clone(),
resolved,
source.clone(),
))
}
}
fn resolve_import_path(current_key: &str, specifier: &str) -> Option<String> {
let current_parent = current_key
.rsplit_once('/')
.map_or("", |(parent, _)| parent);
let joined = if let Some(relative) = specifier.strip_prefix("./") {
format!("{current_parent}/{relative}")
} else {
return None;
};
if joined
.split('/')
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
{
return None;
}
Some(joined)
}
fn data_to_json(data: &Data) -> Value {
match data {
Data::Bool(value) => Value::Bool(*value),
Data::Int(value) => Value::Number(Number::from(*value)),
Data::Float(value) => Number::from_f64(*value)
.map(Value::Number)
.unwrap_or(Value::Null),
Data::String(value) => Value::String(value.clone()),
Data::Array(values) => Value::Array(values.iter().map(data_to_json).collect()),
Data::Object(fields) => Value::Object(
fields
.iter()
.map(|field| (field.name.clone(), data_to_json(&field.value)))
.collect::<Map<_, _>>(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_has_one_explicit_entrypoint_for_each_builtin_profile() {
let catalog = builtin_profile_catalog_snapshot();
assert_eq!(catalog.sources.len(), BUILTIN_PROFILE_RESOURCES.len());
assert_eq!(catalog.entrypoints.len() + 1, catalog.sources.len());
assert_eq!(
catalog.entrypoints.get(BUILTIN_DEFAULT_PROFILE),
Some(&"profiles/default.dcdl".to_owned())
);
assert!(catalog.digest().starts_with("sha256:"));
}
#[test]
fn default_profile_evaluates_from_the_shared_resource_graph() {
let value = resolve_builtin_profile_artifact(BUILTIN_DEFAULT_PROFILE)
.expect("evaluate built-in default")
.expect("default exists");
assert_eq!(value["slug"], "default");
assert_eq!(value["feature"]["task"]["enabled"], true);
assert_eq!(value["feature"]["sub_worker"]["enabled"], true);
assert_eq!(value["feature"]["memory"]["enabled"], false);
assert_eq!(value["feature"]["ticket"]["enabled"], false);
assert_eq!(value["feature"]["worker"]["enabled"], false);
assert_eq!(value["feature"]["manage_workdir"]["enabled"], false);
}
#[test]
fn imports_cannot_escape_the_builtin_resource_catalog() {
assert_eq!(
resolve_import_path("profiles/default.dcdl", "./base.dcdl").as_deref(),
Some("profiles/base.dcdl")
);
assert_eq!(
resolve_import_path("profiles/default.dcdl", "../outside.dcdl"),
None
);
assert_eq!(
resolve_import_path("profiles/default.dcdl", "/outside.dcdl"),
None
);
}
}
+22 -9
View File
@@ -92,6 +92,8 @@ pub struct FeatureConfigPartial {
#[serde(default)] #[serde(default)]
pub worker: Option<WorkerFeatureConfigPartial>, pub worker: Option<WorkerFeatureConfigPartial>,
#[serde(default)] #[serde(default)]
pub workspace_worker_discovery: Option<FeatureFlagConfigPartial>,
#[serde(default)]
pub objective: Option<FeatureFlagConfigPartial>, pub objective: Option<FeatureFlagConfigPartial>,
#[serde(default)] #[serde(default)]
pub manage_workdir: Option<FeatureFlagConfigPartial>, pub manage_workdir: Option<FeatureFlagConfigPartial>,
@@ -119,6 +121,11 @@ impl FeatureConfigPartial {
), ),
flow: merge_option(self.flow, other.flow, FeatureFlagConfigPartial::merge), flow: merge_option(self.flow, other.flow, FeatureFlagConfigPartial::merge),
worker: merge_option(self.worker, other.worker, WorkerFeatureConfigPartial::merge), worker: merge_option(self.worker, other.worker, WorkerFeatureConfigPartial::merge),
workspace_worker_discovery: merge_option(
self.workspace_worker_discovery,
other.workspace_worker_discovery,
FeatureFlagConfigPartial::merge,
),
objective: merge_option( objective: merge_option(
self.objective, self.objective,
other.objective, other.objective,
@@ -265,6 +272,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
.worker .worker
.map(WorkerFeatureConfig::from) .map(WorkerFeatureConfig::from)
.unwrap_or_default(), .unwrap_or_default(),
workspace_worker_discovery: value
.workspace_worker_discovery
.map(FeatureFlagConfig::from)
.unwrap_or_default(),
objective: value objective: value
.objective .objective
.map(FeatureFlagConfig::from) .map(FeatureFlagConfig::from)
@@ -394,6 +405,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
sub_worker: Some(value.sub_worker.into()), sub_worker: Some(value.sub_worker.into()),
flow: Some(value.flow.into()), flow: Some(value.flow.into()),
worker: Some(value.worker.into()), worker: Some(value.worker.into()),
workspace_worker_discovery: Some(value.workspace_worker_discovery.into()),
objective: Some(value.objective.into()), objective: Some(value.objective.into()),
manage_workdir: Some(value.manage_workdir.into()), manage_workdir: Some(value.manage_workdir.into()),
ticket: Some(value.ticket.into()), ticket: Some(value.ticket.into()),
@@ -566,15 +578,16 @@ impl WorkerManifestConfig {
}) })
} }
/// Base config populated with the in-code defaults listed in /// Base config populated with the in-code per-field defaults listed in
/// [`crate::defaults`]. Profile and one-file Manifest resolvers start /// [`crate::defaults`]. This is not a selectable Profile and does not
/// from this layer so every per-field default lives at exactly one /// enable a launch capability surface. Profile and one-file Manifest
/// call site (the `defaults` module). /// resolvers start from this layer so every per-field default lives at
/// exactly one call site (the `defaults` module).
/// ///
/// `TryFrom<WorkerManifestConfig>` also reads the same constants as a /// `TryFrom<WorkerManifestConfig>` also reads the same constants as a
/// belt-and-suspenders fallback, so a manually-constructed config /// belt-and-suspenders fallback, so a manually-constructed config
/// that skips this layer still resolves to the same values. /// that skips this layer still resolves to the same values.
pub fn builtin_defaults() -> Self { pub fn resolution_defaults() -> Self {
Self { Self {
engine: EngineManifestConfig { engine: EngineManifestConfig {
tool_output: ToolOutputLimitsPartial { tool_output: ToolOutputLimitsPartial {
@@ -1973,7 +1986,7 @@ enabled = false
"#, "#,
) )
.unwrap(); .unwrap();
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults() let manifest: WorkerManifest = WorkerManifestConfig::resolution_defaults()
.merge(cfg) .merge(cfg)
.merge(WorkerManifestConfig { .merge(WorkerManifestConfig {
worker: WorkerMetaConfig { worker: WorkerMetaConfig {
@@ -2074,7 +2087,7 @@ enabled = true
"#, "#,
) )
.unwrap(); .unwrap();
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults() let manifest: WorkerManifest = WorkerManifestConfig::resolution_defaults()
.merge(base) .merge(base)
.merge(upper) .merge(upper)
.merge(WorkerManifestConfig { .merge(WorkerManifestConfig {
@@ -2137,7 +2150,7 @@ permission = "write"
#[test] #[test]
fn builtin_defaults_populates_worker_limit_defaults() { fn builtin_defaults_populates_worker_limit_defaults() {
let cfg = WorkerManifestConfig::builtin_defaults(); let cfg = WorkerManifestConfig::resolution_defaults();
assert_eq!( assert_eq!(
cfg.engine.tool_output.default_max_bytes, cfg.engine.tool_output.default_max_bytes,
Some(defaults::TOOL_OUTPUT_MAX_BYTES) Some(defaults::TOOL_OUTPUT_MAX_BYTES)
@@ -2172,7 +2185,7 @@ permission = "write"
}, },
..Default::default() ..Default::default()
}; };
let merged = WorkerManifestConfig::builtin_defaults().merge(overlay); let merged = WorkerManifestConfig::resolution_defaults().merge(overlay);
let manifest: WorkerManifest = merged.try_into().unwrap(); let manifest: WorkerManifest = merged.try_into().unwrap();
assert_eq!( assert_eq!(
manifest.engine.tool_output.default_max_bytes, manifest.engine.tool_output.default_max_bytes,
+16 -4
View File
@@ -1,3 +1,4 @@
mod builtin_profile;
mod config; mod config;
pub mod defaults; pub mod defaults;
mod model; mod model;
@@ -7,6 +8,11 @@ pub mod plugin;
mod profile; mod profile;
mod scope; mod scope;
pub use builtin_profile::{
BUILTIN_DEFAULT_PROFILE, BUILTIN_PROFILE_CATALOG_ID, BUILTIN_PROFILE_RESOURCES,
BuiltinProfileCatalogSnapshot, BuiltinProfileImport, BuiltinProfileResource,
builtin_profile_catalog_snapshot, builtin_profile_entrypoints,
};
pub use config::{ pub use config::{
CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial, CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial,
PermissionConfigPartial, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial, PermissionConfigPartial, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial,
@@ -17,10 +23,11 @@ pub use model::{
}; };
pub use paths::user_profiles_path; pub use paths::user_profiles_path;
pub use profile::{ pub use profile::{
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry, ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
ProfileSelector, ProfileSource, ResolvedProfile, resolve_profile_artifact, ProfileResolveOptions, ProfileResolver, ProfileSelector, ProfileSource, ResolvedProfile,
resolve_profile_artifact_value, WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value,
validate_profile_execution_target,
}; };
pub use protocol::{Permission, ScopeRule}; pub use protocol::{Permission, ScopeRule};
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope}; pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
@@ -118,6 +125,10 @@ pub struct FeatureConfig {
pub flow: FeatureFlagConfig, pub flow: FeatureFlagConfig,
#[serde(default)] #[serde(default)]
pub worker: WorkerFeatureConfig, pub worker: WorkerFeatureConfig,
/// Privileged read-only discovery of visible Workspace Workers. Backend
/// source proof remains required for every listing operation.
#[serde(default)]
pub workspace_worker_discovery: FeatureFlagConfig,
#[serde(default)] #[serde(default)]
pub objective: FeatureFlagConfig, pub objective: FeatureFlagConfig,
#[serde(default)] #[serde(default)]
@@ -142,6 +153,7 @@ impl Default for FeatureConfig {
sub_worker: FeatureFlagConfig::disabled(), sub_worker: FeatureFlagConfig::disabled(),
flow: FeatureFlagConfig::disabled(), flow: FeatureFlagConfig::disabled(),
worker: WorkerFeatureConfig::disabled(), worker: WorkerFeatureConfig::disabled(),
workspace_worker_discovery: FeatureFlagConfig::disabled(),
objective: FeatureFlagConfig::disabled(), objective: FeatureFlagConfig::disabled(),
manage_workdir: FeatureFlagConfig::disabled(), manage_workdir: FeatureFlagConfig::disabled(),
ticket: TicketFeatureConfig::default(), ticket: TicketFeatureConfig::default(),
+258 -258
View File
@@ -6,9 +6,14 @@
//! from launch context. //! from launch context.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::builtin_profile::{
BUILTIN_DEFAULT_PROFILE, builtin_profile_catalog_snapshot, builtin_profile_entrypoints,
resolve_builtin_profile_artifact,
};
use crate::config::{ use crate::config::{
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial, CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
}; };
@@ -23,45 +28,6 @@ use crate::{
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1"; const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml"); const BUILTIN_MODEL_CATALOG: &str = include_str!("../../../resources/models/builtin.toml");
struct BuiltinProfile {
name: &'static str,
label: &'static str,
description: &'static str,
}
const BUILTIN_PROFILES: &[BuiltinProfile] = &[
BuiltinProfile {
name: "companion",
label: "builtin:companion",
description: "Bundled Companion role profile",
},
BuiltinProfile {
name: "intake",
label: "builtin:intake",
description: "Bundled Intake role profile",
},
BuiltinProfile {
name: "orchestrator",
label: "builtin:orchestrator",
description: "Bundled Orchestrator role profile",
},
BuiltinProfile {
name: "coder",
label: "builtin:coder",
description: "Bundled Coder role profile",
},
BuiltinProfile {
name: "reviewer",
label: "builtin:reviewer",
description: "Bundled Reviewer role profile",
},
BuiltinProfile {
name: "memory-consolidation",
label: "builtin:memory-consolidation",
description: "Bundled Memory staging consolidation profile",
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ProfileRegistrySource { pub enum ProfileRegistrySource {
@@ -159,6 +125,108 @@ impl ProfileSelector {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProfileExecutionTarget {
Workspace,
Standalone,
}
impl fmt::Display for ProfileExecutionTarget {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Workspace => formatter.write_str("workspace"),
Self::Standalone => formatter.write_str("standalone"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum WorkspaceAuthorityRequirement {
Flow,
ManageWorkdir,
Memory,
MergeRequest,
Objective,
Orchestration,
Plugins,
Ticket,
Worker,
}
impl fmt::Display for WorkspaceAuthorityRequirement {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Flow => formatter.write_str("feature.flow"),
Self::ManageWorkdir => formatter.write_str("feature.manage_workdir"),
Self::Memory => formatter.write_str("feature.memory"),
Self::MergeRequest => formatter.write_str("feature.merge_request"),
Self::Objective => formatter.write_str("feature.objective"),
Self::Orchestration => formatter.write_str("feature.orchestration"),
Self::Plugins => formatter.write_str("feature.plugins or plugin packages"),
Self::Ticket => formatter.write_str("feature.ticket"),
Self::Worker => formatter.write_str("feature.worker"),
}
}
}
pub fn validate_profile_execution_target(
manifest: &WorkerManifest,
target: ProfileExecutionTarget,
) -> Result<(), ProfileError> {
if target == ProfileExecutionTarget::Workspace {
return Ok(());
}
let feature = &manifest.feature;
let mut requirements = BTreeSet::new();
if feature.flow.enabled {
requirements.insert(WorkspaceAuthorityRequirement::Flow);
}
if feature.manage_workdir.enabled {
requirements.insert(WorkspaceAuthorityRequirement::ManageWorkdir);
}
if feature.memory.enabled || feature.memory.staging {
requirements.insert(WorkspaceAuthorityRequirement::Memory);
}
if feature.merge_request.show
|| feature.merge_request.open
|| feature.merge_request.review
|| feature.merge_request.readiness_check
|| feature.merge_request.complete
{
requirements.insert(WorkspaceAuthorityRequirement::MergeRequest);
}
if feature.objective.enabled {
requirements.insert(WorkspaceAuthorityRequirement::Objective);
}
if feature.orchestration.enabled {
requirements.insert(WorkspaceAuthorityRequirement::Orchestration);
}
if feature.plugins.enabled || !manifest.plugins.is_empty() {
requirements.insert(WorkspaceAuthorityRequirement::Plugins);
}
if feature.ticket.enabled
|| feature.ticket.authoring
|| feature.ticket.thread
|| feature.ticket.intake
|| feature.ticket.workflow
{
requirements.insert(WorkspaceAuthorityRequirement::Ticket);
}
if feature.worker.enabled {
requirements.insert(WorkspaceAuthorityRequirement::Worker);
}
if requirements.is_empty() {
Ok(())
} else {
Err(ProfileError::UnsupportedExecutionTarget {
target,
requirements: requirements.into_iter().collect(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum ProfileSource { pub enum ProfileSource {
@@ -217,13 +285,14 @@ impl ProfileRegistryEntry {
source: ProfileRegistrySource, source: ProfileRegistrySource,
name: &'static str, name: &'static str,
label: &'static str, label: &'static str,
provenance: String,
description: Option<String>, description: Option<String>,
) -> Self { ) -> Self {
Self { Self {
source, source,
name: name.to_string(), name: name.to_string(),
path: None, path: None,
provenance: label.to_string(), provenance,
description, description,
is_default: false, is_default: false,
artifact: ProfileRegistryArtifact::Builtin { label }, artifact: ProfileRegistryArtifact::Builtin { label },
@@ -321,12 +390,16 @@ pub struct ProfileDiscovery {
} }
impl ProfileDiscovery { impl ProfileDiscovery {
pub fn for_cwd(_cwd: &Path) -> Self { pub fn user_settings() -> Self {
Self { Self {
user_config: paths::user_profiles_path(), user_config: paths::user_profiles_path(),
project_config: None, project_config: None,
} }
} }
pub fn for_cwd(_cwd: &Path) -> Self {
Self::user_settings()
}
pub fn with_sources(user_config: Option<PathBuf>, project_config: Option<PathBuf>) -> Self { pub fn with_sources(user_config: Option<PathBuf>, project_config: Option<PathBuf>) -> Self {
Self { Self {
user_config, user_config,
@@ -412,15 +485,22 @@ impl ProfileResolver {
options, options,
), ),
ProfileSelector::Named { .. } | ProfileSelector::Default => { ProfileSelector::Named { .. } | ProfileSelector::Default => {
let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo { let registry = ProfileDiscovery::user_settings().discover()?;
path: PathBuf::from("."),
source,
})?;
let registry = ProfileDiscovery::for_cwd(&cwd).discover()?;
self.resolve_from_registry(selector, &registry, options) self.resolve_from_registry(selector, &registry, options)
} }
} }
} }
pub fn resolve_for_target(
&self,
selector: &ProfileSelector,
options: ProfileResolveOptions,
target: ProfileExecutionTarget,
) -> Result<ResolvedProfile, ProfileError> {
let resolved = self.resolve(selector, options)?;
validate_profile_execution_target(&resolved.manifest, target)?;
Ok(resolved)
}
/// Resolve a registry/default selector against an already-discovered /// Resolve a registry/default selector against an already-discovered
/// registry. Callers such as SubWorkerSpawn use this to bind discovery to the /// registry. Callers such as SubWorkerSpawn use this to bind discovery to the
/// Worker's cwd instead of the process current directory. /// Worker's cwd instead of the process current directory.
@@ -503,7 +583,7 @@ impl ProfileResolver {
.as_deref() .as_deref()
.unwrap_or_else(|| Path::new(".")), .unwrap_or_else(|| Path::new(".")),
)?; )?;
let raw_artifact = builtin_profile_artifact(label).ok_or_else(|| { let raw_artifact = resolve_builtin_profile_artifact(label)?.ok_or_else(|| {
ProfileError::InvalidProfile(format!("unknown builtin profile artifact `{label}`")) ProfileError::InvalidProfile(format!("unknown builtin profile artifact `{label}`"))
})?; })?;
resolve_profile_value( resolve_profile_value(
@@ -565,7 +645,8 @@ fn resolve_profile_value(
memory: profile.memory.map(Into::into), memory: profile.memory.map(Into::into),
skills: profile.skills, skills: profile.skills,
}; };
let config = WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir)); let config =
WorkerManifestConfig::resolution_defaults().merge(config.resolve_paths(profile_dir));
let mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?; let mut manifest = WorkerManifest::try_from(config).map_err(ProfileError::ManifestResolve)?;
manifest.profile = Some(ProfileManifestSnapshot { manifest.profile = Some(ProfileManifestSnapshot {
source: source.clone(), source: source.clone(),
@@ -759,14 +840,30 @@ fn load_profile_registry_file(
} }
fn add_builtin_profiles(registry: &mut ProfileRegistry) { fn add_builtin_profiles(registry: &mut ProfileRegistry) {
for profile in BUILTIN_PROFILES { let catalog = builtin_profile_catalog_snapshot();
let digest = catalog.digest();
for profile in builtin_profile_entrypoints() {
let label = profile
.selector
.expect("built-in Profile entrypoint must have a selector");
let name = label
.strip_prefix("builtin:")
.expect("built-in Profile selector must be source-qualified");
registry.push_entry(ProfileRegistryEntry::embedded( registry.push_entry(ProfileRegistryEntry::embedded(
ProfileRegistrySource::Builtin, ProfileRegistrySource::Builtin,
profile.name, name,
profile.label, label,
format!("{}#{digest}", profile.path),
Some(profile.description.into()), Some(profile.description.into()),
)); ));
} }
registry.set_default(ProfileDefault {
source: Some(ProfileRegistrySource::Builtin),
name: BUILTIN_DEFAULT_PROFILE
.strip_prefix("builtin:")
.expect("built-in default selector must be source-qualified")
.to_owned(),
});
} }
fn parse_profile_ref(raw: &str) -> (Option<ProfileRegistrySource>, String) { fn parse_profile_ref(raw: &str) -> (Option<ProfileRegistrySource>, String) {
@@ -804,201 +901,6 @@ fn read_profile_artifact_file(path: &Path) -> Result<serde_json::Value, ProfileE
} }
} }
fn builtin_profile_artifact(label: &str) -> Option<serde_json::Value> {
let mut value = builtin_base_profile_artifact();
match label {
"builtin:companion" | "companion" => {
apply_role_profile(
&mut value,
"companion",
"Workspace companion profile.",
"workspace_write",
true,
true,
true,
true,
);
Some(value)
}
"builtin:intake" | "intake" => {
apply_role_profile(
&mut value,
"intake",
"Ticket intake profile.",
"workspace_write",
true,
true,
true,
false,
);
Some(value)
}
"builtin:orchestrator" | "orchestrator" => {
apply_role_profile(
&mut value,
"orchestrator",
"Ticket orchestrator profile.",
"workspace_write",
true,
true,
true,
false,
);
Some(value)
}
"builtin:coder" | "coder" => {
apply_role_profile(
&mut value,
"coder",
"Ticket implementation coder profile.",
"workspace_write",
true,
true,
true,
true,
);
Some(value)
}
"builtin:reviewer" | "reviewer" => {
apply_role_profile(
&mut value,
"reviewer",
"Ticket review profile.",
"workspace_read",
true,
true,
true,
false,
);
Some(value)
}
"builtin:memory-consolidation" | "memory-consolidation" => {
value["slug"] = serde_json::Value::String("memory-consolidation".to_string());
value["description"] =
serde_json::Value::String("Memory staging consolidation profile.".to_string());
value["feature"]["task"] = serde_json::json!({ "enabled": false });
value["feature"]["memory"] = serde_json::json!({ "enabled": true, "staging": true });
value["feature"]["web"] = serde_json::json!({ "enabled": false });
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": false });
value["feature"]["objective"] = serde_json::json!({ "enabled": false });
value["feature"]["ticket"] = serde_json::json!({ "enabled": false, "thread": false });
Some(value)
}
_ => None,
}
}
fn builtin_base_profile_artifact() -> serde_json::Value {
serde_json::json!({
"slug": "default",
"description": "Default Yoi coding profile.",
"model": { "ref": "codex-oauth/gpt-5.5" },
"session": { "record_event_trace": true },
"engine": { "reasoning": "high" },
"compaction": {
"kind": "tokens",
"threshold": 240000,
"request_threshold": 270000,
"worker_context_max_tokens": 100000
},
"feature": {
"task": { "enabled": true },
"memory": { "enabled": true },
"web": { "enabled": true },
"image": { "enabled": true },
"sub_worker": { "enabled": true },
"worker": { "enabled": false },
"objective": { "enabled": true },
"ticket": { "enabled": true, "authoring": true, "thread": true }
},
"memory": {
"extract_threshold": 50000,
"consolidation_threshold_files": 5,
"consolidation_threshold_bytes": 50000
},
"web": {
"enabled": true,
"search": {
"provider": "brave",
"api_key_secret": "web/brave/default"
}
}
})
}
#[allow(clippy::too_many_arguments)]
fn apply_role_profile(
value: &mut serde_json::Value,
slug: &str,
description: &str,
_scope: &str,
task: bool,
memory: bool,
web: bool,
sub_worker: bool,
) {
value["slug"] = serde_json::Value::String(slug.to_string());
value["description"] = serde_json::Value::String(description.to_string());
value["feature"]["task"] = serde_json::json!({ "enabled": task });
value["feature"]["memory"] = serde_json::json!({ "enabled": memory });
value["feature"]["web"] = serde_json::json!({ "enabled": web });
value["feature"]["image"] = serde_json::json!({ "enabled": true });
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
value["feature"]["worker"] = serde_json::json!({
"enabled": slug == "orchestrator",
"direct_spawn": slug != "orchestrator"
});
value["feature"]["manage_workdir"] = serde_json::json!({
"enabled": matches!(slug, "companion" | "orchestrator")
});
value["feature"]["orchestration"] = serde_json::json!({ "enabled": slug == "orchestrator" });
let ticket = match slug {
"companion" => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
"intake" => {
serde_json::json!({ "enabled": true, "authoring": true, "thread": true, "intake": true })
}
"orchestrator" => {
serde_json::json!({ "enabled": true, "thread": true, "workflow": true })
}
"coder" => serde_json::json!({ "enabled": true, "thread": true }),
"reviewer" => serde_json::json!({ "enabled": true, "thread": true }),
_ => serde_json::json!({ "enabled": true, "authoring": true, "thread": true }),
};
value["feature"]["ticket"] = ticket;
let merge_request = match slug {
"coder" => serde_json::json!({
"show": true,
"open": true,
"review": false,
"readiness_check": false,
"complete": false
}),
"reviewer" => serde_json::json!({
"show": true,
"open": false,
"review": true,
"readiness_check": false,
"complete": false
}),
"orchestrator" => serde_json::json!({
"show": true,
"open": false,
"review": false,
"readiness_check": true,
"complete": true
}),
_ => serde_json::json!({
"show": false,
"open": false,
"review": false,
"readiness_check": false,
"complete": false
}),
};
value["feature"]["merge_request"] = merge_request;
}
fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> { fn reject_manifest_shaped_profile(value: &serde_json::Value) -> Result<(), ProfileError> {
let Some(map) = value.as_object() else { let Some(map) = value.as_object() else {
return Err(ProfileError::InvalidProfile( return Err(ProfileError::InvalidProfile(
@@ -1288,6 +1190,13 @@ pub enum ProfileError {
#[source] #[source]
source: toml::de::Error, source: toml::de::Error,
}, },
#[error("failed to evaluate built-in Profile `{selector}`: {message}")]
BuiltinProfileEvaluation { selector: String, message: String },
#[error("Profile requires unsupported {target} launch authorities: {requirements:?}")]
UnsupportedExecutionTarget {
target: ProfileExecutionTarget,
requirements: Vec<WorkspaceAuthorityRequirement>,
},
#[error("no default profile is configured")] #[error("no default profile is configured")]
NoDefaultProfile, NoDefaultProfile,
#[error("profile resolution requires an explicit runtime Worker name")] #[error("profile resolution requires an explicit runtime Worker name")]
@@ -1341,18 +1250,21 @@ mod tests {
); );
} }
#[test] #[test]
fn builtin_profiles_do_not_define_an_implicit_default() { fn builtin_default_is_explicit_registry_authority() {
let registry = ProfileDiscovery::with_sources(None, None) let registry = ProfileDiscovery::with_sources(None, None)
.discover() .discover()
.unwrap(); .unwrap();
assert!(matches!( let default = registry.default_entry().unwrap();
registry.default_entry(), assert_eq!(default.source, ProfileRegistrySource::Builtin);
Err(ProfileError::NoDefaultProfile) assert_eq!(default.name, "default");
)); assert_eq!(default.qualified_name(), BUILTIN_DEFAULT_PROFILE);
assert!(matches!( assert!(default.is_default);
registry.select(&ProfileSelector::Default), assert!(
Err(ProfileError::NoDefaultProfile) default
)); .provenance
.starts_with("profiles/default.dcdl#sha256:")
);
assert_eq!(registry.select(&ProfileSelector::Default).unwrap(), default);
} }
#[test] #[test]
fn builtin_role_profiles_are_registered_and_resolve() { fn builtin_role_profiles_are_registered_and_resolve() {
@@ -1407,6 +1319,92 @@ mod tests {
} }
} }
#[test]
fn builtin_default_resolves_as_a_standalone_local_capability_profile() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve_for_target(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"),
ProfileResolveOptions::with_worker_name("standalone-worker"),
ProfileExecutionTarget::Standalone,
)
.unwrap();
assert!(matches!(
&resolved.source,
ProfileSource::Registry {
source: ProfileRegistrySource::Builtin,
name,
path: None,
provenance: Some(provenance),
..
} if name == "default" && provenance.starts_with("profiles/default.dcdl#sha256:")
));
assert!(resolved.manifest.feature.task.enabled);
assert!(resolved.manifest.feature.web.enabled);
assert!(resolved.manifest.feature.image.enabled);
assert!(resolved.manifest.feature.sub_worker.enabled);
assert!(resolved.manifest.scope.allow.iter().any(|rule| {
rule.permission == protocol::Permission::Write && rule.target == tmp.path()
}));
assert!(resolved.manifest.delegation_scope.allow.iter().any(|rule| {
rule.permission == protocol::Permission::Write && rule.target == tmp.path()
}));
assert!(!resolved.manifest.feature.memory.enabled);
assert!(!resolved.manifest.feature.ticket.enabled);
assert!(!resolved.manifest.feature.objective.enabled);
assert!(!resolved.manifest.feature.flow.enabled);
assert!(!resolved.manifest.feature.worker.enabled);
assert!(!resolved.manifest.feature.manage_workdir.enabled);
assert!(!resolved.manifest.feature.plugins.enabled);
assert!(resolved.manifest.plugins.is_empty());
}
#[test]
fn standalone_rejects_profiles_that_require_workspace_authority() {
let tmp = TempDir::new().unwrap();
let error = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve_for_target(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"),
ProfileResolveOptions::with_worker_name("standalone-worker"),
ProfileExecutionTarget::Standalone,
)
.unwrap_err();
let diagnostic = error.to_string();
let ProfileError::UnsupportedExecutionTarget {
target,
requirements,
} = error
else {
panic!("unexpected error: {error}");
};
assert_eq!(target, ProfileExecutionTarget::Standalone);
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Memory));
assert!(requirements.contains(&WorkspaceAuthorityRequirement::MergeRequest));
assert!(requirements.contains(&WorkspaceAuthorityRequirement::Ticket));
assert!(!diagnostic.contains(tmp.path().to_string_lossy().as_ref()));
}
#[test]
fn repository_markers_do_not_change_builtin_profile_authority() {
let tmp = TempDir::new().unwrap();
let nested = tmp.path().join("repository/nested");
std::fs::create_dir_all(&nested).unwrap();
std::fs::create_dir_all(tmp.path().join("repository/.yoi")).unwrap();
std::fs::write(
tmp.path().join("repository/.yoi/profiles.toml"),
"default = { source = 'project', name = 'shadow' }\n",
)
.unwrap();
let discovery = ProfileDiscovery::for_cwd(&nested);
assert_eq!(discovery.user_config, paths::user_profiles_path());
assert!(discovery.project_config.is_none());
}
#[test] #[test]
fn builtin_coder_uses_sub_worker_control_without_worker_control() { fn builtin_coder_uses_sub_worker_control_without_worker_control() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
@@ -1423,7 +1421,7 @@ mod tests {
} }
#[test] #[test]
fn builtin_companion_uses_sub_worker_control_without_worker_control() { fn builtin_companion_combines_runtime_and_sub_worker_control_with_discovery() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new() let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path()) .with_workspace_base(tmp.path())
@@ -1435,7 +1433,9 @@ mod tests {
assert!(resolved.manifest.feature.manage_workdir.enabled); assert!(resolved.manifest.feature.manage_workdir.enabled);
assert!(resolved.manifest.feature.sub_worker.enabled); assert!(resolved.manifest.feature.sub_worker.enabled);
assert!(!resolved.manifest.feature.worker.enabled); assert!(resolved.manifest.feature.worker.enabled);
assert!(!resolved.manifest.feature.worker.direct_spawn);
assert!(resolved.manifest.feature.workspace_worker_discovery.enabled);
} }
#[test] #[test]
+2 -1
View File
@@ -14,6 +14,7 @@ json-schema = ["dep:schemars"]
schemars = { workspace = true, optional = true } schemars = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2.workspace = true
tokio = { workspace = true, features = ["io-util"], optional = true } tokio = { workspace = true, features = ["io-util"], optional = true }
ts-rs = { version = "12.0.1", optional = true } ts-rs = { version = "12.0.1", optional = true }
uuid = { workspace = true, features = ["serde"] } uuid = { workspace = true, features = ["serde", "v7"] }
+132
View File
@@ -0,0 +1,132 @@
use std::{fmt, str::FromStr};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use sha2::{Digest, Sha256};
use uuid::{Uuid, Version};
/// Stable Worker identity independent of its current Runtime placement or
/// conversation Session.
///
/// Workspace authority allocates this ID for managed Workers. A standalone
/// Worker store allocates it locally when no Workspace authority is present.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct WorkerId(Uuid);
impl WorkerId {
pub fn now_v7() -> Self {
Self(Uuid::now_v7())
}
/// Converts a legacy Runtime-local numeric id into a syntactically valid
/// migration-only UUIDv7 value. New Worker allocation must use `now_v7`.
pub fn from_legacy_u64(value: u64) -> Self {
let mut bytes = [0_u8; 16];
bytes[8..].copy_from_slice(&value.to_be_bytes());
bytes[6] = 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Self(Uuid::from_bytes(bytes))
}
pub fn from_legacy_binding(workspace_id: &str, runtime_id: &str, value: u64) -> Self {
let mut hasher = Sha256::new();
hasher.update(b"yoi.workspace-worker-id.v1\0");
hasher.update(workspace_id.as_bytes());
hasher.update([0]);
hasher.update(runtime_id.as_bytes());
hasher.update([0]);
hasher.update(value.to_be_bytes());
let digest = hasher.finalize();
let mut bytes = [0_u8; 16];
bytes.copy_from_slice(&digest[..16]);
// Migrated ids sort before normally allocated UUIDv7 values while retaining
// deterministic collision-resistant payload bits.
bytes[..6].fill(0);
bytes[6] = (bytes[6] & 0x0f) | 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Self(Uuid::from_bytes(bytes))
}
pub fn parse(value: &str) -> Option<Self> {
let value = Uuid::parse_str(value).ok()?;
(value.get_version() == Some(Version::SortRand)).then_some(Self(value))
}
pub const fn as_uuid(self) -> Uuid {
self.0
}
#[must_use]
pub fn short(self) -> String {
let simple = self.0.simple().to_string();
simple[simple.len() - 12..].to_string()
}
}
impl fmt::Display for WorkerId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
impl FromStr for WorkerId {
type Err = WorkerIdParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::parse(value).ok_or(WorkerIdParseError)
}
}
impl Serialize for WorkerId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for WorkerId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(&value).ok_or_else(|| de::Error::custom("Worker id must be a UUIDv7"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WorkerIdParseError;
impl fmt::Display for WorkerIdParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("Worker id must be a UUIDv7")
}
}
impl std::error::Error for WorkerIdParseError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn worker_id_accepts_only_uuid_v7() {
let worker_id = WorkerId::now_v7();
assert_eq!(WorkerId::parse(&worker_id.to_string()), Some(worker_id));
assert!(WorkerId::parse("30").is_none());
assert!(WorkerId::parse(&Uuid::nil().to_string()).is_none());
}
#[test]
fn legacy_worker_id_mapping_is_stable() {
assert_eq!(
WorkerId::from_legacy_binding("workspace", "runtime", 42),
WorkerId::from_legacy_binding("workspace", "runtime", 42)
);
assert_ne!(
WorkerId::from_legacy_binding("workspace", "runtime", 42),
WorkerId::from_legacy_binding("workspace", "runtime", 43)
);
}
}
+247 -28
View File
@@ -1,3 +1,4 @@
pub mod identity;
#[cfg(feature = "stream")] #[cfg(feature = "stream")]
pub mod stream; pub mod stream;
pub mod subscription; pub mod subscription;
@@ -8,6 +9,8 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub use identity::{WorkerId, WorkerIdParseError};
fn default_true() -> bool { fn default_true() -> bool {
true true
} }
@@ -190,6 +193,64 @@ impl WorkerEvent {
/// variants — emits an alert and inserts a `[unknown input segment]` /// variants — emits an alert and inserts a `[unknown input segment]`
/// placeholder into the LLM context so neither user nor LLM is blind to /// placeholder into the LLM context so neither user nor LLM is blind to
/// the dropped intent. /// the dropped intent.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum PasteArtifactMediaType {
TextPlainUtf8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum PasteArtifactAvailability {
Available,
Unavailable,
IntegrityFailed,
}
impl PasteArtifactMediaType {
pub fn as_str(self) -> &'static str {
match self {
Self::TextPlainUtf8 => "text/plain; charset=utf-8",
}
}
}
impl PasteArtifactAvailability {
pub fn as_str(self) -> &'static str {
match self {
Self::Available => "available",
Self::Unavailable => "unavailable",
Self::IntegrityFailed => "integrity_failed",
}
}
}
/// Session-owned reference to a large pasted-input artifact.
///
/// The reference contains only bounded integrity and provenance metadata. The
/// artifact body remains in session storage and is available to the model only
/// through the scoped paste-artifact tools installed by Worker.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct PasteArtifactRef {
pub artifact_id: String,
pub created_at_ms: u64,
pub media_type: PasteArtifactMediaType,
/// Availability observed when this immutable reference was committed.
/// Reads revalidate storage and integrity rather than trusting this field.
pub availability: PasteArtifactAvailability,
pub byte_len: u64,
pub char_count: u64,
pub line_count: u64,
pub sha256: String,
pub source_entry_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
@@ -207,6 +268,10 @@ pub enum Segment {
lines: u32, lines: u32,
content: String, content: String,
}, },
/// Internal reference produced when Worker stores a large `Paste` before
/// committing input. Clients may receive this in history/event projections;
/// the body is intentionally absent.
PasteArtifact { artifact: PasteArtifactRef },
/// `@<path>` file-system reference. Worker resolves readable files to /// `@<path>` file-system reference. Worker resolves readable files to
/// `[File: <path>]` attachments and readable normal directories to shallow /// `[File: <path>]` attachments and readable normal directories to shallow
/// `[Dir: <path>]` listings; the flattened user text keeps the literal /// `[Dir: <path>]` listings; the flattened user text keeps the literal
@@ -247,6 +312,21 @@ impl Segment {
match seg { match seg {
Segment::Text { content } => out.push_str(content), Segment::Text { content } => out.push_str(content),
Segment::Paste { content, .. } => out.push_str(content), Segment::Paste { content, .. } => out.push_str(content),
Segment::PasteArtifact { artifact } => {
use std::fmt::Write as _;
let _ = write!(
out,
"[Large paste stored as artifact {}: {} bytes, {} chars, {} lines, {}, {}, created at {} ms, sha256 {}; use SearchInputArtifact and ReadInputArtifact to inspect it]",
artifact.artifact_id,
artifact.byte_len,
artifact.char_count,
artifact.line_count,
artifact.media_type.as_str(),
artifact.availability.as_str(),
artifact.created_at_ms,
artifact.sha256
);
}
Segment::FileRef { path } => { Segment::FileRef { path } => {
out.push('@'); out.push('@');
out.push_str(path); out.push_str(path);
@@ -340,8 +420,7 @@ pub struct InternalWorkerRef {
pub struct InternalWorkerSnapshot { pub struct InternalWorkerSnapshot {
pub worker: InternalWorkerRef, pub worker: InternalWorkerRef,
pub revision: u64, pub revision: u64,
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))] pub session: SessionSnapshot,
pub entries: Vec<serde_json::Value>,
#[serde(default)] #[serde(default)]
pub status: WorkerStatus, pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -364,12 +443,114 @@ pub enum ToolResultDisposition {
OutcomeUnknown, OutcomeUnknown,
} }
/// Canonical, storage-independent projection of committed session history.
///
/// Worker protocols expose this DTO instead of append-log records. New
/// storage variants can therefore be added without teaching every client how
/// to replay the durable log format.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct SessionSnapshot {
pub entries: Vec<SessionSnapshotEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum SessionEntryProvenance {
HumanInput,
WorkerInput,
FlowInstruction,
BackendInstruction,
ModelOutput,
ToolOutput,
DerivedSummary,
LegacyUnknown,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct SessionSnapshotEntry {
/// Stable identity from durable history metadata, or a deterministic
/// identity derived from the legacy segment and log position.
pub entry_id: String,
/// Timestamp copied from the durable log record that commits this entry.
pub timestamp: u64,
pub provenance: SessionEntryProvenance,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub derived_from: Vec<String>,
#[serde(flatten)]
pub data: SessionSnapshotEntryData,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SessionSnapshotEntryData {
UserInput {
segments: Vec<Segment>,
},
Message {
role: SessionMessageRole,
content: Vec<SessionContentPart>,
},
ToolCall {
call_id: String,
name: String,
arguments: String,
},
ToolResult {
call_id: String,
summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
content: Option<String>,
is_error: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<SessionToolAttachment>,
},
SystemItem {
item_kind: String,
content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
data: Option<serde_json::Value>,
},
RunError {
message: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum SessionMessageRole {
User,
Assistant,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SessionContentPart {
Text { text: String },
Refusal { refusal: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct SessionToolAttachment {
pub media_type: String,
/// Base64-encoded durable attachment body. Public snapshots preserve the
/// committed multimodal value instead of replacing it with placeholder text.
pub data_base64: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "event", content = "data", rename_all = "snake_case")] #[serde(tag = "event", content = "data", rename_all = "snake_case")]
pub enum Event { pub enum Event {
/// A user input message was accepted, persisted as /// A user input message was accepted, persisted as
/// `LogEntry::UserInput`, and is about to start a new turn. /// `LogEntry::AnnotatedUserInput`, and is about to start a new turn.
/// Broadcast to every subscribed client so TUI / GUI instances show /// Broadcast to every subscribed client so TUI / GUI instances show
/// the same user line that reconnect snapshots would replay from /// the same user line that reconnect snapshots would replay from
/// history; clients must not synthesize a separate pending/fake /// history; clients must not synthesize a separate pending/fake
@@ -390,7 +571,7 @@ pub enum Event {
/// of parsing free-text prefixes like `[Notification] …` or /// of parsing free-text prefixes like `[Notification] …` or
/// `[File: …]`. /// `[File: …]`.
/// ///
/// One event per `LogEntry::SystemItem` commit. Disk-side and /// One event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and
/// wire-side are 1:1. /// wire-side are 1:1.
SystemItem { SystemItem {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))] #[cfg_attr(feature = "typescript", ts(type = "unknown"))]
@@ -555,8 +736,7 @@ pub enum Event {
/// role-specific entry events (`SegmentRotated` / `SystemItem`) — /// role-specific entry events (`SegmentRotated` / `SystemItem`) —
/// there is no generic "every committed entry" broadcast. /// there is no generic "every committed entry" broadcast.
Snapshot { Snapshot {
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))] session: SessionSnapshot,
entries: Vec<serde_json::Value>,
greeting: Greeting, greeting: Greeting,
#[serde(default)] #[serde(default)]
status: WorkerStatus, status: WorkerStatus,
@@ -589,14 +769,10 @@ pub enum Event {
/// Server-side segment log rotated to a fresh `SegmentStart`. /// Server-side segment log rotated to a fresh `SegmentStart`.
/// ///
/// Fires on compaction and on auto-fork when the store head drifts /// Fires on compaction and on auto-fork when the store head drifts
/// from the live writer's cached head. Clients drop their derived /// A compaction/fork has replaced the authoritative segment. Clients drop
/// view and reseed from `entry.history` exactly the way they would /// their derived view and reseed from the canonical committed snapshot.
/// from a connect-time `Snapshot`.
///
/// Payload is the JSON form of `session_store::LogEntry::SegmentStart`.
SegmentRotated { SegmentRotated {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))] session: SessionSnapshot,
entry: serde_json::Value,
}, },
/// Current Worker controller status. Broadcast on every controller-level /// Current Worker controller status. Broadcast on every controller-level
/// transition and included in `History` snapshots for late attach. /// transition and included in `History` snapshots for late attach.
@@ -623,11 +799,10 @@ pub enum Event {
head_entries: usize, head_entries: usize,
targets: Vec<RewindTarget>, targets: Vec<RewindTarget>,
}, },
/// A rewind has truncated the authoritative session. `entries` is the /// A rewind has truncated the authoritative session. `session` is the
/// retained session-log prefix clients should use to reseed display state. /// retained canonical snapshot clients should use to reseed display state.
RewindApplied { RewindApplied {
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))] session: SessionSnapshot,
entries: Vec<serde_json::Value>,
input: Vec<Segment>, input: Vec<Segment>,
summary: RewindSummary, summary: RewindSummary,
}, },
@@ -1104,6 +1279,32 @@ mod tests {
} }
} }
#[test]
fn paste_artifact_segment_roundtrips_without_body() {
let artifact = PasteArtifactRef {
artifact_id: "019ca7c8-57b6-7f05-8edf-524147aba7b2".to_string(),
created_at_ms: 1_700_000_000_000,
media_type: PasteArtifactMediaType::TextPlainUtf8,
availability: PasteArtifactAvailability::Available,
byte_len: 65_536,
char_count: 65_530,
line_count: 200,
sha256: "a".repeat(64),
source_entry_id: "entry-1".to_string(),
};
let segment = Segment::PasteArtifact {
artifact: artifact.clone(),
};
let json = serde_json::to_string(&segment).unwrap();
assert!(!json.contains("pasted body"));
assert_eq!(serde_json::from_str::<Segment>(&json).unwrap(), segment);
let projected = Segment::flatten_to_text(&[segment]);
assert!(projected.contains(&artifact.artifact_id));
assert!(projected.contains("SearchInputArtifact"));
assert!(projected.contains("ReadInputArtifact"));
assert!(!projected.contains("pasted body"));
}
#[test] #[test]
fn method_run_flow_segment_roundtrip() { fn method_run_flow_segment_roundtrip() {
let method = Method::Run { let method = Method::Run {
@@ -1440,7 +1641,17 @@ mod tests {
#[test] #[test]
fn event_snapshot_format() { fn event_snapshot_format() {
let event = Event::Snapshot { let event = Event::Snapshot {
entries: vec![serde_json::json!({"kind": "user_input", "ts": 1, "segments": []})], session: SessionSnapshot {
entries: vec![SessionSnapshotEntry {
entry_id: "entry-1".into(),
timestamp: 1,
provenance: SessionEntryProvenance::HumanInput,
derived_from: Vec::new(),
data: SessionSnapshotEntryData::UserInput {
segments: Vec::new(),
},
}],
},
greeting: Greeting { greeting: Greeting {
worker_name: "test".into(), worker_name: "test".into(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
@@ -1458,8 +1669,12 @@ mod tests {
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "snapshot"); assert_eq!(parsed["event"], "snapshot");
assert!(parsed["data"]["entries"].is_array()); assert!(parsed["data"]["session"]["entries"].is_array());
assert_eq!(parsed["data"]["entries"][0]["kind"], "user_input"); assert_eq!(
parsed["data"]["session"]["entries"][0]["kind"],
"user_input"
);
assert_eq!(parsed["data"]["session"]["entries"][0]["timestamp"], 1);
assert_eq!(parsed["data"]["greeting"]["worker_name"], "test"); assert_eq!(parsed["data"]["greeting"]["worker_name"], "test");
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read"); assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000); assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
@@ -1469,7 +1684,7 @@ mod tests {
#[test] #[test]
fn event_snapshot_in_flight_roundtrip_and_default() { fn event_snapshot_in_flight_roundtrip_and_default() {
let inbound = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"status":"running"}}"#; let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"status":"running"}}"#;
let decoded: Event = serde_json::from_str(inbound).unwrap(); let decoded: Event = serde_json::from_str(inbound).unwrap();
match decoded { match decoded {
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()), Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
@@ -1477,7 +1692,9 @@ mod tests {
} }
let event = Event::Snapshot { let event = Event::Snapshot {
session: SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
greeting: Greeting { greeting: Greeting {
worker_name: "test".into(), worker_name: "test".into(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
@@ -1543,15 +1760,17 @@ mod tests {
#[test] #[test]
fn event_segment_rotated_roundtrip() { fn event_segment_rotated_roundtrip() {
let event = Event::SegmentRotated { let event = Event::SegmentRotated {
entry: serde_json::json!({"kind": "segment_start", "ts": 1, "history": []}), session: SessionSnapshot {
entries: Vec::new(),
},
}; };
let json = serde_json::to_string(&event).unwrap(); let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "segment_rotated"); assert_eq!(parsed["event"], "segment_rotated");
assert_eq!(parsed["data"]["entry"]["kind"], "segment_start"); assert!(parsed["data"]["session"]["entries"].is_array());
let decoded: Event = serde_json::from_str(&json).unwrap(); let decoded: Event = serde_json::from_str(&json).unwrap();
match decoded { match decoded {
Event::SegmentRotated { entry } => assert_eq!(entry["kind"], "segment_start"), Event::SegmentRotated { session } => assert!(session.entries.is_empty()),
other => panic!("expected SegmentRotated, got {other:?}"), other => panic!("expected SegmentRotated, got {other:?}"),
} }
} }
@@ -1627,8 +1846,8 @@ mod tests {
} }
#[test] #[test]
fn event_snapshot_legacy_without_status_defaults_to_idle() { fn event_snapshot_without_status_defaults_to_idle() {
let json = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#; let json = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
let decoded: Event = serde_json::from_str(json).unwrap(); let decoded: Event = serde_json::from_str(json).unwrap();
match decoded { match decoded {
Event::Snapshot { Event::Snapshot {
@@ -2039,11 +2258,11 @@ mod tests {
} }
#[test] #[test]
fn legacy_snapshot_defaults_internal_workers_to_empty() { fn snapshot_defaults_internal_workers_to_empty() {
let snapshot: Event = serde_json::from_value(serde_json::json!({ let snapshot: Event = serde_json::from_value(serde_json::json!({
"event": "snapshot", "event": "snapshot",
"data": { "data": {
"entries": [], "session": { "entries": [] },
"greeting": { "greeting": {
"worker_name": "parent", "worker_name": "parent",
"cwd": ".", "cwd": ".",
+15 -2
View File
@@ -7,8 +7,11 @@ use crate::{
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry, CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry,
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId, InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
RunResult, ScopeRule, Segment, ToolResultDisposition, TurnResult, WorkerEvent, WorkerStatus, PasteArtifactRef, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
ToolResultDisposition, TurnResult, WorkerEvent, WorkerStatus,
subscription::{ subscription::{
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame, EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest, SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
@@ -63,12 +66,22 @@ pub fn generated_protocol_types() -> String {
push_decl::<RewindSummary>(&cfg, &mut output); push_decl::<RewindSummary>(&cfg, &mut output);
push_decl::<InFlightBlock>(&cfg, &mut output); push_decl::<InFlightBlock>(&cfg, &mut output);
push_decl::<InFlightSnapshot>(&cfg, &mut output); push_decl::<InFlightSnapshot>(&cfg, &mut output);
push_decl::<SessionEntryProvenance>(&cfg, &mut output);
push_decl::<SessionMessageRole>(&cfg, &mut output);
push_decl::<SessionContentPart>(&cfg, &mut output);
push_decl::<SessionToolAttachment>(&cfg, &mut output);
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
push_decl::<SessionSnapshot>(&cfg, &mut output);
push_decl::<InternalWorkerKind>(&cfg, &mut output); push_decl::<InternalWorkerKind>(&cfg, &mut output);
push_decl::<InternalWorkerRef>(&cfg, &mut output); push_decl::<InternalWorkerRef>(&cfg, &mut output);
push_decl::<InternalWorkerSnapshot>(&cfg, &mut output); push_decl::<InternalWorkerSnapshot>(&cfg, &mut output);
push_decl::<Greeting>(&cfg, &mut output); push_decl::<Greeting>(&cfg, &mut output);
push_decl::<Alert>(&cfg, &mut output); push_decl::<Alert>(&cfg, &mut output);
push_decl::<MemoryWorkerEvent>(&cfg, &mut output); push_decl::<MemoryWorkerEvent>(&cfg, &mut output);
push_decl::<PasteArtifactMediaType>(&cfg, &mut output);
push_decl::<PasteArtifactAvailability>(&cfg, &mut output);
push_decl::<PasteArtifactRef>(&cfg, &mut output);
push_decl::<Segment>(&cfg, &mut output); push_decl::<Segment>(&cfg, &mut output);
push_decl::<WorkerEvent>(&cfg, &mut output); push_decl::<WorkerEvent>(&cfg, &mut output);
push_decl::<SubscriptionRequestId>(&cfg, &mut output); push_decl::<SubscriptionRequestId>(&cfg, &mut output);
+2
View File
@@ -8,8 +8,10 @@ license.workspace = true
[dependencies] [dependencies]
base64.workspace = true base64.workspace = true
agen = { workspace = true } agen = { workspace = true }
fs4.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2.workspace = true
uuid = { workspace = true, features = ["v7", "serde"] } uuid = { workspace = true, features = ["v7", "serde"] }
thiserror = { workspace = true } thiserror = { workspace = true }
protocol = { workspace = true } protocol = { workspace = true }
+182 -1
View File
@@ -16,9 +16,11 @@
//! enumerable by the picker. //! enumerable by the picker.
use crate::event_trace::TraceEntry; use crate::event_trace::TraceEntry;
use crate::paste_artifact::{read_from_dir, write_to_dir};
use crate::segment_log::LogEntry; use crate::segment_log::LogEntry;
use crate::store::{Store, StoreError}; use crate::store::{Store, StoreError};
use crate::{SegmentId, SessionId}; use crate::{PasteArtifactLimits, SegmentId, SessionId};
use protocol::PasteArtifactRef;
use std::fs; use std::fs;
use std::io::{Read, Seek, SeekFrom, Write}; use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -109,6 +111,16 @@ impl FsStore {
.join(format!("{segment_id}.trace.jsonl")) .join(format!("{segment_id}.trace.jsonl"))
} }
fn paste_artifact_dir(&self, session_id: SessionId) -> PathBuf {
self.session_dir(session_id).join("artifacts").join("paste")
}
#[cfg(test)]
fn paste_artifact_path(&self, session_id: SessionId, artifact_id: &str) -> PathBuf {
self.paste_artifact_dir(session_id)
.join(format!("{artifact_id}.json"))
}
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> { fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
let _guard = self let _guard = self
.append_lock .append_lock
@@ -350,6 +362,33 @@ impl Store for FsStore {
Ok(complete.lines().filter(|l| !l.trim().is_empty()).count()) Ok(complete.lines().filter(|l| !l.trim().is_empty()).count())
} }
fn write_paste_artifact(
&self,
session_id: SessionId,
source_entry_id: &str,
content: &str,
limits: PasteArtifactLimits,
) -> Result<PasteArtifactRef, StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("session store append lock was poisoned"))?;
write_to_dir(
&self.paste_artifact_dir(session_id),
source_entry_id,
content,
limits,
)
}
fn read_paste_artifact(
&self,
session_id: SessionId,
artifact_id: &str,
) -> Result<(PasteArtifactRef, String), StoreError> {
read_from_dir(&self.paste_artifact_dir(session_id), artifact_id)
}
fn append_trace( fn append_trace(
&self, &self,
session_id: SessionId, session_id: SessionId,
@@ -398,4 +437,146 @@ mod tests {
store.create_segment(session_id, segment_id, &[]).unwrap(); store.create_segment(session_id, segment_id, &[]).unwrap();
assert!(store.session_modified_at(session_id).unwrap().is_some()); assert!(store.session_modified_at(session_id).unwrap().is_some());
} }
#[test]
fn paste_artifacts_are_atomic_integrity_checked_and_session_scoped() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FsStore::new(tmp.path()).unwrap();
let owner = new_session_id();
let other = new_session_id();
let content = "αβγ\nsecond line\n";
let reference = store
.write_paste_artifact(owner, "entry-1", content, PasteArtifactLimits::default())
.unwrap();
assert_eq!(reference.byte_len, content.len() as u64);
assert!(reference.created_at_ms > 0);
assert_eq!(
reference.media_type,
protocol::PasteArtifactMediaType::TextPlainUtf8
);
assert_eq!(
reference.availability,
protocol::PasteArtifactAvailability::Available
);
assert_eq!(reference.char_count, content.chars().count() as u64);
assert_eq!(reference.source_entry_id, "entry-1");
assert_eq!(
store
.read_paste_artifact(owner, &reference.artifact_id)
.unwrap()
.1,
content
);
assert!(matches!(
store.read_paste_artifact(other, &reference.artifact_id),
Err(StoreError::PasteArtifactNotFound(_))
));
assert!(
self::fs::read_dir(store.paste_artifact_dir(owner))
.unwrap()
.all(|entry| !entry
.unwrap()
.file_name()
.to_string_lossy()
.ends_with(".tmp"))
);
let very_large = "z".repeat(1024 * 1024);
let very_large_ref = store
.write_paste_artifact(
owner,
"entry-2",
&very_large,
PasteArtifactLimits::default(),
)
.unwrap();
assert_eq!(
store
.read_paste_artifact(owner, &very_large_ref.artifact_id)
.unwrap()
.1,
very_large
);
}
#[test]
fn concurrent_paste_writes_atomically_enforce_aggregate_caps() {
let tmp = tempfile::TempDir::new().unwrap();
let session_id = new_session_id();
let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
let limits = PasteArtifactLimits {
max_artifact_bytes: 4,
max_session_bytes: 8,
max_session_artifacts: 1,
};
let mut handles = Vec::new();
for entry_id in ["entry-1", "entry-2"] {
let root = tmp.path().to_path_buf();
let barrier = barrier.clone();
handles.push(std::thread::spawn(move || {
let store = FsStore::new(root).unwrap();
barrier.wait();
store.write_paste_artifact(session_id, entry_id, "1234", limits)
}));
}
barrier.wait();
let results = handles
.into_iter()
.map(|handle| handle.join().unwrap())
.collect::<Vec<_>>();
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
assert_eq!(
results
.iter()
.filter(|result| matches!(result, Err(StoreError::PasteArtifactLimit(_))))
.count(),
1
);
assert_eq!(
std::fs::read_dir(
FsStore::new(tmp.path())
.unwrap()
.paste_artifact_dir(session_id)
)
.unwrap()
.filter_map(Result::ok)
.filter(
|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("json")
)
.count(),
1
);
}
#[test]
fn paste_artifact_limits_and_corruption_fail_closed() {
let tmp = tempfile::TempDir::new().unwrap();
let store = FsStore::new(tmp.path()).unwrap();
let session_id = new_session_id();
let limits = PasteArtifactLimits {
max_artifact_bytes: 5,
max_session_bytes: 8,
max_session_artifacts: 2,
};
let first = store
.write_paste_artifact(session_id, "entry-1", "1234", limits)
.unwrap();
assert!(matches!(
store.write_paste_artifact(session_id, "entry-2", "56789", limits),
Err(StoreError::PasteArtifactLimit(_))
));
assert!(matches!(
store.write_paste_artifact(session_id, "entry-2", "5678", limits),
Ok(_)
));
std::fs::write(
store.paste_artifact_path(session_id, &first.artifact_id),
b"{}",
)
.unwrap();
assert!(matches!(
store.read_paste_artifact(session_id, &first.artifact_id),
Err(StoreError::Serde(_)) | Err(StoreError::PasteArtifactIntegrity(_))
));
}
} }
+6 -21
View File
@@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{LoggedItem, SessionId}; use crate::LoggedItem;
/// Stable logical identity of one model-visible history entry. /// Stable logical identity of one model-visible history entry.
/// ///
@@ -142,12 +142,15 @@ mod tests {
#[test] #[test]
fn annotated_segment_start_is_restore_visible_without_projecting_metadata() { fn annotated_segment_start_is_restore_visible_without_projecting_metadata() {
let session_id = uuid::Uuid::now_v7(); let session_id = uuid::Uuid::now_v7();
let history_entry = legacy_logged_history(LoggedItem::Message { let history_entry = LoggedHistoryEntry {
item: LoggedItem::Message {
role: LoggedRole::Assistant, role: LoggedRole::Assistant,
content: vec![crate::LoggedContentPart::Text { content: vec![crate::LoggedContentPart::Text {
text: "answer".into(), text: "answer".into(),
}], }],
}); },
metadata: LoggedSessionHistoryMetadata::legacy_unknown(),
};
let state = crate::collect_state(&[crate::LogEntry::AnnotatedSegmentStart { let state = crate::collect_state(&[crate::LogEntry::AnnotatedSegmentStart {
ts: 1, ts: 1,
session_id, session_id,
@@ -160,21 +163,3 @@ mod tests {
assert_eq!(state.history[0].as_text(), Some("answer")); assert_eq!(state.history[0].as_text(), Some("answer"));
} }
} }
/// Legacy Session Logs did not persist annotations. Decode helpers explicitly
/// create `LegacyUnknown`; they never infer Human/System authority from role or
/// plaintext.
pub fn legacy_logged_history(item: LoggedItem) -> LoggedHistoryEntry {
LoggedHistoryEntry {
item,
metadata: LoggedSessionHistoryMetadata::legacy_unknown(),
}
}
pub fn legacy_segment_history(
session_id: SessionId,
items: impl IntoIterator<Item = LoggedItem>,
) -> Vec<LoggedHistoryEntry> {
let _ = session_id;
items.into_iter().map(legacy_logged_history).collect()
}
@@ -0,0 +1,188 @@
//! Versioned decoder for Session schemas that predate canonical annotated history.
//!
//! These types are intentionally private to `session-store`. Current writers,
//! replay, and public projections use [`crate::LogEntry`] exclusively; only the
//! Worker Session schema migration is allowed to deserialize these shapes.
use agen::llm_client::types::RequestConfig;
use protocol::Segment;
use serde::Deserialize;
use crate::{
LogEntry, LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry, SegmentId,
SegmentOrigin, SessionExtension, SessionId, SystemItem,
};
#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum LegacyHistoryLogEntry {
SegmentStart {
ts: u64,
session_id: SessionId,
system_prompt: Option<String>,
config: RequestConfig,
history: Vec<LoggedItem>,
#[serde(default)]
forked_from: Option<SegmentOrigin>,
#[serde(default)]
compacted_from: Option<SegmentOrigin>,
},
UserInput {
ts: u64,
segments: Vec<Segment>,
#[serde(default)]
extensions: Vec<SessionExtension>,
},
AssistantItem {
ts: u64,
item: LoggedItem,
},
ToolResult {
ts: u64,
item: LoggedItem,
},
SystemItem {
ts: u64,
item: SystemItem,
},
}
/// Schema-v1 decoder. Non-history records already had their current shape, so
/// they pass through `LogEntry`; legacy history records are converted below.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum LegacySessionLogEntryV1 {
History(LegacyHistoryLogEntry),
Current(LogEntry),
}
/// Schema v2 retained the v1 history shapes while adding non-history records.
/// Keep a distinct type so supported source versions remain explicit rather
/// than turning migration compatibility into the current `LogEntry` contract.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum LegacySessionLogEntryV2 {
History(LegacyHistoryLogEntry),
Current(LogEntry),
}
pub(crate) fn decode_entry(
schema_version: u32,
line: &str,
session_id: SessionId,
segment_id: SegmentId,
line_index: usize,
) -> Result<LogEntry, serde_json::Error> {
let entry = match schema_version {
1 => match serde_json::from_str::<LegacySessionLogEntryV1>(line)? {
LegacySessionLogEntryV1::History(entry) => Entry::History(entry),
LegacySessionLogEntryV1::Current(entry) => Entry::Current(entry),
},
2 => match serde_json::from_str::<LegacySessionLogEntryV2>(line)? {
LegacySessionLogEntryV2::History(entry) => Entry::History(entry),
LegacySessionLogEntryV2::Current(entry) => Entry::Current(entry),
},
_ => unreachable!("legacy decoder called for unsupported schema {schema_version}"),
};
Ok(match entry {
Entry::History(entry) => {
canonicalize_history_entry(session_id, segment_id, line_index, entry)
}
Entry::Current(entry) => entry,
})
}
enum Entry {
History(LegacyHistoryLogEntry),
Current(LogEntry),
}
fn legacy_metadata(
segment_id: SegmentId,
line_index: usize,
item_index: usize,
) -> LoggedSessionHistoryMetadata {
let mut identity = Vec::with_capacity(32);
identity.extend_from_slice(segment_id.as_bytes());
identity.extend_from_slice(&(line_index as u64).to_be_bytes());
identity.extend_from_slice(&(item_index as u64).to_be_bytes());
LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId(format!(
"l-{}",
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, identity)
)),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
}
}
fn canonicalize_history_entry(
_session_id: SessionId,
segment_id: SegmentId,
line_index: usize,
entry: LegacyHistoryLogEntry,
) -> LogEntry {
match entry {
LegacyHistoryLogEntry::SegmentStart {
ts,
session_id,
system_prompt,
config,
history,
forked_from,
compacted_from,
} => LogEntry::AnnotatedSegmentStart {
ts,
session_id,
system_prompt,
config,
history: history
.into_iter()
.enumerate()
.map(|(item_index, item)| LoggedHistoryEntry {
item,
metadata: legacy_metadata(segment_id, line_index, item_index),
})
.collect(),
forked_from,
compacted_from,
},
LegacyHistoryLogEntry::UserInput {
ts,
segments,
extensions,
} => LogEntry::AnnotatedUserInput {
ts,
history: vec![LoggedHistoryEntry {
item: LoggedItem::from(agen::Item::user_message(Segment::flatten_to_text(
&segments,
))),
metadata: legacy_metadata(segment_id, line_index, 0),
}],
segments,
extensions,
},
LegacyHistoryLogEntry::AssistantItem { ts, item } => LogEntry::AnnotatedAssistantItem {
ts,
entry: LoggedHistoryEntry {
item,
metadata: legacy_metadata(segment_id, line_index, 0),
},
},
LegacyHistoryLogEntry::ToolResult { ts, item } => LogEntry::AnnotatedToolResult {
ts,
entry: LoggedHistoryEntry {
item,
metadata: legacy_metadata(segment_id, line_index, 0),
},
},
LegacyHistoryLogEntry::SystemItem { ts, item } => LogEntry::AnnotatedSystemItem {
ts,
entry: LoggedSystemHistoryEntry {
item,
metadata: legacy_metadata(segment_id, line_index, 0),
},
},
}
}
+7 -3
View File
@@ -26,14 +26,17 @@
//! let (session_id, segment_id) = create_segment(&store, SegmentStartState { //! let (session_id, segment_id) = create_segment(&store, SegmentStartState {
//! system_prompt: None, //! system_prompt: None,
//! config: &config, //! config: &config,
//! history: &[], //! history: Vec::new(),
//! })?; //! })?;
//! ``` //! ```
pub mod event_trace; pub mod event_trace;
pub mod fs_store; pub mod fs_store;
pub mod history; pub mod history;
mod legacy_session_log;
pub mod logged_item; pub mod logged_item;
mod paste_artifact;
pub mod public_snapshot;
pub mod segment; pub mod segment;
pub mod segment_log; pub mod segment_log;
pub mod store; pub mod store;
@@ -48,11 +51,12 @@ pub use fs_store::FsStore;
pub use history::{ pub use history::{
LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId, LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry,
LoggedWorkerSubject, legacy_logged_history, legacy_segment_history, LoggedWorkerSubject,
}; };
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged}; pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
pub use paste_artifact::PasteArtifactLimits;
pub use segment::{ pub use segment::{
SegmentStartState, append_entry, append_system_item, classify_history_item, SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork, create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork,
fork_at, restore, restore_by_segment, save_config_changed, save_delta, save_extension, fork_at, restore, restore_by_segment, save_config_changed, save_delta, save_extension,
save_run_completed, save_run_errored, save_turn_end, save_usage, save_user_input, save_run_completed, save_run_errored, save_turn_end, save_usage, save_user_input,
+186
View File
@@ -0,0 +1,186 @@
//! Session-owned storage for large pasted-input artifacts.
use std::fs;
use std::io::Write as _;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use fs4::fs_std::FileExt;
use protocol::{PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::StoreError;
/// Bounded storage policy applied before a large paste becomes durable input.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PasteArtifactLimits {
pub max_artifact_bytes: u64,
pub max_session_bytes: u64,
pub max_session_artifacts: u64,
}
impl Default for PasteArtifactLimits {
fn default() -> Self {
Self {
max_artifact_bytes: 8 * 1024 * 1024,
max_session_bytes: 64 * 1024 * 1024,
max_session_artifacts: 1_024,
}
}
}
/// Integrity-bearing on-disk record. The body and metadata are committed in one
/// atomic file replacement so readers never observe a half-written artifact.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct StoredPasteArtifact {
pub reference: PasteArtifactRef,
pub content: String,
}
pub(crate) fn write_to_dir(
artifact_dir: &Path,
source_entry_id: &str,
content: &str,
limits: PasteArtifactLimits,
) -> Result<PasteArtifactRef, StoreError> {
let byte_len = content.len() as u64;
if byte_len > limits.max_artifact_bytes {
return Err(StoreError::PasteArtifactLimit(format!(
"artifact has {byte_len} bytes; maximum is {}",
limits.max_artifact_bytes
)));
}
fs::create_dir_all(artifact_dir)?;
let aggregate_lock = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.open(artifact_dir.join(".aggregate.lock"))?;
FileExt::lock_exclusive(&aggregate_lock)?;
let mut aggregate = 0_u64;
let mut artifact_count = 0_u64;
for entry in fs::read_dir(artifact_dir)? {
let path = entry?.path();
if path.extension().and_then(|value| value.to_str()) != Some("json") {
continue;
}
let stored: StoredPasteArtifact = serde_json::from_slice(&fs::read(&path)?)?;
verify(&stored, &stored.reference.artifact_id)?;
artifact_count = artifact_count.checked_add(1).ok_or_else(|| {
StoreError::PasteArtifactLimit("session artifact count overflow".to_string())
})?;
aggregate = aggregate
.checked_add(stored.reference.byte_len)
.ok_or_else(|| {
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
})?;
}
let projected = aggregate.checked_add(byte_len).ok_or_else(|| {
StoreError::PasteArtifactLimit("session aggregate size overflow".to_string())
})?;
if projected > limits.max_session_bytes {
return Err(StoreError::PasteArtifactLimit(format!(
"session artifacts would use {projected} bytes; maximum is {}",
limits.max_session_bytes
)));
}
if artifact_count >= limits.max_session_artifacts {
return Err(StoreError::PasteArtifactLimit(format!(
"session already has {artifact_count} artifacts; maximum is {}",
limits.max_session_artifacts
)));
}
let artifact_id = uuid::Uuid::now_v7().to_string();
let created_at_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| StoreError::PasteArtifactIntegrity(error.to_string()))?
.as_millis() as u64;
let reference = PasteArtifactRef {
artifact_id: artifact_id.clone(),
created_at_ms,
media_type: PasteArtifactMediaType::TextPlainUtf8,
availability: PasteArtifactAvailability::Available,
byte_len,
char_count: content.chars().count() as u64,
line_count: line_count(content),
sha256: sha256_hex(content),
source_entry_id: source_entry_id.to_string(),
};
let bytes = serde_json::to_vec(&StoredPasteArtifact {
reference: reference.clone(),
content: content.to_string(),
})?;
let target = artifact_dir.join(format!("{artifact_id}.json"));
let temporary = artifact_dir.join(format!(".{artifact_id}.tmp"));
let mut file = fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&temporary)?;
if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
let _ = fs::remove_file(&temporary);
return Err(error.into());
}
if let Err(error) = fs::rename(&temporary, &target) {
let _ = fs::remove_file(&temporary);
return Err(error.into());
}
if let Ok(directory) = fs::File::open(artifact_dir) {
directory.sync_all()?;
}
Ok(reference)
}
pub(crate) fn read_from_dir(
artifact_dir: &Path,
artifact_id: &str,
) -> Result<(PasteArtifactRef, String), StoreError> {
let parsed = uuid::Uuid::parse_str(artifact_id)
.map_err(|_| StoreError::PasteArtifactNotFound(artifact_id.to_string()))?;
if parsed.to_string() != artifact_id {
return Err(StoreError::PasteArtifactNotFound(artifact_id.to_string()));
}
let path = artifact_dir.join(format!("{artifact_id}.json"));
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(StoreError::PasteArtifactNotFound(artifact_id.to_string()));
}
Err(error) => return Err(error.into()),
};
let stored: StoredPasteArtifact = serde_json::from_slice(&bytes)?;
verify(&stored, artifact_id)?;
Ok((stored.reference, stored.content))
}
fn verify(stored: &StoredPasteArtifact, artifact_id: &str) -> Result<(), StoreError> {
let actual_digest = sha256_hex(&stored.content);
if stored.reference.artifact_id != artifact_id
|| stored.reference.created_at_ms == 0
|| stored.reference.media_type != PasteArtifactMediaType::TextPlainUtf8
|| stored.reference.availability != PasteArtifactAvailability::Available
|| stored.reference.byte_len != stored.content.len() as u64
|| stored.reference.char_count != stored.content.chars().count() as u64
|| stored.reference.line_count != line_count(&stored.content)
|| stored.reference.sha256 != actual_digest
{
return Err(StoreError::PasteArtifactIntegrity(artifact_id.to_string()));
}
Ok(())
}
fn sha256_hex(content: &str) -> String {
Sha256::digest(content.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn line_count(content: &str) -> u64 {
if content.is_empty() {
0
} else {
content.lines().count().max(1) as u64
}
}
+484
View File
@@ -0,0 +1,484 @@
use base64::{
Engine as _,
engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD},
};
use protocol::{
Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole, SessionSnapshot,
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
};
use crate::{
LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole,
LoggedSessionHistoryOrigin, SessionId, SystemItem,
};
/// Project a complete current-segment log. A valid segment starts with one
/// canonical annotated SegmentStart record; malformed partial input uses the
/// nil session only to keep the public failure projection deterministic.
pub fn project_current_session_snapshot(log: &[LogEntry]) -> SessionSnapshot {
let session_id = log.iter().find_map(|entry| match entry {
LogEntry::AnnotatedSegmentStart { session_id, .. } => Some(*session_id),
_ => None,
});
project_session_snapshot(session_id.unwrap_or_else(SessionId::nil), log)
}
/// Project the current durable segment into the only public session-history
/// representation. Append-log records remain an internal persistence format.
pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> SessionSnapshot {
let mut session_key = session_id;
let mut entries = Vec::new();
for (log_index, record) in log.iter().enumerate() {
match record {
LogEntry::AnnotatedSegmentStart {
ts,
session_id,
history,
..
} => {
session_key = *session_id;
entries.clear();
extend_history(&mut entries, history, None, *ts);
}
LogEntry::AnnotatedUserInput {
ts,
segments,
history,
..
} => extend_history(&mut entries, history, Some(segments), *ts),
LogEntry::AnnotatedAssistantItem { ts, entry }
| LogEntry::AnnotatedToolResult { ts, entry } => {
if let Some(data) = project_item(&entry.item) {
entries.push(history_entry(entry, *ts, data));
}
}
LogEntry::AnnotatedSystemItem { ts, entry } => entries.push(system_entry(
&entry.item,
entry.metadata.entry_id.0.clone(),
*ts,
provenance(&entry.metadata.origin),
derivation_ids(entry),
)),
LogEntry::RunErrored { ts, message, .. } => entries.push(legacy_entry(
&session_key,
log_index,
0,
*ts,
SessionSnapshotEntryData::RunError {
message: message.clone(),
},
)),
// Run checkpoints, configuration, usage, and extension state are
// controller/storage authority rather than committed conversation.
LogEntry::Invoke { .. }
| LogEntry::TurnEnd { .. }
| LogEntry::RunCompleted { .. }
| LogEntry::ActiveRunCheckpoint { .. }
| LogEntry::PausedTurnAbandoned { .. }
| LogEntry::ConfigChanged { .. }
| LogEntry::LlmUsage { .. }
| LogEntry::Extension { .. } => {}
}
}
SessionSnapshot { entries }
}
fn extend_history(
output: &mut Vec<SessionSnapshotEntry>,
history: &[LoggedHistoryEntry],
input_segments: Option<&Vec<Segment>>,
timestamp: u64,
) {
let mut attached_segments = false;
for entry in history {
let data = if !attached_segments
&& input_segments.is_some()
&& matches!(
&entry.item,
LoggedItem::Message {
role: LoggedRole::User,
..
}
) {
attached_segments = true;
SessionSnapshotEntryData::UserInput {
segments: input_segments.cloned().unwrap_or_default(),
}
} else {
let Some(data) = project_item(&entry.item) else {
continue;
};
data
};
output.push(history_entry(entry, timestamp, data));
}
}
fn history_entry(
entry: &LoggedHistoryEntry,
timestamp: u64,
data: SessionSnapshotEntryData,
) -> SessionSnapshotEntry {
SessionSnapshotEntry {
entry_id: entry.metadata.entry_id.0.clone(),
timestamp,
provenance: provenance(&entry.metadata.origin),
derived_from: entry
.metadata
.derivation
.as_ref()
.map(|derivation| {
derivation
.sources
.iter()
.map(|source| source.0.clone())
.collect()
})
.unwrap_or_default(),
data,
}
}
fn derivation_ids(entry: &crate::LoggedSystemHistoryEntry) -> Vec<String> {
entry
.metadata
.derivation
.as_ref()
.map(|derivation| {
derivation
.sources
.iter()
.map(|source| source.0.clone())
.collect()
})
.unwrap_or_default()
}
fn legacy_entry(
session_key: &SessionId,
log_index: usize,
item_index: usize,
timestamp: u64,
data: SessionSnapshotEntryData,
) -> SessionSnapshotEntry {
SessionSnapshotEntry {
entry_id: legacy_entry_id(session_key, log_index, item_index),
timestamp,
provenance: SessionEntryProvenance::LegacyUnknown,
derived_from: Vec::new(),
data,
}
}
fn legacy_entry_id(session_key: &SessionId, log_index: usize, item_index: usize) -> String {
let mut identity = Vec::with_capacity(32);
identity.extend_from_slice(session_key.as_bytes());
identity.extend_from_slice(&(log_index as u64).to_be_bytes());
identity.extend_from_slice(&(item_index as u64).to_be_bytes());
format!("l-{}", URL_SAFE_NO_PAD.encode(identity))
}
fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance {
match origin {
LoggedSessionHistoryOrigin::HumanInput { .. } => SessionEntryProvenance::HumanInput,
LoggedSessionHistoryOrigin::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
LoggedSessionHistoryOrigin::FlowInstruction { .. } => {
SessionEntryProvenance::FlowInstruction
}
LoggedSessionHistoryOrigin::BackendInstruction { .. } => {
SessionEntryProvenance::BackendInstruction
}
LoggedSessionHistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
LoggedSessionHistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
LoggedSessionHistoryOrigin::DerivedSummary => SessionEntryProvenance::DerivedSummary,
LoggedSessionHistoryOrigin::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
}
}
fn project_item(item: &LoggedItem) -> Option<SessionSnapshotEntryData> {
match item {
LoggedItem::Message { role, content } => {
let role = match role {
LoggedRole::User => SessionMessageRole::User,
LoggedRole::Assistant => SessionMessageRole::Assistant,
// System prompts and instruction history never cross the public
// snapshot boundary. Typed SystemItems have separate records.
LoggedRole::System => return None,
};
Some(SessionSnapshotEntryData::Message {
role,
content: content
.iter()
.map(|part| match part {
LoggedContentPart::Text { text } => {
SessionContentPart::Text { text: text.clone() }
}
LoggedContentPart::Refusal { refusal } => SessionContentPart::Refusal {
refusal: refusal.clone(),
},
})
.collect(),
})
}
LoggedItem::ToolCall {
call_id,
name,
arguments,
} => Some(SessionSnapshotEntryData::ToolCall {
call_id: call_id.clone(),
name: name.clone(),
arguments: arguments.clone(),
}),
LoggedItem::ToolResult {
call_id,
summary,
content,
is_error,
attachments,
..
} => Some(SessionSnapshotEntryData::ToolResult {
call_id: call_id.clone(),
summary: summary.clone(),
content: content.clone(),
is_error: *is_error,
attachments: attachments
.iter()
.map(|attachment| match attachment {
crate::logged_item::LoggedAttachment::Image { mime_type, data } => {
SessionToolAttachment {
media_type: mime_type.clone(),
data_base64: BASE64.encode(data),
}
}
})
.collect(),
}),
// Hidden model reasoning is never observable.
LoggedItem::Reasoning { .. } => None,
}
}
fn system_entry(
item: &SystemItem,
entry_id: String,
timestamp: u64,
provenance: SessionEntryProvenance,
derived_from: Vec<String>,
) -> SessionSnapshotEntry {
let mut data = serde_json::to_value(item).ok();
if let Some(serde_json::Value::Object(object)) = data.as_mut() {
object.remove("prompt_provenance");
}
let item_kind = data
.as_ref()
.and_then(|value| value.get("kind"))
.and_then(serde_json::Value::as_str)
.unwrap_or("system_item")
.to_owned();
SessionSnapshotEntry {
entry_id,
timestamp,
provenance,
derived_from,
data: SessionSnapshotEntryData::SystemItem {
item_kind,
content: item.history_text(),
data,
},
}
}
#[cfg(test)]
mod tests {
use agen::llm_client::RequestConfig;
use super::*;
use crate::{
LoggedHistoryDerivation, LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata,
LoggedWorkerSubject,
};
#[test]
fn current_projection_is_stable_and_hides_reasoning_and_system_prompts() {
let session_id = crate::new_session_id();
let log = vec![LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: vec![
LoggedItem::Message {
role: LoggedRole::System,
content: vec![LoggedContentPart::Text {
text: "secret prompt".into(),
}],
},
LoggedItem::Reasoning {
text: "secret reasoning".into(),
summary: Vec::new(),
encrypted_content: None,
signature: None,
},
LoggedItem::Message {
role: LoggedRole::Assistant,
content: vec![LoggedContentPart::Text {
text: "visible".into(),
}],
},
]
.into_iter()
.map(|item| LoggedHistoryEntry {
item,
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
})
.collect(),
forked_from: None,
compacted_from: None,
}];
let first = project_session_snapshot(session_id, &log);
let second = project_session_snapshot(session_id, &log);
assert_eq!(first, second);
assert_eq!(first.entries.len(), 1);
assert_eq!(first.entries[0].timestamp, 1);
assert_eq!(
first.entries[0].provenance,
SessionEntryProvenance::LegacyUnknown
);
let json = serde_json::to_string(&first).unwrap();
assert!(!json.contains("secret prompt"));
assert!(!json.contains("secret reasoning"));
assert!(json.contains("visible"));
}
#[test]
fn annotated_user_input_attaches_segments_to_first_user_role_entry_for_any_origin() {
let session_id = crate::new_session_id();
let segments = vec![Segment::Text {
content: "normal submit".into(),
}];
for origin in [
LoggedSessionHistoryOrigin::LegacyUnknown,
LoggedSessionHistoryOrigin::FlowInstruction {
selector: "builtin:coder-review".into(),
definition_id: "flow-definition".into(),
definition_revision: 7,
instance_id: "flow-instance".into(),
state_id: "implement".into(),
},
] {
let user_entry_id = LoggedSessionHistoryEntryId::new();
let source_entry_id = LoggedSessionHistoryEntryId::new();
let log = vec![
LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: Vec::new(),
forked_from: None,
compacted_from: None,
},
LogEntry::AnnotatedUserInput {
ts: 2,
segments: segments.clone(),
history: vec![
LoggedHistoryEntry {
item: LoggedItem::Message {
role: LoggedRole::System,
content: vec![LoggedContentPart::Text {
text: "flow instruction".into(),
}],
},
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::FlowInstruction {
selector: "builtin:coder-review".into(),
definition_id: "flow-definition".into(),
definition_revision: 7,
instance_id: "flow-instance".into(),
state_id: "implement".into(),
},
derivation: None,
},
},
LoggedHistoryEntry {
item: LoggedItem::Message {
role: LoggedRole::User,
content: vec![LoggedContentPart::Text {
text: "normal submit".into(),
}],
},
metadata: LoggedSessionHistoryMetadata {
entry_id: user_entry_id.clone(),
origin: origin.clone(),
derivation: Some(LoggedHistoryDerivation {
sources: vec![source_entry_id.clone()],
}),
},
},
],
extensions: Vec::new(),
},
];
let snapshot = project_current_session_snapshot(&log);
assert_eq!(snapshot.entries.len(), 1);
assert_eq!(snapshot.entries[0].entry_id, user_entry_id.0);
assert_eq!(snapshot.entries[0].provenance, provenance(&origin));
assert_eq!(snapshot.entries[0].derived_from, vec![source_entry_id.0]);
assert_eq!(
snapshot.entries[0].data,
SessionSnapshotEntryData::UserInput {
segments: segments.clone(),
}
);
}
}
#[test]
fn annotated_projection_preserves_identity_and_provenance() {
let session_id = crate::new_session_id();
let metadata = LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::ModelOutput {
worker: LoggedWorkerSubject {
workspace_id: None,
runtime_id: None,
worker_id: "worker".into(),
},
},
derivation: None,
};
let expected_id = metadata.entry_id.0.clone();
let log = vec![LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: vec![LoggedHistoryEntry {
item: LoggedItem::Message {
role: LoggedRole::Assistant,
content: vec![LoggedContentPart::Text { text: "ok".into() }],
},
metadata,
}],
forked_from: None,
compacted_from: None,
}];
let snapshot = project_session_snapshot(session_id, &log);
assert_eq!(snapshot.entries[0].entry_id, expected_id);
assert_eq!(
snapshot.entries[0].provenance,
SessionEntryProvenance::ModelOutput
);
}
}
+39 -45
View File
@@ -4,11 +4,9 @@
//! The caller (typically Worker) holds the Engine directly and calls these //! The caller (typically Worker) holds the Engine directly and calls these
//! functions after state-mutating operations. //! functions after state-mutating operations.
use crate::logged_item::{LoggedItem, to_logged};
use crate::segment_log::{self, LogEntry, SegmentOrigin}; use crate::segment_log::{self, LogEntry, SegmentOrigin};
use crate::store::{Store, StoreError}; use crate::store::{Store, StoreError};
use crate::system_item::SystemItem; use crate::{LoggedHistoryEntry, LoggedSystemHistoryEntry, SegmentId, SessionId};
use crate::{SegmentId, SessionId};
use agen::EngineResult; use agen::EngineResult;
use agen::llm_client::RequestConfig; use agen::llm_client::RequestConfig;
use agen::llm_client::types::Item; use agen::llm_client::types::Item;
@@ -18,7 +16,7 @@ use protocol::Segment;
pub struct SegmentStartState<'a> { pub struct SegmentStartState<'a> {
pub system_prompt: Option<&'a str>, pub system_prompt: Option<&'a str>,
pub config: &'a RequestConfig, pub config: &'a RequestConfig,
pub history: &'a [Item], pub history: Vec<LoggedHistoryEntry>,
} }
/// Create a new session + initial segment, writing the initial /// Create a new session + initial segment, writing the initial
@@ -44,12 +42,12 @@ pub fn create_segment_with_ids(
segment_id: SegmentId, segment_id: SegmentId,
state: SegmentStartState<'_>, state: SegmentStartState<'_>,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
let entry = LogEntry::SegmentStart { let entry = LogEntry::AnnotatedSegmentStart {
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
session_id, session_id,
system_prompt: state.system_prompt.map(String::from), system_prompt: state.system_prompt.map(String::from),
config: state.config.clone(), config: state.config.clone(),
history: to_logged(state.history), history: state.history.to_vec(),
forked_from: None, forked_from: None,
compacted_from: None, compacted_from: None,
}; };
@@ -70,12 +68,12 @@ pub fn create_compacted_segment(
source_turn_count: usize, source_turn_count: usize,
) -> Result<SegmentId, StoreError> { ) -> Result<SegmentId, StoreError> {
let segment_id = crate::new_segment_id(); let segment_id = crate::new_segment_id();
let entry = LogEntry::SegmentStart { let entry = LogEntry::AnnotatedSegmentStart {
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
session_id: source_session_id, session_id: source_session_id,
system_prompt: state.system_prompt.map(String::from), system_prompt: state.system_prompt.map(String::from),
config: state.config.clone(), config: state.config.clone(),
history: to_logged(state.history), history: state.history.to_vec(),
forked_from: None, forked_from: None,
compacted_from: Some(SegmentOrigin { compacted_from: Some(SegmentOrigin {
segment_id: source_segment_id, segment_id: source_segment_id,
@@ -154,12 +152,12 @@ pub fn ensure_head_or_fork(
} }
let source_segment_id = *segment_id; let source_segment_id = *segment_id;
let fork_id = crate::new_segment_id(); let fork_id = crate::new_segment_id();
let entry = LogEntry::SegmentStart { let entry = LogEntry::AnnotatedSegmentStart {
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
session_id, session_id,
system_prompt: state.system_prompt.map(String::from), system_prompt: state.system_prompt.map(String::from),
config: state.config.clone(), config: state.config.clone(),
history: to_logged(state.history), history: state.history.to_vec(),
forked_from: Some(SegmentOrigin { forked_from: Some(SegmentOrigin {
segment_id: source_segment_id, segment_id: source_segment_id,
at_turn_index, at_turn_index,
@@ -183,8 +181,9 @@ pub fn save_user_input(
session_id: SessionId, session_id: SessionId,
segment_id: SegmentId, segment_id: SegmentId,
segments: Vec<Segment>, segments: Vec<Segment>,
history: Vec<LoggedHistoryEntry>,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
save_user_input_with_extensions(store, session_id, segment_id, segments, Vec::new()) save_user_input_with_extensions(store, session_id, segment_id, segments, history, Vec::new())
} }
/// Atomically persist one typed user submission and Runtime-owned session /// Atomically persist one typed user submission and Runtime-owned session
@@ -194,15 +193,17 @@ pub fn save_user_input_with_extensions(
session_id: SessionId, session_id: SessionId,
segment_id: SegmentId, segment_id: SegmentId,
segments: Vec<Segment>, segments: Vec<Segment>,
history: Vec<LoggedHistoryEntry>,
extensions: Vec<segment_log::SessionExtension>, extensions: Vec<segment_log::SessionExtension>,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
append_entry( append_entry(
store, store,
session_id, session_id,
segment_id, segment_id,
LogEntry::UserInput { LogEntry::AnnotatedUserInput {
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
segments, segments,
history,
extensions, extensions,
}, },
) )
@@ -220,64 +221,57 @@ pub fn save_delta(
store: &impl Store, store: &impl Store,
session_id: SessionId, session_id: SessionId,
segment_id: SegmentId, segment_id: SegmentId,
new_items: &[Item], new_items: &[LoggedHistoryEntry],
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
if new_items.is_empty() { if new_items.is_empty() {
return Ok(()); return Ok(());
} }
let ts = segment_log::now_millis(); let ts = segment_log::now_millis();
for item in new_items { for entry in new_items {
let item = Item::from(entry.item.clone());
if item.is_user_message() { if item.is_user_message() {
// Already persisted by save_user_input at submit time. // Already persisted by save_user_input at submit time.
continue; continue;
} }
let entry = classify_history_item(item, ts); let entry = classify_logged_history_entry(entry.clone(), ts);
append_entry(store, session_id, segment_id, entry)?; append_entry(store, session_id, segment_id, entry)?;
} }
Ok(()) Ok(())
} }
/// Map one history item to its singular `LogEntry` form. Used by the /// Map one annotated history entry to its singular `LogEntry` form. Used by
/// fallback `save_delta` path and the controller's worker-callback /// the fallback `save_delta` path and the controller's worker-callback
/// classifier so write classification lives in one place. /// classifier so write classification lives in one place without discarding
pub fn classify_history_item(item: &Item, ts: u64) -> LogEntry { /// identity or provenance.
/// Map one already-annotated history entry to its singular canonical record
/// without changing its identity or provenance.
pub fn classify_logged_history_entry(entry: LoggedHistoryEntry, ts: u64) -> LogEntry {
let item = Item::from(entry.item.clone());
if item.is_tool_result() { if item.is_tool_result() {
LogEntry::ToolResult { LogEntry::AnnotatedToolResult { ts, entry }
ts,
item: LoggedItem::from(item),
}
} else if item.is_assistant_message() || item.is_tool_call() || item.is_reasoning() {
LogEntry::AssistantItem {
ts,
item: LoggedItem::from(item),
}
} else { } else {
// Defensive: anything else (future Item kinds) routes through // Assistant messages, tool calls, reasoning, and future non-user
// AssistantItem rather than getting silently dropped. // items all use the assistant-side canonical record.
LogEntry::AssistantItem { LogEntry::AnnotatedAssistantItem { ts, entry }
ts,
item: LoggedItem::from(item),
}
} }
} }
/// Append a single typed system item as `LogEntry::SystemItem`. Helper /// Append one typed system item and its history metadata as a canonical
/// for the Worker-side interceptor commit path; mirrors the per-item /// `LogEntry::AnnotatedSystemItem`.
/// commit shape used for assistant / tool result entries.
pub fn append_system_item( pub fn append_system_item(
store: &impl Store, store: &impl Store,
session_id: SessionId, session_id: SessionId,
segment_id: SegmentId, segment_id: SegmentId,
item: SystemItem, entry: LoggedSystemHistoryEntry,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
append_entry( append_entry(
store, store,
session_id, session_id,
segment_id, segment_id,
LogEntry::SystemItem { LogEntry::AnnotatedSystemItem {
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
item, entry,
}, },
) )
} }
@@ -430,12 +424,12 @@ pub fn fork(
) -> Result<(SessionId, SegmentId), StoreError> { ) -> Result<(SessionId, SegmentId), StoreError> {
let session_id = crate::new_session_id(); let session_id = crate::new_session_id();
let fork_id = crate::new_segment_id(); let fork_id = crate::new_segment_id();
let entry = LogEntry::SegmentStart { let entry = LogEntry::AnnotatedSegmentStart {
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
session_id, session_id,
system_prompt: state.system_prompt.map(String::from), system_prompt: state.system_prompt.map(String::from),
config: state.config.clone(), config: state.config.clone(),
history: to_logged(state.history), history: state.history.to_vec(),
forked_from: None, forked_from: None,
compacted_from: None, compacted_from: None,
}; };
@@ -470,7 +464,7 @@ pub fn fork_at(
// segment), before any turn completes. // segment), before any turn completes.
entries entries
.iter() .iter()
.position(|e| !matches!(e, LogEntry::SegmentStart { .. })) .position(|e| !matches!(e, LogEntry::AnnotatedSegmentStart { .. }))
.unwrap_or(entries.len()) .unwrap_or(entries.len())
} else { } else {
entries entries
@@ -482,12 +476,12 @@ pub fn fork_at(
let state = segment_log::collect_state(&entries[..cut]); let state = segment_log::collect_state(&entries[..cut]);
let fork_id = crate::new_segment_id(); let fork_id = crate::new_segment_id();
let entry = LogEntry::SegmentStart { let entry = LogEntry::AnnotatedSegmentStart {
ts: segment_log::now_millis(), ts: segment_log::now_millis(),
session_id: source_session_id, session_id: source_session_id,
system_prompt: state.system_prompt, system_prompt: state.system_prompt,
config: state.config, config: state.config,
history: to_logged(&state.history), history: state.annotated_history,
forked_from: Some(SegmentOrigin { forked_from: Some(SegmentOrigin {
segment_id: source_id, segment_id: source_id,
at_turn_index, at_turn_index,
+86 -133
View File
@@ -16,7 +16,6 @@ use serde::{Deserialize, Serialize};
use crate::history::{LoggedHistoryEntry, LoggedSystemHistoryEntry}; use crate::history::{LoggedHistoryEntry, LoggedSystemHistoryEntry};
use crate::logged_item::LoggedItem; use crate::logged_item::LoggedItem;
use crate::system_item::SystemItem;
/// A single segment log entry, serialized as one JSONL line. /// A single segment log entry, serialized as one JSONL line.
/// ///
@@ -50,28 +49,7 @@ impl SessionExtension {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum LogEntry { pub enum LogEntry {
/// Segment start. Always the first entry in a segment log. /// Canonical segment seed. Retained entries keep their stable logical
/// For forked segments, `history` contains the seed state from the parent.
SegmentStart {
ts: u64,
/// Session this segment belongs to. Compaction / fork inherits
/// the source segment's session_id; only fresh "new conversation"
/// segments mint a new session_id.
session_id: crate::SessionId,
system_prompt: Option<String>,
config: RequestConfig,
history: Vec<LoggedItem>,
/// Origin: forked from a sibling segment at a specific turn boundary.
/// The referenced segment is guaranteed to share `session_id`.
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_from: Option<SegmentOrigin>,
/// Origin: compacted from a sibling segment at a specific turn boundary.
/// The referenced segment is guaranteed to share `session_id`.
#[serde(default, skip_serializing_if = "Option::is_none")]
compacted_from: Option<SegmentOrigin>,
},
/// Schema-v2 segment seed. Retained entries keep their stable logical
/// identity and origin across fork/compaction/restore. /// identity and origin across fork/compaction/restore.
AnnotatedSegmentStart { AnnotatedSegmentStart {
ts: u64, ts: u64,
@@ -105,22 +83,7 @@ pub enum LogEntry {
/// restore conservatively instead of re-running a dangling tool call. /// restore conservatively instead of re-running a dangling tool call.
Invoke { ts: u64, trigger: InvokeKind }, Invoke { ts: u64, trigger: InvokeKind },
/// User input accepted at submit time. Carries the original typed /// Canonical user submission with its exact model-visible entries. Typed
/// `Vec<Segment>` so clients can re-render typed atoms (paste chips,
/// file refs) on segment restore.
/// Replay flattens these into a `Item::user_message` for the worker
/// history; the worker layer never sees segments directly.
UserInput {
ts: u64,
segments: Vec<Segment>,
/// Typed durable state committed atomically with this input record.
/// Runtime-owned Flow invocation uses this to avoid a Backend-instance
/// commit that can get ahead of Worker history.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extensions: Vec<SessionExtension>,
},
/// Schema-v2 user submission with its exact model-visible entries. Typed
/// Flow instructions and caller-attributed input remain separate entries. /// Flow instructions and caller-attributed input remain separate entries.
AnnotatedUserInput { AnnotatedUserInput {
ts: u64, ts: u64,
@@ -130,35 +93,19 @@ pub enum LogEntry {
history: Vec<LoggedHistoryEntry>, history: Vec<LoggedHistoryEntry>,
}, },
/// Schema-v2 model output and metadata committed as one journal record. /// Canonical model output and metadata committed as one journal record.
AnnotatedAssistantItem { ts: u64, entry: LoggedHistoryEntry }, AnnotatedAssistantItem { ts: u64, entry: LoggedHistoryEntry },
/// One assistant-side item appended to history — assistant message, /// Canonical tool output and metadata committed as one journal record.
/// reasoning, or tool call. Singular: one entry per history item so
/// the wire-side `Event::*` lane and on-disk LogEntry stay 1:1.
AssistantItem { ts: u64, item: LoggedItem },
/// Schema-v2 tool output and metadata committed as one journal record.
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry }, AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
/// One tool-execution result appended to history. /// Canonical typed system event and model-visible metadata committed
ToolResult { ts: u64, item: LoggedItem },
/// Schema-v2 typed system event and model-visible metadata committed
/// together. /// together.
AnnotatedSystemItem { AnnotatedSystemItem {
ts: u64, ts: u64,
entry: LoggedSystemHistoryEntry, entry: LoggedSystemHistoryEntry,
}, },
/// One typed agent-injected system item: notification, child-Worker
/// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each
/// `SystemItem` carries kind metadata that the LLM
/// itself never sees (the LLM gets `Item::system_message` with the
/// item's denormalised `body`), but live clients and replay paths
/// dispatch on `kind` for typed rendering.
SystemItem { ts: u64, item: SystemItem },
/// Turn boundary. Records the turn count after increment. /// Turn boundary. Records the turn count after increment.
TurnEnd { ts: u64, turn_count: usize }, TurnEnd { ts: u64, turn_count: usize },
@@ -260,6 +207,10 @@ pub struct RestoredState {
pub system_prompt: Option<String>, pub system_prompt: Option<String>,
pub config: RequestConfig, pub config: RequestConfig,
pub history: Vec<Item>, pub history: Vec<Item>,
/// Canonical persisted history with stable identity and provenance. This is
/// the authority for rewrites, forks, and annotated restore; `history` is
/// retained as the model-facing item projection.
pub annotated_history: Vec<LoggedHistoryEntry>,
pub turn_count: usize, pub turn_count: usize,
/// AgentTurns consumed by the active paused/yielded logical run. /// AgentTurns consumed by the active paused/yielded logical run.
pub active_run_turn_count: Option<usize>, pub active_run_turn_count: Option<usize>,
@@ -276,7 +227,7 @@ pub struct RestoredState {
/// session-store は domain を不透明扱いし、各ドメインが自前で fold する。 /// session-store は domain を不透明扱いし、各ドメインが自前で fold する。
pub extensions: Vec<(String, serde_json::Value)>, pub extensions: Vec<(String, serde_json::Value)>,
/// User submissions in original typed form, in submit order. /// User submissions in original typed form, in submit order.
/// One entry per `LogEntry::UserInput`; the K-th entry corresponds to /// One entry per `LogEntry::AnnotatedUserInput`; the K-th entry corresponds to
/// the K-th `Item::user_message` derived during replay (modulo /// the K-th `Item::user_message` derived during replay (modulo
/// pre-compaction history seeded via `SegmentStart.history`, whose /// pre-compaction history seeded via `SegmentStart.history`, whose
/// original segments are not preserved). Used by clients to re-render /// original segments are not preserved). Used by clients to re-render
@@ -291,6 +242,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
system_prompt: None, system_prompt: None,
config: RequestConfig::default(), config: RequestConfig::default(),
history: Vec::new(), history: Vec::new(),
annotated_history: Vec::new(),
turn_count: 0, turn_count: 0,
active_run_turn_count: None, active_run_turn_count: None,
last_run_interrupted: false, last_run_interrupted: false,
@@ -304,18 +256,6 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.entries_count += 1; state.entries_count += 1;
match entry { match entry {
LogEntry::SegmentStart {
session_id,
system_prompt,
config,
history,
..
} => {
state.session_id = Some(*session_id);
state.system_prompt = system_prompt.clone();
state.config = config.clone();
state.history = history.iter().cloned().map(Item::from).collect();
}
LogEntry::AnnotatedSegmentStart { LogEntry::AnnotatedSegmentStart {
session_id, session_id,
system_prompt, system_prompt,
@@ -326,6 +266,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.session_id = Some(*session_id); state.session_id = Some(*session_id);
state.system_prompt = system_prompt.clone(); state.system_prompt = system_prompt.clone();
state.config = config.clone(); state.config = config.clone();
state.annotated_history = history.clone();
state.history = history state.history = history
.iter() .iter()
.cloned() .cloned()
@@ -338,26 +279,13 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.last_run_interrupted = true; state.last_run_interrupted = true;
state.active_run_turn_count = Some(0); state.active_run_turn_count = Some(0);
} }
LogEntry::UserInput {
segments,
extensions,
..
} => {
let text = Segment::flatten_to_text(segments);
state.history.push(Item::user_message(text));
state.user_segments.push(segments.clone());
state.extensions.extend(
extensions
.iter()
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
);
}
LogEntry::AnnotatedUserInput { LogEntry::AnnotatedUserInput {
segments, segments,
extensions, extensions,
history, history,
.. ..
} => { } => {
state.annotated_history.extend(history.iter().cloned());
state state
.history .history
.extend(history.iter().cloned().map(|entry| Item::from(entry.item))); .extend(history.iter().cloned().map(|entry| Item::from(entry.item)));
@@ -370,20 +298,16 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
} }
LogEntry::AnnotatedAssistantItem { entry, .. } LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => { | LogEntry::AnnotatedToolResult { entry, .. } => {
state.annotated_history.push(entry.clone());
state.history.push(Item::from(entry.item.clone())); state.history.push(Item::from(entry.item.clone()));
} }
LogEntry::AnnotatedSystemItem { entry, .. } => { LogEntry::AnnotatedSystemItem { entry, .. } => {
state.annotated_history.push(LoggedHistoryEntry {
item: LoggedItem::from(entry.item.to_history_item()),
metadata: entry.metadata.clone(),
});
state.history.push(entry.item.to_history_item()); state.history.push(entry.item.to_history_item());
} }
LogEntry::AssistantItem { item, .. } => {
state.history.push(Item::from(item.clone()));
}
LogEntry::ToolResult { item, .. } => {
state.history.push(Item::from(item.clone()));
}
LogEntry::SystemItem { item, .. } => {
state.history.push(item.to_history_item());
}
LogEntry::TurnEnd { turn_count, .. } => { LogEntry::TurnEnd { turn_count, .. } => {
if let Some(active_turn_count) = &mut state.active_run_turn_count { if let Some(active_turn_count) = &mut state.active_run_turn_count {
*active_turn_count += turn_count.saturating_sub(state.turn_count); *active_turn_count += turn_count.saturating_sub(state.turn_count);
@@ -465,6 +389,20 @@ pub fn now_millis() -> u64 {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::{
LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin,
};
fn annotated(item: Item) -> LoggedHistoryEntry {
LoggedHistoryEntry {
item: LoggedItem::from(item),
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
}
}
#[test] #[test]
fn replay_empty() { fn replay_empty() {
@@ -476,12 +414,12 @@ mod tests {
#[test] #[test]
fn replay_segment_start_sets_initial_state() { fn replay_segment_start_sets_initial_state() {
let state = collect_state(&[LogEntry::SegmentStart { let state = collect_state(&[LogEntry::AnnotatedSegmentStart {
ts: 1000, ts: 1000,
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: Some("You are helpful.".into()), system_prompt: Some("You are helpful.".into()),
config: RequestConfig::default().with_max_tokens(1024), config: RequestConfig::default().with_max_tokens(1024),
history: vec![Item::user_message("seed").into()], history: vec![annotated(Item::user_message("seed"))],
forked_from: None, forked_from: None,
compacted_from: None, compacted_from: None,
}]); }]);
@@ -494,7 +432,7 @@ mod tests {
#[test] #[test]
fn replay_full_turn() { fn replay_full_turn() {
let state = collect_state(&[ let state = collect_state(&[
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
ts: 1000, ts: 1000,
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: None, system_prompt: None,
@@ -503,14 +441,15 @@ mod tests {
forked_from: None, forked_from: None,
compacted_from: None, compacted_from: None,
}, },
LogEntry::UserInput { LogEntry::AnnotatedUserInput {
ts: 2000, ts: 2000,
extensions: vec![], extensions: vec![],
segments: vec![Segment::text("Hello")], segments: vec![Segment::text("Hello")],
history: vec![annotated(Item::user_message("Hello"))],
}, },
LogEntry::AssistantItem { LogEntry::AnnotatedAssistantItem {
ts: 3000, ts: 3000,
item: Item::assistant_message("Hi!").into(), entry: annotated(Item::assistant_message("Hi!")),
}, },
LogEntry::TurnEnd { LogEntry::TurnEnd {
ts: 3100, ts: 3100,
@@ -531,7 +470,7 @@ mod tests {
#[test] #[test]
fn replay_incomplete_invoke_is_interrupted() { fn replay_incomplete_invoke_is_interrupted() {
let state = collect_state(&[ let state = collect_state(&[
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
ts: 1000, ts: 1000,
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: None, system_prompt: None,
@@ -544,14 +483,15 @@ mod tests {
ts: 2000, ts: 2000,
trigger: InvokeKind::UserSend, trigger: InvokeKind::UserSend,
}, },
LogEntry::UserInput { LogEntry::AnnotatedUserInput {
ts: 2001, ts: 2001,
extensions: vec![], extensions: vec![],
segments: vec![Segment::text("run a tool")], segments: vec![Segment::text("run a tool")],
history: vec![annotated(Item::user_message("run a tool"))],
}, },
LogEntry::AssistantItem { LogEntry::AnnotatedAssistantItem {
ts: 3000, ts: 3000,
item: Item::tool_call("call_1", "side_effect", "{}").into(), entry: annotated(Item::tool_call("call_1", "side_effect", "{}")),
}, },
]); ]);
@@ -561,7 +501,7 @@ mod tests {
#[test] #[test]
fn replay_with_tool_calls() { fn replay_with_tool_calls() {
let state = collect_state(&[ let state = collect_state(&[
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
ts: 1000, ts: 1000,
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: None, system_prompt: None,
@@ -570,22 +510,27 @@ mod tests {
forked_from: None, forked_from: None,
compacted_from: None, compacted_from: None,
}, },
LogEntry::UserInput { LogEntry::AnnotatedUserInput {
ts: 2000, ts: 2000,
extensions: vec![], extensions: vec![],
segments: vec![Segment::text("Check weather")], segments: vec![Segment::text("Check weather")],
history: vec![annotated(Item::user_message("Check weather"))],
}, },
LogEntry::AssistantItem { LogEntry::AnnotatedAssistantItem {
ts: 3000, ts: 3000,
item: Item::tool_call("call_1", "get_weather", r#"{"city":"Tokyo"}"#).into(), entry: annotated(Item::tool_call(
"call_1",
"get_weather",
r#"{"city":"Tokyo"}"#,
)),
}, },
LogEntry::ToolResult { LogEntry::AnnotatedToolResult {
ts: 3500, ts: 3500,
item: Item::tool_result("call_1", "Sunny, 25C").into(), entry: annotated(Item::tool_result("call_1", "Sunny, 25C")),
}, },
LogEntry::AssistantItem { LogEntry::AnnotatedAssistantItem {
ts: 4000, ts: 4000,
item: Item::assistant_message("It's sunny in Tokyo!").into(), entry: annotated(Item::assistant_message("It's sunny in Tokyo!")),
}, },
LogEntry::TurnEnd { LogEntry::TurnEnd {
ts: 4100, ts: 4100,
@@ -599,9 +544,9 @@ mod tests {
#[test] #[test]
fn replay_restores_durable_tool_image_detail() { fn replay_restores_durable_tool_image_detail() {
let entry = LogEntry::ToolResult { let entry = LogEntry::AnnotatedToolResult {
ts: 3500, ts: 3500,
item: Item::tool_result_item_with_attachments( entry: annotated(Item::tool_result_item_with_attachments(
"call_image", "call_image",
"attached", "attached",
None, None,
@@ -609,8 +554,7 @@ mod tests {
vec![agen::tool::Attachment::Image( vec![agen::tool::Attachment::Image(
agen::tool::ImageAttachment::new("image/png", b"durable-image".to_vec()), agen::tool::ImageAttachment::new("image/png", b"durable-image".to_vec()),
)], )],
) )),
.into(),
}; };
let persisted = serde_json::to_string(&entry).unwrap(); let persisted = serde_json::to_string(&entry).unwrap();
let restored_entry: LogEntry = serde_json::from_str(&persisted).unwrap(); let restored_entry: LogEntry = serde_json::from_str(&persisted).unwrap();
@@ -630,7 +574,7 @@ mod tests {
#[test] #[test]
fn replay_config_changed() { fn replay_config_changed() {
let state = collect_state(&[ let state = collect_state(&[
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
ts: 1000, ts: 1000,
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: None, system_prompt: None,
@@ -650,7 +594,7 @@ mod tests {
#[test] #[test]
fn replay_llm_usage_appends_to_usage_history() { fn replay_llm_usage_appends_to_usage_history() {
let state = collect_state(&[ let state = collect_state(&[
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
ts: 1000, ts: 1000,
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: None, system_prompt: None,
@@ -659,10 +603,11 @@ mod tests {
forked_from: None, forked_from: None,
compacted_from: None, compacted_from: None,
}, },
LogEntry::UserInput { LogEntry::AnnotatedUserInput {
ts: 2000, ts: 2000,
extensions: vec![], extensions: vec![],
segments: vec![Segment::text("hi")], segments: vec![Segment::text("hi")],
history: vec![annotated(Item::user_message("hi"))],
}, },
LogEntry::LlmUsage { LogEntry::LlmUsage {
ts: 2100, ts: 2100,
@@ -672,9 +617,9 @@ mod tests {
cache_write_tokens: 0, cache_write_tokens: 0,
output_tokens: 10, output_tokens: 10,
}, },
LogEntry::AssistantItem { LogEntry::AnnotatedAssistantItem {
ts: 2200, ts: 2200,
item: Item::assistant_message("yo").into(), entry: annotated(Item::assistant_message("yo")),
}, },
LogEntry::LlmUsage { LogEntry::LlmUsage {
ts: 3100, ts: 3100,
@@ -698,7 +643,7 @@ mod tests {
#[test] #[test]
fn replay_without_llm_usage_keeps_usage_history_empty() { fn replay_without_llm_usage_keeps_usage_history_empty() {
let state = collect_state(&[ let state = collect_state(&[
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
ts: 1000, ts: 1000,
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: None, system_prompt: None,
@@ -707,10 +652,11 @@ mod tests {
forked_from: None, forked_from: None,
compacted_from: None, compacted_from: None,
}, },
LogEntry::UserInput { LogEntry::AnnotatedUserInput {
ts: 2000, ts: 2000,
extensions: vec![], extensions: vec![],
segments: vec![Segment::text("hi")], segments: vec![Segment::text("hi")],
history: vec![annotated(Item::user_message("hi"))],
}, },
]); ]);
assert!(state.usage_history.is_empty()); assert!(state.usage_history.is_empty());
@@ -771,7 +717,7 @@ mod tests {
#[test] #[test]
fn replay_invoke_marker_only_mutates_interrupted_state() { fn replay_invoke_marker_only_mutates_interrupted_state() {
let state = collect_state(&[ let state = collect_state(&[
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
ts: 0, ts: 0,
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: None, system_prompt: None,
@@ -784,10 +730,11 @@ mod tests {
ts: 100, ts: 100,
trigger: InvokeKind::UserSend, trigger: InvokeKind::UserSend,
}, },
LogEntry::UserInput { LogEntry::AnnotatedUserInput {
ts: 101, ts: 101,
extensions: vec![], extensions: vec![],
segments: vec![Segment::text("hi")], segments: vec![Segment::text("hi")],
history: vec![annotated(Item::user_message("hi"))],
}, },
LogEntry::TurnEnd { LogEntry::TurnEnd {
ts: 200, ts: 200,
@@ -806,7 +753,7 @@ mod tests {
#[test] #[test]
fn replay_paused_turn_abandoned_clears_interrupted_marker() { fn replay_paused_turn_abandoned_clears_interrupted_marker() {
let state = collect_state(&[ let state = collect_state(&[
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
ts: 0, ts: 0,
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: None, system_prompt: None,
@@ -830,7 +777,7 @@ mod tests {
#[test] #[test]
fn replay_restores_active_run_budget_across_compaction_checkpoint() { fn replay_restores_active_run_budget_across_compaction_checkpoint() {
let state = collect_state(&[ let state = collect_state(&[
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
ts: 0, ts: 0,
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: None, system_prompt: None,
@@ -861,7 +808,7 @@ mod tests {
})) }))
.expect("legacy run-completed entry"); .expect("legacy run-completed entry");
let state = collect_state(&[ let state = collect_state(&[
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
ts: 0, ts: 0,
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: None, system_prompt: None,
@@ -924,7 +871,7 @@ mod tests {
#[test] #[test]
fn replay_extension_collects_domain_payload_pairs() { fn replay_extension_collects_domain_payload_pairs() {
let state = collect_state(&[ let state = collect_state(&[
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
ts: 1000, ts: 1000,
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: None, system_prompt: None,
@@ -983,9 +930,12 @@ mod tests {
#[test] #[test]
fn user_input_extensions_restore_with_the_same_committed_input() { fn user_input_extensions_restore_with_the_same_committed_input() {
let segments = vec![Segment::text("Flow instructions"), Segment::text("Ticket")]; let segments = vec![Segment::text("Flow instructions"), Segment::text("Ticket")];
let entry = LogEntry::UserInput { let entry = LogEntry::AnnotatedUserInput {
ts: 9999, ts: 9999,
segments: segments.clone(), segments: segments.clone(),
history: vec![annotated(Item::user_message(Segment::flatten_to_text(
&segments,
)))],
extensions: vec![SessionExtension::new( extensions: vec![SessionExtension::new(
"flow.runtime.v1", "flow.runtime.v1",
serde_json::json!({ "state": "implement", "revision": 0 }), serde_json::json!({ "state": "implement", "revision": 0 }),
@@ -1000,7 +950,7 @@ mod tests {
assert_eq!(state.extensions[0].1["state"], "implement"); assert_eq!(state.extensions[0].1["state"], "implement");
} }
/// Mixed segments survive a JSON round-trip through `LogEntry::UserInput`, /// Mixed segments survive a JSON round-trip through `LogEntry::AnnotatedUserInput`,
/// and `collect_state` derives `Item::user_message` from the flattened /// and `collect_state` derives `Item::user_message` from the flattened
/// text while preserving the original segments separately. This covers /// text while preserving the original segments separately. This covers
/// the segments → flatten → Item replay path from the ticket. /// the segments → flatten → Item replay path from the ticket.
@@ -1020,16 +970,19 @@ mod tests {
path: "src/main.rs".into(), path: "src/main.rs".into(),
}, },
]; ];
let entry = LogEntry::UserInput { let entry = LogEntry::AnnotatedUserInput {
ts: 4242, ts: 4242,
extensions: vec![], extensions: vec![],
segments: segments.clone(), segments: segments.clone(),
history: vec![annotated(Item::user_message(Segment::flatten_to_text(
&segments,
)))],
}; };
// JSON round-trip preserves the variant byte-for-byte. // JSON round-trip preserves the variant byte-for-byte.
let json = serde_json::to_string(&entry).unwrap(); let json = serde_json::to_string(&entry).unwrap();
let parsed: LogEntry = serde_json::from_str(&json).unwrap(); let parsed: LogEntry = serde_json::from_str(&json).unwrap();
let state = collect_state(&[ let state = collect_state(&[
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
ts: 1, ts: 1,
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: None, system_prompt: None,
+34 -1
View File
@@ -13,7 +13,8 @@
use crate::event_trace::TraceEntry; use crate::event_trace::TraceEntry;
use crate::segment_log::LogEntry; use crate::segment_log::LogEntry;
use crate::{SegmentId, SessionId}; use crate::{PasteArtifactLimits, SegmentId, SessionId};
use protocol::PasteArtifactRef;
/// Errors from the persistence store. /// Errors from the persistence store.
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -29,6 +30,18 @@ pub enum StoreError {
#[error("log corrupted at line {line}: {message}")] #[error("log corrupted at line {line}: {message}")]
Corrupt { line: usize, message: String }, Corrupt { line: usize, message: String },
#[error("paste artifact storage is unavailable")]
PasteArtifactUnsupported,
#[error("paste artifact not found: {0}")]
PasteArtifactNotFound(String),
#[error("paste artifact integrity check failed: {0}")]
PasteArtifactIntegrity(String),
#[error("paste artifact size limit exceeded: {0}")]
PasteArtifactLimit(String),
} }
/// Sync persistence backend for segment logs. /// Sync persistence backend for segment logs.
@@ -117,6 +130,26 @@ pub trait Store: Send + Sync {
segment_id: SegmentId, segment_id: SegmentId,
) -> Result<usize, StoreError>; ) -> Result<usize, StoreError>;
/// Store a large paste before its reference is committed to history.
fn write_paste_artifact(
&self,
_session_id: SessionId,
_source_entry_id: &str,
_content: &str,
_limits: PasteArtifactLimits,
) -> Result<PasteArtifactRef, StoreError> {
Err(StoreError::PasteArtifactUnsupported)
}
/// Read and verify one artifact owned by `session_id`.
fn read_paste_artifact(
&self,
_session_id: SessionId,
_artifact_id: &str,
) -> Result<(PasteArtifactRef, String), StoreError> {
Err(StoreError::PasteArtifactUnsupported)
}
/// Append a trace entry to the debug event trace file. /// Append a trace entry to the debug event trace file.
fn append_trace( fn append_trace(
&self, &self,
+1 -1
View File
@@ -8,7 +8,7 @@
//! `kind` instead of parsing text prefixes like `[Notification] …` or //! `kind` instead of parsing text prefixes like `[Notification] …` or
//! `[File: …]`. //! `[File: …]`.
//! //!
//! Persisted as the payload of [`crate::LogEntry::SystemItem`] (one //! Persisted as the payload of [`crate::LogEntry::AnnotatedSystemItem`] (one
//! entry per item), and broadcast live as the payload of //! entry per item), and broadcast live as the payload of
//! `Event::SystemItem` on the wire. //! `Event::SystemItem` on the wire.
//! //!
@@ -608,6 +608,24 @@ where
) -> Result<usize, crate::StoreError> { ) -> Result<usize, crate::StoreError> {
self.session_store.read_entry_count(session_id, segment_id) self.session_store.read_entry_count(session_id, segment_id)
} }
fn write_paste_artifact(
&self,
session_id: SessionId,
source_entry_id: &str,
content: &str,
limits: crate::PasteArtifactLimits,
) -> Result<protocol::PasteArtifactRef, crate::StoreError> {
self.session_store
.write_paste_artifact(session_id, source_entry_id, content, limits)
}
fn read_paste_artifact(
&self,
session_id: SessionId,
artifact_id: &str,
) -> Result<(protocol::PasteArtifactRef, String), crate::StoreError> {
self.session_store
.read_paste_artifact(session_id, artifact_id)
}
fn append_trace( fn append_trace(
&self, &self,
session_id: SessionId, session_id: SessionId,
+521 -19
View File
@@ -10,9 +10,11 @@
//! every later operation must use that same ID. //! every later operation must use that same ID.
use crate::event_trace::TraceEntry; use crate::event_trace::TraceEntry;
use crate::paste_artifact::{read_from_dir, write_to_dir};
use crate::segment_log::LogEntry; use crate::segment_log::LogEntry;
use crate::store::{Store, StoreError}; use crate::store::{Store, StoreError};
use crate::{SegmentId, SessionId}; use crate::{PasteArtifactLimits, SegmentId, SessionId};
use protocol::PasteArtifactRef;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions}; use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write}; use std::io::{Read, Seek, SeekFrom, Write};
@@ -20,10 +22,12 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::SystemTime; use std::time::SystemTime;
const SESSION_SCHEMA_VERSION: u32 = 2; const SESSION_SCHEMA_VERSION: u32 = 3;
const PREVIOUS_SESSION_SCHEMA_VERSION: u32 = 2;
const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1; const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1;
const SESSION_FILE: &str = "session.json"; const SESSION_FILE: &str = "session.json";
const SEGMENTS_DIR: &str = "segments"; const SEGMENTS_DIR: &str = "segments";
const PASTE_ARTIFACTS_DIR: &str = "artifacts/paste";
#[derive(Clone)] #[derive(Clone)]
pub struct WorkerSessionStore { pub struct WorkerSessionStore {
@@ -47,9 +51,15 @@ impl WorkerSessionStore {
Ok(bytes) => { Ok(bytes) => {
let mut manifest: SessionManifest = serde_json::from_slice(&bytes)?; let mut manifest: SessionManifest = serde_json::from_slice(&bytes)?;
match manifest.schema_version { match manifest.schema_version {
SESSION_SCHEMA_VERSION => {} SESSION_SCHEMA_VERSION => {
LEGACY_SESSION_SCHEMA_VERSION => { validate_canonical_segment_logs(&root)?;
validate_legacy_segment_logs(&root)?; }
PREVIOUS_SESSION_SCHEMA_VERSION | LEGACY_SESSION_SCHEMA_VERSION => {
migrate_segment_logs_to_v3(
&root,
manifest.session_id,
manifest.schema_version,
)?;
manifest.schema_version = SESSION_SCHEMA_VERSION; manifest.schema_version = SESSION_SCHEMA_VERSION;
atomic_write_json(&root.join(SESSION_FILE), &manifest)?; atomic_write_json(&root.join(SESSION_FILE), &manifest)?;
} }
@@ -144,6 +154,41 @@ impl WorkerSessionStore {
.join(format!("{segment_id}.trace.jsonl")) .join(format!("{segment_id}.trace.jsonl"))
} }
fn append_log_entry(&self, path: &Path, entry: &LogEntry) -> Result<(), StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
let mut file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.append(true)
.open(path)?;
let committed_len = truncate_uncommitted_tail(&mut file)?;
file.seek(SeekFrom::Start(0))?;
let mut existing = Vec::new();
file.read_to_end(&mut existing)?;
parse_jsonl::<LogEntry>(&existing)?;
let line = serde_json::to_string(entry)?;
let mut record = Vec::with_capacity(line.len() + 1);
record.extend_from_slice(line.as_bytes());
record.push(b'\n');
if let Err(write_error) = file.write_all(&record) {
return match file.set_len(committed_len) {
Ok(()) => Err(write_error.into()),
Err(rollback_error) => Err(std::io::Error::new(
rollback_error.kind(),
format!(
"session append failed ({write_error}) and rollback failed: {rollback_error}"
),
)
.into()),
};
}
Ok(())
}
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> { fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
let _guard = self let _guard = self
.append_lock .append_lock
@@ -183,7 +228,7 @@ impl Store for WorkerSessionStore {
entry: &LogEntry, entry: &LogEntry,
) -> Result<(), StoreError> { ) -> Result<(), StoreError> {
self.ensure_session(session_id, true)?; self.ensure_session(session_id, true)?;
self.append_line(&self.log_path(segment_id), &serde_json::to_string(entry)?) self.append_log_entry(&self.log_path(segment_id), entry)
} }
fn read_all( fn read_all(
@@ -275,6 +320,35 @@ impl Store for WorkerSessionStore {
.count()) .count())
} }
fn write_paste_artifact(
&self,
session_id: SessionId,
source_entry_id: &str,
content: &str,
limits: PasteArtifactLimits,
) -> Result<PasteArtifactRef, StoreError> {
self.ensure_session(session_id, true)?;
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
write_to_dir(
&self.root.join(PASTE_ARTIFACTS_DIR),
source_entry_id,
content,
limits,
)
}
fn read_paste_artifact(
&self,
session_id: SessionId,
artifact_id: &str,
) -> Result<(PasteArtifactRef, String), StoreError> {
self.ensure_session(session_id, false)?;
read_from_dir(&self.root.join(PASTE_ARTIFACTS_DIR), artifact_id)
}
fn append_trace( fn append_trace(
&self, &self,
session_id: SessionId, session_id: SessionId,
@@ -286,37 +360,138 @@ impl Store for WorkerSessionStore {
} }
} }
fn validate_legacy_segment_logs(root: &Path) -> Result<(), StoreError> { fn segment_log_paths(root: &Path) -> Result<Vec<(SegmentId, PathBuf)>, StoreError> {
let segments = root.join(SEGMENTS_DIR); let segments = root.join(SEGMENTS_DIR);
if !segments.exists() { if !segments.exists() {
return Ok(()); return Ok(Vec::new());
} }
let mut paths = Vec::new();
for entry in fs::read_dir(&segments)? { for entry in fs::read_dir(&segments)? {
let entry = entry?; let entry = entry?;
let path = entry.path(); let path = entry.path();
let metadata = fs::symlink_metadata(&path)?;
let Some(name) = path.file_name().and_then(|name| name.to_str()) else { let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue; return Err(StoreError::Corrupt {
line: 0,
message: format!("non-UTF-8 Worker Session segment path: {}", path.display()),
});
}; };
if !name.ends_with(".jsonl") || name.ends_with(".trace.jsonl") { if name.ends_with(".trace.jsonl") || name.starts_with('.') {
continue; continue;
} }
let contents = fs::read_to_string(&path)?; if !name.ends_with(".jsonl") {
for (line_index, line) in contents.lines().enumerate() {
if line.trim().is_empty() {
continue; continue;
} }
serde_json::from_str::<LogEntry>(line).map_err(|error| StoreError::Corrupt { if !metadata.file_type().is_file() {
line: line_index + 1, return Err(StoreError::Corrupt {
line: 0,
message: format!( message: format!(
"cannot migrate legacy Worker Session log {}: {error}", "Worker Session segment is not a regular file: {}",
path.display()
),
});
}
let segment_id =
name.trim_end_matches(".jsonl")
.parse()
.map_err(|_| StoreError::Corrupt {
line: 0,
message: format!("invalid Worker Session segment name: {name}"),
})?;
paths.push((segment_id, path));
}
paths.sort_by_key(|(segment_id, _)| *segment_id);
Ok(paths)
}
fn migrate_segment_logs_to_v3(
root: &Path,
session_id: SessionId,
source_schema_version: u32,
) -> Result<(), StoreError> {
struct MigrationPlan {
path: PathBuf,
source: Vec<u8>,
output: Vec<u8>,
}
// Phase 1 is strictly read-only. Every segment must parse and canonicalize
// successfully before the first authoritative byte is replaced.
let mut plans = Vec::new();
for (segment_id, path) in segment_log_paths(root)? {
let source = fs::read(&path)?;
let canonical = parse_legacy_jsonl(source_schema_version, session_id, segment_id, &source)
.map_err(|error| StoreError::Corrupt {
line: 0,
message: format!(
"cannot migrate Worker Session log {}: {error}",
path.display() path.display()
), ),
})?; })?;
let mut output = Vec::new();
for entry in canonical {
serde_json::to_writer(&mut output, &entry)?;
output.push(b'\n');
} }
plans.push(MigrationPlan {
path,
source,
output,
});
}
// Fence the complete preflight snapshot before starting phase 2. Session
// open is the exclusive restore boundary; this additionally fails closed
// if an unexpected writer raced the preflight.
for plan in &plans {
if fs::read(&plan.path)? != plan.source {
return Err(StoreError::Corrupt {
line: 0,
message: format!(
"Worker Session segment changed during migration: {}",
plan.path.display()
),
});
}
}
for plan in plans {
atomic_write_bytes(&plan.path, &plan.output)?;
} }
Ok(()) Ok(())
} }
fn validate_canonical_segment_logs(root: &Path) -> Result<(), StoreError> {
for (_, path) in segment_log_paths(root)? {
let _: Vec<LogEntry> = parse_jsonl(&fs::read(&path)?)?;
}
Ok(())
}
fn parse_legacy_jsonl(
schema_version: u32,
session_id: SessionId,
segment_id: SegmentId,
bytes: &[u8],
) -> Result<Vec<LogEntry>, serde_json::Error> {
let text = std::str::from_utf8(bytes).map_err(|error| {
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
})?;
text.lines()
.enumerate()
.filter(|(_, line)| !line.trim().is_empty())
.map(|(line_index, line)| {
crate::legacy_session_log::decode_entry(
schema_version,
line,
session_id,
segment_id,
line_index,
)
})
.collect()
}
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> { fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
let mut bytes = serde_json::to_vec_pretty(value)?; let mut bytes = serde_json::to_vec_pretty(value)?;
bytes.push(b'\n'); bytes.push(b'\n');
@@ -418,7 +593,21 @@ fn truncate_uncommitted_tail(file: &mut File) -> std::io::Result<u64> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::{Store, new_segment_id, new_session_id}; use crate::{
LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata,
LoggedSessionHistoryOrigin, Store, new_segment_id, new_session_id,
};
fn annotated(item: agen::Item) -> LoggedHistoryEntry {
LoggedHistoryEntry {
item: LoggedItem::from(item),
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
}
}
#[test] #[test]
fn canonical_layout_and_single_session_invariant() { fn canonical_layout_and_single_session_invariant() {
@@ -445,7 +634,46 @@ mod tests {
} }
#[test] #[test]
fn schema_v1_logs_are_validated_and_promoted_to_v2() { fn worker_session_store_keeps_paste_artifacts_inside_retention_root() {
let root = tempfile::tempdir().unwrap();
let store = WorkerSessionStore::new(root.path().join("session")).unwrap();
let session_id = new_session_id();
store
.create_segment(session_id, new_segment_id(), &[])
.unwrap();
let content = "large paste body\n終端\n";
let reference = store
.write_paste_artifact(
session_id,
"entry-1",
content,
PasteArtifactLimits::default(),
)
.unwrap();
assert!(
root.path()
.join(format!(
"session/{PASTE_ARTIFACTS_DIR}/{}.json",
reference.artifact_id
))
.is_file()
);
assert_eq!(
store
.read_paste_artifact(session_id, &reference.artifact_id)
.unwrap()
.1,
content
);
assert!(matches!(
store.read_paste_artifact(new_session_id(), &reference.artifact_id),
Err(StoreError::Corrupt { .. })
));
}
#[test]
fn schema_v1_logs_are_rewritten_and_promoted_to_v3() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
let session_id = new_session_id(); let session_id = new_session_id();
let segment_id = new_segment_id(); let segment_id = new_segment_id();
@@ -467,7 +695,7 @@ mod tests {
} }
#[test] #[test]
fn schema_v1_migration_rejects_corrupt_log_before_manifest_update() { fn schema_v1_migration_rejects_corrupt_log_before_v3_manifest_update() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
let session_id = new_session_id(); let session_id = new_session_id();
let manifest = SessionManifest { let manifest = SessionManifest {
@@ -492,6 +720,280 @@ mod tests {
assert_eq!(persisted.schema_version, LEGACY_SESSION_SCHEMA_VERSION); assert_eq!(persisted.schema_version, LEGACY_SESSION_SCHEMA_VERSION);
} }
#[test]
fn schema_v2_migration_rewrites_legacy_records_with_stable_unknown_provenance() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap();
atomic_write_json(
&root.path().join(SESSION_FILE),
&SessionManifest {
schema_version: PREVIOUS_SESSION_SCHEMA_VERSION,
session_id,
},
)
.unwrap();
let source = vec![
serde_json::json!({
"kind": "segment_start",
"ts": 1,
"session_id": session_id,
"system_prompt": null,
"config": agen::llm_client::RequestConfig::default(),
"history": [LoggedItem::from(agen::Item::assistant_message("prior"))],
"forked_from": null,
"compacted_from": null
}),
serde_json::json!({
"kind": "user_input",
"ts": 2,
"segments": [{ "kind": "text", "content": "hello" }],
"extensions": []
}),
serde_json::json!({
"kind": "assistant_item",
"ts": 3,
"item": LoggedItem::from(agen::Item::assistant_message("reply"))
}),
];
let path = root
.path()
.join(SEGMENTS_DIR)
.join(format!("{segment_id}.jsonl"));
let mut bytes = Vec::new();
for entry in source {
serde_json::to_writer(&mut bytes, &entry).unwrap();
bytes.push(b'\n');
}
fs::write(&path, bytes).unwrap();
let store = WorkerSessionStore::new(root.path()).unwrap();
let first = store.read_all(session_id, segment_id).unwrap();
assert!(matches!(first[0], LogEntry::AnnotatedSegmentStart { .. }));
assert!(matches!(first[1], LogEntry::AnnotatedUserInput { .. }));
assert!(matches!(first[2], LogEntry::AnnotatedAssistantItem { .. }));
let first_bytes = fs::read(&path).unwrap();
drop(store);
let reopened = WorkerSessionStore::new(root.path()).unwrap();
assert_eq!(fs::read(&path).unwrap(), first_bytes);
let snapshot = crate::public_snapshot::project_current_session_snapshot(
&reopened.read_all(session_id, segment_id).unwrap(),
);
assert_eq!(snapshot.entries.len(), 3);
assert_eq!(
snapshot
.entries
.iter()
.map(|entry| entry.timestamp)
.collect::<Vec<_>>(),
vec![1, 2, 3]
);
assert!(snapshot.entries.iter().all(|entry| {
entry.provenance == protocol::SessionEntryProvenance::LegacyUnknown
&& entry.entry_id.len() <= 64
}));
}
#[test]
fn schema_v2_preflight_keeps_earlier_segments_unchanged_when_later_is_corrupt() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let valid_segment = uuid::Uuid::from_u128(1);
let corrupt_segment = uuid::Uuid::from_u128(2);
fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap();
atomic_write_json(
&root.path().join(SESSION_FILE),
&SessionManifest {
schema_version: PREVIOUS_SESSION_SCHEMA_VERSION,
session_id,
},
)
.unwrap();
let manifest_before = fs::read(root.path().join(SESSION_FILE)).unwrap();
let valid_path = root
.path()
.join(SEGMENTS_DIR)
.join(format!("{valid_segment}.jsonl"));
let valid_entry = serde_json::json!({
"kind": "segment_start",
"ts": 1,
"session_id": session_id,
"system_prompt": null,
"config": agen::llm_client::RequestConfig::default(),
"history": [LoggedItem::from(agen::Item::assistant_message("prior"))],
"forked_from": null,
"compacted_from": null
});
let mut valid_bytes = serde_json::to_vec(&valid_entry).unwrap();
valid_bytes.push(b'\n');
fs::write(&valid_path, &valid_bytes).unwrap();
let corrupt_path = root
.path()
.join(SEGMENTS_DIR)
.join(format!("{corrupt_segment}.jsonl"));
fs::write(&corrupt_path, b"{not-json}\n").unwrap();
let corrupt_before = fs::read(&corrupt_path).unwrap();
let error = match WorkerSessionStore::new(root.path()) {
Ok(_) => panic!("later corrupt segment must fail migration preflight"),
Err(error) => error,
};
assert!(matches!(error, StoreError::Corrupt { .. }));
assert_eq!(fs::read(&valid_path).unwrap(), valid_bytes);
assert_eq!(fs::read(&corrupt_path).unwrap(), corrupt_before);
assert_eq!(
fs::read(root.path().join(SESSION_FILE)).unwrap(),
manifest_before
);
}
#[test]
fn current_jsonl_requires_annotations_across_append_rewrite_and_reopen() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
let store = WorkerSessionStore::new(root.path()).unwrap();
store
.create_segment(
session_id,
segment_id,
&[LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: agen::llm_client::RequestConfig::default(),
history: vec![annotated(agen::Item::user_message("seed"))],
forked_from: None,
compacted_from: None,
}],
)
.unwrap();
store
.append(
session_id,
segment_id,
&LogEntry::AnnotatedAssistantItem {
ts: 2,
entry: annotated(agen::Item::assistant_message("reply")),
},
)
.unwrap();
let before_rewrite = store.read_all(session_id, segment_id).unwrap();
store
.create_segment(session_id, segment_id, &before_rewrite)
.unwrap();
drop(store);
let reopened = WorkerSessionStore::new(root.path()).unwrap();
let restored = reopened.read_all(session_id, segment_id).unwrap();
assert_eq!(
serde_json::to_value(&restored).unwrap(),
serde_json::to_value(&before_rewrite).unwrap()
);
for entry in &restored {
match entry {
LogEntry::AnnotatedSegmentStart { history, .. } => assert!(history.iter().all(
|entry| !entry.metadata.entry_id.0.is_empty()
&& matches!(
entry.metadata.origin,
LoggedSessionHistoryOrigin::LegacyUnknown
)
)),
LogEntry::AnnotatedAssistantItem { entry, .. } => {
assert!(!entry.metadata.entry_id.0.is_empty());
assert!(matches!(
entry.metadata.origin,
LoggedSessionHistoryOrigin::LegacyUnknown
));
}
_ => {}
}
}
let log = fs::read_to_string(reopened.log_path(segment_id)).unwrap();
for line in log.lines() {
let value: serde_json::Value = serde_json::from_str(line).unwrap();
let kind = value["kind"].as_str().unwrap();
assert!(
!matches!(
kind,
"segment_start"
| "user_input"
| "assistant_item"
| "tool_result"
| "system_item"
),
"current-schema JSONL contains legacy history record: {kind}"
);
}
}
#[test]
fn schema_v3_rejects_legacy_records_and_new_writes_are_canonical() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
let store = WorkerSessionStore::new(root.path()).unwrap();
store
.create_segment(
session_id,
segment_id,
&[LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: agen::llm_client::RequestConfig::default(),
history: vec![annotated(agen::Item::assistant_message("seed"))],
forked_from: None,
compacted_from: None,
}],
)
.unwrap();
store
.append(
session_id,
segment_id,
&LogEntry::AnnotatedUserInput {
ts: 2,
segments: vec![protocol::Segment::Text {
content: "new".into(),
}],
history: vec![annotated(agen::Item::user_message("new"))],
extensions: Vec::new(),
},
)
.unwrap();
let entries = store.read_all(session_id, segment_id).unwrap();
assert!(matches!(entries[0], LogEntry::AnnotatedSegmentStart { .. }));
assert!(matches!(entries[1], LogEntry::AnnotatedUserInput { .. }));
drop(store);
let path = root
.path()
.join(SEGMENTS_DIR)
.join(format!("{segment_id}.jsonl"));
let mut file = OpenOptions::new().append(true).open(path).unwrap();
serde_json::to_writer(
&mut file,
&serde_json::json!({
"kind": "system_item",
"ts": 3,
"item": { "kind": "legacy_ignored", "slug": "legacy" }
}),
)
.unwrap();
file.write_all(b"\n").unwrap();
let error = match WorkerSessionStore::new(root.path()) {
Ok(_) => panic!("schema v3 must reject a legacy history record"),
Err(error) => error,
};
assert!(matches!(error, StoreError::Corrupt { .. }));
}
#[test] #[test]
fn reopen_preserves_session_and_segment_ids() { fn reopen_preserves_session_and_segment_ids() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
+28 -12
View File
@@ -1,12 +1,25 @@
use agen::EngineResult; use agen::EngineResult;
use agen::llm_client::types::{Item, RequestConfig}; use agen::llm_client::types::{Item, RequestConfig};
use session_store::{ use session_store::{
FsStore, LogEntry, Store, TraceEntry, collect_state, new_segment_id, new_session_id, FsStore, LogEntry, LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, Store, TraceEntry, collect_state,
new_segment_id, new_session_id,
}; };
use std::io::Write; use std::io::Write;
fn annotated(item: Item) -> LoggedHistoryEntry {
LoggedHistoryEntry {
item: LoggedItem::from(item),
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
}
}
fn nil_session_start(ts: u64, session_id: uuid::Uuid) -> LogEntry { fn nil_session_start(ts: u64, session_id: uuid::Uuid) -> LogEntry {
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
ts, ts,
session_id, session_id,
system_prompt: None, system_prompt: None,
@@ -25,7 +38,7 @@ fn round_trip_write_and_read() {
let segid = new_segment_id(); let segid = new_segment_id();
let entries = vec![ let entries = vec![
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
ts: 1000, ts: 1000,
session_id: sid, session_id: sid,
system_prompt: Some("You are helpful.".into()), system_prompt: Some("You are helpful.".into()),
@@ -34,14 +47,15 @@ fn round_trip_write_and_read() {
forked_from: None, forked_from: None,
compacted_from: None, compacted_from: None,
}, },
LogEntry::UserInput { LogEntry::AnnotatedUserInput {
ts: 2000, ts: 2000,
extensions: vec![], extensions: vec![],
segments: vec![protocol::Segment::text("Hello")], segments: vec![protocol::Segment::text("Hello")],
history: vec![annotated(Item::user_message("Hello"))],
}, },
LogEntry::AssistantItem { LogEntry::AnnotatedAssistantItem {
ts: 3000, ts: 3000,
item: Item::assistant_message("Hi there!").into(), entry: annotated(Item::assistant_message("Hi there!")),
}, },
LogEntry::TurnEnd { LogEntry::TurnEnd {
ts: 3100, ts: 3100,
@@ -79,14 +93,14 @@ fn create_segment_writes_all_entries() {
let sid = new_session_id(); let sid = new_session_id();
let segid = new_segment_id(); let segid = new_segment_id();
let entries = [LogEntry::SegmentStart { let entries = [LogEntry::AnnotatedSegmentStart {
ts: 1000, ts: 1000,
session_id: sid, session_id: sid,
system_prompt: None, system_prompt: None,
config: RequestConfig::default(), config: RequestConfig::default(),
history: vec![ history: vec![
Item::user_message("seed").into(), annotated(Item::user_message("seed")),
Item::assistant_message("ok").into(), annotated(Item::assistant_message("ok")),
], ],
forked_from: None, forked_from: None,
compacted_from: None, compacted_from: None,
@@ -205,7 +219,7 @@ fn read_entry_count_matches_append_tally() {
let segid = new_segment_id(); let segid = new_segment_id();
let entries = [ let entries = [
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
ts: 1000, ts: 1000,
session_id: sid, session_id: sid,
system_prompt: None, system_prompt: None,
@@ -214,10 +228,11 @@ fn read_entry_count_matches_append_tally() {
forked_from: None, forked_from: None,
compacted_from: None, compacted_from: None,
}, },
LogEntry::UserInput { LogEntry::AnnotatedUserInput {
ts: 2000, ts: 2000,
extensions: vec![], extensions: vec![],
segments: vec![protocol::Segment::text("Hello")], segments: vec![protocol::Segment::text("Hello")],
history: vec![annotated(Item::user_message("Hello"))],
}, },
]; ];
@@ -254,10 +269,11 @@ fn unterminated_utf8_tail_is_ignored_and_replaced_on_append() {
assert_eq!(store.read_all(sid, segid).unwrap().len(), 1); assert_eq!(store.read_all(sid, segid).unwrap().len(), 1);
assert_eq!(store.read_entry_count(sid, segid).unwrap(), 1); assert_eq!(store.read_entry_count(sid, segid).unwrap(), 1);
let next = LogEntry::UserInput { let next = LogEntry::AnnotatedUserInput {
ts: 2, ts: 2,
extensions: vec![], extensions: vec![],
segments: vec![protocol::Segment::text("recovered")], segments: vec![protocol::Segment::text("recovered")],
history: vec![annotated(Item::user_message("recovered"))],
}; };
store.append(sid, segid, &next).unwrap(); store.append(sid, segid, &next).unwrap();
+67 -26
View File
@@ -16,6 +16,21 @@ use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state};
// Helpers // Helpers
// ============================================================================= // =============================================================================
fn annotated(items: &[Item]) -> Vec<session_store::LoggedHistoryEntry> {
items
.iter()
.cloned()
.map(|item| session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::from(item),
metadata: session_store::LoggedSessionHistoryMetadata {
entry_id: session_store::LoggedSessionHistoryEntryId::new(),
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
})
.collect()
}
fn simple_text_events() -> Vec<Event> { fn simple_text_events() -> Vec<Event> {
vec![ vec![
Event::text_block_start(0), Event::text_block_start(0),
@@ -144,6 +159,7 @@ async fn run_and_persist(
session_id, session_id,
segment_id, segment_id,
vec![protocol::Segment::text(input)], vec![protocol::Segment::text(input)],
annotated(&[Item::user_message(input)]),
) )
.unwrap(); .unwrap();
@@ -154,8 +170,8 @@ async fn run_and_persist(
worker.engine = locked.unlock(); worker.engine = locked.unlock();
let projected = worker.history(); let projected = worker.history();
let new_items = &projected[history_before..]; let new_items = annotated(&projected[history_before..]);
session_store::save_delta(store, session_id, segment_id, new_items).unwrap(); session_store::save_delta(store, session_id, segment_id, &new_items).unwrap();
session_store::save_turn_end(store, session_id, segment_id, worker.turn_count()).unwrap(); session_store::save_turn_end(store, session_id, segment_id, worker.turn_count()).unwrap();
match &result { match &result {
@@ -219,7 +235,7 @@ async fn session_run_logs_entries() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: &worker.history(), history: annotated(&worker.history()),
}, },
) )
.unwrap(); .unwrap();
@@ -237,7 +253,10 @@ async fn session_run_logs_entries() {
); );
// First entry is SegmentStart // First entry is SegmentStart
assert!(matches!(&entries[0], LogEntry::SegmentStart { .. })); assert!(matches!(
&entries[0],
LogEntry::AnnotatedSegmentStart { .. }
));
// Has a RunCompleted with Finished // Has a RunCompleted with Finished
let has_finished = entries.iter().any(|e| { let has_finished = entries.iter().any(|e| {
@@ -264,7 +283,7 @@ async fn session_restore_round_trip() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: &worker.history(), history: annotated(&worker.history()),
}, },
) )
.unwrap(); .unwrap();
@@ -303,7 +322,7 @@ async fn session_run_with_tool_call() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: &worker.history(), history: annotated(&worker.history()),
}, },
) )
.unwrap(); .unwrap();
@@ -314,12 +333,12 @@ async fn session_run_with_tool_call() {
let has_tool_results = entries let has_tool_results = entries
.iter() .iter()
.any(|e| matches!(e, LogEntry::ToolResult { .. })); .any(|e| matches!(e, LogEntry::AnnotatedToolResult { .. }));
assert!(has_tool_results, "should have ToolResult entry"); assert!(has_tool_results, "should have ToolResult entry");
let has_assistant = entries let has_assistant = entries
.iter() .iter()
.any(|e| matches!(e, LogEntry::AssistantItem { .. })); .any(|e| matches!(e, LogEntry::AnnotatedAssistantItem { .. }));
assert!(has_assistant, "should have AssistantItem entry"); assert!(has_assistant, "should have AssistantItem entry");
} }
@@ -338,7 +357,7 @@ async fn session_resume_after_pause() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: &worker.history(), history: annotated(&worker.history()),
}, },
) )
.unwrap(); .unwrap();
@@ -377,7 +396,7 @@ async fn session_fork_creates_new_session() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: &worker.history(), history: annotated(&worker.history()),
}, },
) )
.unwrap(); .unwrap();
@@ -390,7 +409,7 @@ async fn session_fork_creates_new_session() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: &worker.history(), history: annotated(&worker.history()),
}, },
) )
.unwrap(); .unwrap();
@@ -399,7 +418,10 @@ async fn session_fork_creates_new_session() {
// Fork should have a SegmentStart with the current history // Fork should have a SegmentStart with the current history
let fork_entries = store.read_all(fork_sid, fork_segid).unwrap(); let fork_entries = store.read_all(fork_sid, fork_segid).unwrap();
assert_eq!(fork_entries.len(), 1); assert_eq!(fork_entries.len(), 1);
assert!(matches!(&fork_entries[0], LogEntry::SegmentStart { .. })); assert!(matches!(
&fork_entries[0],
LogEntry::AnnotatedSegmentStart { .. }
));
let fork_state = collect_state(&fork_entries); let fork_state = collect_state(&fork_entries);
assert_eq!(fork_state.session_id, Some(fork_sid)); assert_eq!(fork_state.session_id, Some(fork_sid));
@@ -418,7 +440,7 @@ async fn session_fork_at_truncates_within_session() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: &worker.history(), history: annotated(&worker.history()),
}, },
) )
.unwrap(); .unwrap();
@@ -445,6 +467,23 @@ async fn session_fork_at_truncates_within_session() {
.expect("source segment has the matching TurnEnd"); .expect("source segment has the matching TurnEnd");
let source_state_at_fork = collect_state(&all_entries[..=turn_end_pos]); let source_state_at_fork = collect_state(&all_entries[..=turn_end_pos]);
assert_eq!(fork_state.history.len(), source_state_at_fork.history.len()); assert_eq!(fork_state.history.len(), source_state_at_fork.history.len());
assert_eq!(
fork_state.annotated_history, source_state_at_fork.annotated_history,
"fork_at must preserve every retained history entry identity and provenance",
);
assert!(fork_state.annotated_history.iter().all(|entry| {
!entry.metadata.entry_id.0.is_empty()
&& matches!(
entry.metadata.origin,
session_store::LoggedSessionHistoryOrigin::LegacyUnknown
| session_store::LoggedSessionHistoryOrigin::HumanInput { .. }
| session_store::LoggedSessionHistoryOrigin::WorkerInput { .. }
| session_store::LoggedSessionHistoryOrigin::BackendInstruction { .. }
| session_store::LoggedSessionHistoryOrigin::ModelOutput { .. }
| session_store::LoggedSessionHistoryOrigin::ToolOutput { .. }
| session_store::LoggedSessionHistoryOrigin::DerivedSummary
)
}));
// list_segments should show both source and fork in the same Session. // list_segments should show both source and fork in the same Session.
let segs = store.list_segments(sid).unwrap(); let segs = store.list_segments(sid).unwrap();
@@ -463,7 +502,7 @@ async fn session_config_changed_logged() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: &worker.history(), history: annotated(&worker.history()),
}, },
) )
.unwrap(); .unwrap();
@@ -496,7 +535,7 @@ async fn session_auto_forks_on_conflict() {
SegmentStartState { SegmentStartState {
system_prompt: worker_a.get_system_prompt(), system_prompt: worker_a.get_system_prompt(),
config: worker_a.request_config(), config: worker_a.request_config(),
history: &worker_a.history(), history: annotated(&worker_a.history()),
}, },
) )
.unwrap(); .unwrap();
@@ -505,12 +544,14 @@ async fn session_auto_forks_on_conflict() {
let mut entries_written: usize = 1; let mut entries_written: usize = 1;
// Simulate another Worker writing to the same segment behind our back. // Simulate another Worker writing to the same segment behind our back.
let extra_entry = LogEntry::UserInput { session_store::save_user_input(
ts: 9999, &store,
extensions: vec![], sid,
segments: vec![protocol::Segment::text("Interloper")], original_segid,
}; vec![protocol::Segment::text("Interloper")],
store.append(sid, original_segid, &extra_entry).unwrap(); annotated(&[Item::user_message("Interloper")]),
)
.unwrap();
// Now the on-disk count exceeds our tally — ensure_head_or_fork should auto-fork. // Now the on-disk count exceeds our tally — ensure_head_or_fork should auto-fork.
session_store::ensure_head_or_fork( session_store::ensure_head_or_fork(
@@ -522,7 +563,7 @@ async fn session_auto_forks_on_conflict() {
SegmentStartState { SegmentStartState {
system_prompt: worker_a.get_system_prompt(), system_prompt: worker_a.get_system_prompt(),
config: worker_a.request_config(), config: worker_a.request_config(),
history: &worker_a.history(), history: annotated(&worker_a.history()),
}, },
) )
.unwrap(); .unwrap();
@@ -543,7 +584,7 @@ async fn session_auto_forks_on_conflict() {
// The new segment records its lineage forward via forked_from; the // The new segment records its lineage forward via forked_from; the
// source segment is left immutable (no terminal marker written back). // source segment is left immutable (no terminal marker written back).
match &fork_entries[0] { match &fork_entries[0] {
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
forked_from: Some(origin), forked_from: Some(origin),
.. ..
} => { } => {
@@ -563,7 +604,7 @@ async fn session_auto_forks_on_conflict() {
); );
let has_interloper = original_entries let has_interloper = original_entries
.iter() .iter()
.any(|e| matches!(e, LogEntry::UserInput { .. })); .any(|e| matches!(e, LogEntry::AnnotatedUserInput { .. }));
assert!(has_interloper); assert!(has_interloper);
} }
@@ -581,7 +622,7 @@ async fn nested_past_fork_leaves_ancestors_immutable() {
SegmentStartState { SegmentStartState {
system_prompt: worker.get_system_prompt(), system_prompt: worker.get_system_prompt(),
config: worker.request_config(), config: worker.request_config(),
history: &worker.history(), history: annotated(&worker.history()),
}, },
) )
.unwrap(); .unwrap();
@@ -618,7 +659,7 @@ async fn nested_past_fork_leaves_ancestors_immutable() {
// fork2's lineage points at fork1, not the root. // fork2's lineage points at fork1, not the root.
match &store.read_all(sid, fork2).unwrap()[0] { match &store.read_all(sid, fork2).unwrap()[0] {
LogEntry::SegmentStart { LogEntry::AnnotatedSegmentStart {
forked_from: Some(origin), forked_from: Some(origin),
.. ..
} => assert_eq!(origin.segment_id, fork1), } => assert_eq!(origin.segment_id, fork1),
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "standalone"
description = "In-process standalone Worker host"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
agen.workspace = true
client.workspace = true
fs4.workspace = true
manifest.workspace = true
protocol.workspace = true
serde.workspace = true
serde_json.workspace = true
session-store.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["rt", "sync", "time"] }
uuid = { workspace = true, features = ["v7"] }
worker.workspace = true
[dev-dependencies]
async-trait.workspace = true
futures.workspace = true
tempfile.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
+544
View File
@@ -0,0 +1,544 @@
use std::path::PathBuf;
use std::time::Duration;
use agen::llm_client::client::LlmClient;
use client::Client;
use client::transport::in_process::{Peer as InProcessPeer, Socket as InProcessSocket};
use manifest::ScopeRule;
use protocol::stream::{decode_method, encode_event};
use protocol::{Event, Method, WorkerId};
use session_store::{
CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore,
};
use thiserror::Error;
use worker::bootstrap::{
WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, bash_output_dir_for_worker_id,
};
use worker::controller::WorkerControllerTransport;
use worker::ipc::protocol_session::{
WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event,
subscribe_worker_protocol_session,
};
use worker::runtime::worker_allocation::ScopeLockError;
use worker::{BootstrappedWorker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext};
use crate::launch::ResolvedStandaloneLaunch;
use crate::store::{
StaleLeasePolicy, StandaloneShutdownReason, StandaloneStoreError, StandaloneWorkerLease,
StandaloneWorkerRecord, StandaloneWorkerStore,
};
const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
type StandaloneBackingStore = CombinedStore<FsStore, FsWorkerStore>;
/// One client-owned top-level Worker and its standalone Worker authority.
///
/// The host deliberately exposes the existing typed Worker protocol rather than owning an
/// HTTP/WebSocket server or creating Runtime/Workspace/Ticket/Workdir domain records.
pub struct StandaloneHost {
handle: worker::WorkerHandle,
shutdown: Option<worker::controller::ShutdownReceiver>,
shutdown_timeout: Duration,
store: StandaloneWorkerStore,
worker_store: FsWorkerStore,
record: StandaloneWorkerRecord,
lease: Option<StandaloneWorkerLease>,
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum StandaloneStartupError {
#[error("the standalone state store could not be opened or validated")]
StateStore,
#[error("the standalone Worker is already active")]
WorkerActive,
#[error("the standalone Worker lease cannot be observed safely; recovery is rejected")]
LeaseLivenessUnknown,
#[error("the standalone Worker working directory is unavailable or changed")]
WorkingDirectoryUnavailable,
#[error(
"requested scope `{}` conflicts with worker allocation `{competitor}` rule `{}`",
requested_rule.target.display(),
competitor_rule.target.display()
)]
ScopeConflict {
competitor: String,
requested_rule: ScopeRule,
competitor_rule: ScopeRule,
},
#[error("the resolved Worker configuration or persisted history is invalid")]
WorkerConfiguration,
#[error("the configured model provider is unavailable")]
ModelProvider,
#[error("the fixed standalone feature composition could not be installed")]
FeatureComposition,
#[error("the in-process Worker controller could not start")]
Controller,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum StandaloneShutdownError {
#[error("the standalone Worker did not stop before the shutdown deadline")]
DeadlineExceeded,
#[error("the standalone Worker shutdown confirmation was lost")]
ConfirmationLost,
#[error("the standalone Worker final state could not be committed")]
StateStore,
}
impl StandaloneHost {
pub async fn start(launch: ResolvedStandaloneLaunch) -> Result<Self, StandaloneStartupError> {
Self::start_with_optional_model_client(launch, None).await
}
pub async fn start_with_model_client<C>(
launch: ResolvedStandaloneLaunch,
model_client: C,
) -> Result<Self, StandaloneStartupError>
where
C: LlmClient + 'static,
{
Self::start_with_optional_model_client(launch, Some(Box::new(model_client))).await
}
async fn start_with_optional_model_client(
launch: ResolvedStandaloneLaunch,
model_client: Option<Box<dyn LlmClient>>,
) -> Result<Self, StandaloneStartupError> {
let store =
StandaloneWorkerStore::open(&launch.state_dir).map_err(classify_store_startup_error)?;
let allocation = store
.allocate(&launch.cwd, StaleLeasePolicy::Reject)
.map_err(classify_store_startup_error)?;
let worker_id = allocation.worker_id();
// WorkerId is the stable identity. The current Worker store remains
// name-keyed, so keep its derived storage key separate from the
// user-facing profile name.
let manifest = launch.profile.manifest.clone();
let storage_key = format!("standalone-{worker_id}");
let mut bootstrap_manifest = manifest.clone();
bootstrap_manifest.worker.name = storage_key.clone();
let (backing_store, worker_store) = match backing_store(&store, worker_id) {
Ok(stores) => stores,
Err(error) => {
let _ = store.abandon_allocation(allocation);
return Err(error);
}
};
let filesystem_authority =
WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone());
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
let runtime_base = store.runtime_dir(worker_id);
let bash_output_dir = bash_output_dir_for_worker_id(worker_id);
let mut bootstrap = WorkerBootstrap::new(
bootstrap_manifest,
backing_store,
launch.prompt_catalog,
workspace_context,
filesystem_authority,
WorkerBootstrapLayout::Direct {
runtime_base,
bash_output_dir,
},
WorkerControllerTransport::InProcess,
);
if let Some(model_client) = model_client {
bootstrap = bootstrap.with_model_client(model_client);
}
let started = match bootstrap.start().await {
Ok(started) => started,
Err(error) => {
let _ = store.abandon_allocation(allocation);
return Err(classify_startup_error(error));
}
};
let active = match active_pointer(&worker_store, &storage_key) {
Ok(active) => active,
Err(error) => {
stop_started_worker(started).await;
let _ = store.abandon_allocation(allocation);
return Err(error);
}
};
let record = match store.commit_created(
&allocation,
manifest,
storage_key,
active.session_id,
active.segment_id,
) {
Ok(record) => record,
Err(_) => {
stop_started_worker(started).await;
let _ = store.abandon_allocation(allocation);
return Err(StandaloneStartupError::StateStore);
}
};
Ok(Self::from_started(
started,
store,
worker_store,
record,
allocation.into_lease(),
))
}
pub async fn restore(
state_dir: PathBuf,
worker_id: WorkerId,
) -> Result<Self, StandaloneStartupError> {
Self::restore_with_optional_model_client(state_dir, worker_id, None).await
}
pub async fn restore_with_model_client<C>(
state_dir: PathBuf,
worker_id: WorkerId,
model_client: C,
) -> Result<Self, StandaloneStartupError>
where
C: LlmClient + 'static,
{
Self::restore_with_optional_model_client(state_dir, worker_id, Some(Box::new(model_client)))
.await
}
async fn restore_with_optional_model_client(
state_dir: PathBuf,
worker_id: WorkerId,
model_client: Option<Box<dyn LlmClient>>,
) -> Result<Self, StandaloneStartupError> {
let store = StandaloneWorkerStore::open(state_dir).map_err(classify_store_startup_error)?;
let record = store
.load(worker_id)
.map_err(classify_store_startup_error)?;
record.cwd.verify().map_err(classify_store_startup_error)?;
let lease = store
.acquire_lease(worker_id, StaleLeasePolicy::Recover)
.map_err(classify_store_startup_error)?;
let (backing_store, worker_store) = backing_store(&store, worker_id)?;
let storage_key = record.storage_key.clone();
let mut manifest = record.manifest.clone();
manifest.worker.name = storage_key.clone();
let filesystem_authority = WorkerFilesystemAuthority::local(
record.cwd.canonical_path.clone(),
record.cwd.canonical_path.clone(),
);
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
let runtime_base = store.runtime_dir(worker_id);
let bash_output_dir = bash_output_dir_for_worker_id(worker_id);
let mut bootstrap = WorkerBootstrap::new(
manifest,
backing_store,
worker::PromptCatalogSource::builtins_only(),
workspace_context,
filesystem_authority,
WorkerBootstrapLayout::Direct {
runtime_base,
bash_output_dir,
},
WorkerControllerTransport::InProcess,
);
if let Some(model_client) = model_client {
bootstrap = bootstrap.with_model_client(model_client);
}
let prepared = bootstrap
.prepare_restored(&storage_key)
.await
.map_err(classify_startup_error)?;
let started = prepared.start().await.map_err(classify_startup_error)?;
let active = match active_pointer(&worker_store, &storage_key) {
Ok(active) => active,
Err(error) => {
stop_started_worker(started).await;
return Err(error);
}
};
let record =
match store.update_active_pointer(&record, active.session_id, active.segment_id) {
Ok(record) => record,
Err(_) => {
stop_started_worker(started).await;
lease.retain();
return Err(StandaloneStartupError::StateStore);
}
};
Ok(Self::from_started(
started,
store,
worker_store,
record,
lease,
))
}
fn from_started(
started: BootstrappedWorker,
store: StandaloneWorkerStore,
worker_store: FsWorkerStore,
record: StandaloneWorkerRecord,
lease: StandaloneWorkerLease,
) -> Self {
Self {
handle: started.handle,
shutdown: Some(started.shutdown),
shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
store,
worker_store,
record,
lease: Some(lease),
}
}
#[must_use]
pub fn worker_id(&self) -> WorkerId {
self.record.worker_id
}
#[must_use]
pub fn record(&self) -> &StandaloneWorkerRecord {
&self.record
}
/// Open one complete client-side Worker protocol session.
///
/// Working events, committed session entries, alert snapshots, and the
/// initial history snapshot are merged behind the client boundary.
pub fn connect(&self) -> Client<InProcessSocket> {
let streams = subscribe_worker_protocol_session(&self.handle);
let (socket, peer) = InProcessSocket::pair();
tokio::spawn(run_protocol_session(self.handle.clone(), streams, peer));
Client::new(socket)
}
pub fn with_shutdown_timeout(mut self, shutdown_timeout: Duration) -> Self {
self.shutdown_timeout = shutdown_timeout;
self
}
pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> {
let _ = self.handle.send(Method::Shutdown).await;
let Some(shutdown) = self.shutdown.take() else {
self.retain_lease();
return Err(StandaloneShutdownError::ConfirmationLost);
};
match tokio::time::timeout(self.shutdown_timeout, shutdown).await {
Ok(Ok(())) => {}
Ok(Err(_)) => {
self.retain_lease();
return Err(StandaloneShutdownError::ConfirmationLost);
}
Err(_) => {
self.retain_lease();
return Err(StandaloneShutdownError::DeadlineExceeded);
}
}
let active = match active_pointer(&self.worker_store, &self.record.storage_key) {
Ok(active) => active,
Err(_) => {
self.retain_lease();
return Err(StandaloneShutdownError::StateStore);
}
};
if self
.store
.mark_stopped(
&self.record,
active.session_id,
active.segment_id,
StandaloneShutdownReason::UserExit,
)
.is_err()
{
self.retain_lease();
return Err(StandaloneShutdownError::StateStore);
}
if let Some(lease) = self.lease.take() {
lease
.release()
.map_err(|_| StandaloneShutdownError::StateStore)?;
}
Ok(())
}
fn retain_lease(&mut self) {
if let Some(lease) = self.lease.take() {
lease.retain();
}
}
}
async fn run_protocol_session(
handle: worker::WorkerHandle,
streams: WorkerProtocolSessionStreams,
mut peer: InProcessPeer,
) {
let WorkerProtocolSessionStreams {
snapshot_event,
mut log_entries,
alert_snapshot,
mut events,
} = streams;
if !send_protocol_snapshot(&peer, alert_snapshot, snapshot_event).await {
return;
}
loop {
tokio::select! {
message = peer.next() => {
let Some(message) = message else {
return;
};
let Ok(method) = decode_method(&message) else {
return;
};
if let Some(event) = dispatch_worker_protocol_method(&handle, method).await
&& !send_protocol_event(&peer, event).await
{
return;
}
}
event = events.recv() => {
match event {
Ok(event) => {
if !send_protocol_event(&peer, event).await {
return;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
let replacement = subscribe_worker_protocol_session(&handle);
let WorkerProtocolSessionStreams {
snapshot_event,
log_entries: replacement_log_entries,
alert_snapshot,
events: replacement_events,
} = replacement;
log_entries = replacement_log_entries;
events = replacement_events;
if !send_protocol_snapshot(&peer, alert_snapshot, snapshot_event).await {
return;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
}
}
entry = log_entries.recv() => {
match entry {
Ok(entry) => {
if let Some(event) = live_log_entry_event(entry)
&& !send_protocol_event(&peer, event).await
{
return;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
let replacement = subscribe_worker_protocol_session(&handle);
let WorkerProtocolSessionStreams {
snapshot_event,
log_entries: replacement_log_entries,
alert_snapshot,
events: replacement_events,
} = replacement;
log_entries = replacement_log_entries;
events = replacement_events;
if !send_protocol_snapshot(&peer, alert_snapshot, snapshot_event).await {
return;
}
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
}
}
}
}
}
async fn send_protocol_snapshot(
peer: &InProcessPeer,
alert_snapshot: Vec<protocol::Alert>,
snapshot_event: Event,
) -> bool {
for alert in alert_snapshot {
if !send_protocol_event(peer, Event::Alert(alert)).await {
return false;
}
}
send_protocol_event(peer, snapshot_event).await
}
async fn send_protocol_event(peer: &InProcessPeer, event: Event) -> bool {
let Ok(message) = encode_event(&event) else {
return false;
};
peer.send(message).await.is_ok()
}
fn backing_store(
store: &StandaloneWorkerStore,
worker_id: WorkerId,
) -> Result<(StandaloneBackingStore, FsWorkerStore), StandaloneStartupError> {
let session_store = FsStore::new(store.sessions_dir(worker_id))
.map_err(|_| StandaloneStartupError::StateStore)?;
let worker_store = FsWorkerStore::new(store.worker_metadata_dir(worker_id))
.map_err(|_| StandaloneStartupError::StateStore)?;
Ok((
CombinedStore::new(session_store, worker_store.clone()),
worker_store,
))
}
fn active_pointer(
worker_store: &FsWorkerStore,
storage_key: &str,
) -> Result<WorkerActiveSegmentRef, StandaloneStartupError> {
worker_store
.read_by_name(storage_key)
.map_err(|_| StandaloneStartupError::StateStore)?
.and_then(|metadata| metadata.active)
.ok_or(StandaloneStartupError::StateStore)
}
async fn stop_started_worker(started: BootstrappedWorker) {
let _ = started.handle.send(Method::Shutdown).await;
let _ = tokio::time::timeout(Duration::from_secs(2), started.shutdown).await;
}
fn classify_store_startup_error(error: StandaloneStoreError) -> StandaloneStartupError {
match error {
StandaloneStoreError::WorkerLeased(_) => StandaloneStartupError::WorkerActive,
StandaloneStoreError::LeaseLivenessUnknown(_) => {
StandaloneStartupError::LeaseLivenessUnknown
}
StandaloneStoreError::CwdUnavailable(_)
| StandaloneStoreError::CwdNotDirectory
| StandaloneStoreError::CwdIdentityMismatch => {
StandaloneStartupError::WorkingDirectoryUnavailable
}
_ => StandaloneStartupError::StateStore,
}
}
fn classify_startup_error(error: WorkerBootstrapError) -> StandaloneStartupError {
match error {
WorkerBootstrapError::Worker(WorkerError::ScopeLock(ScopeLockError::WriteConflict {
competitor,
rule,
competitor_rule,
})) => StandaloneStartupError::ScopeConflict {
competitor,
requested_rule: rule,
competitor_rule,
},
WorkerBootstrapError::Worker(WorkerError::Provider(_)) => {
StandaloneStartupError::ModelProvider
}
WorkerBootstrapError::Worker(_) => StandaloneStartupError::WorkerConfiguration,
WorkerBootstrapError::Controller { source, .. }
if source.kind() == std::io::ErrorKind::Other =>
{
StandaloneStartupError::FeatureComposition
}
WorkerBootstrapError::Controller { .. } => StandaloneStartupError::Controller,
}
}
+86
View File
@@ -0,0 +1,86 @@
use std::path::{Path, PathBuf};
use manifest::{
ProfileExecutionTarget, ProfileResolveOptions, ProfileResolver, ProfileSelector,
ResolvedProfile,
};
use thiserror::Error;
use worker::PromptCatalogSource;
/// Process launch input resolved before any Worker/session side effect occurs.
#[derive(Debug, Clone)]
pub struct StandaloneLaunchConfig {
pub cwd: PathBuf,
pub state_dir: PathBuf,
pub profile: ProfileSelector,
pub worker_name: String,
}
pub struct ResolvedStandaloneLaunch {
pub cwd: PathBuf,
pub state_dir: PathBuf,
pub profile: ResolvedProfile,
pub prompt_catalog: PromptCatalogSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum StandaloneLaunchError {
#[error("the standalone working directory is unavailable")]
WorkingDirectoryUnavailable,
#[error("path-based profiles are not standalone launch authority")]
PathProfileUnsupported,
#[error("the standalone profile could not be resolved")]
ProfileResolutionFailed,
}
impl StandaloneLaunchConfig {
pub fn new(
cwd: impl Into<PathBuf>,
state_dir: impl Into<PathBuf>,
profile: ProfileSelector,
worker_name: impl Into<String>,
) -> Self {
Self {
cwd: cwd.into(),
state_dir: state_dir.into(),
profile,
worker_name: worker_name.into(),
}
}
/// Resolve only built-in/XDG profile authority and bind standalone scope
/// to the canonical process cwd. Repository-local profile discovery is
/// deliberately not part of this path.
pub fn resolve(self) -> Result<ResolvedStandaloneLaunch, StandaloneLaunchError> {
if matches!(self.profile, ProfileSelector::Path { .. }) {
return Err(StandaloneLaunchError::PathProfileUnsupported);
}
let cwd = canonical_directory(&self.cwd)?;
let profile = ProfileResolver::new()
.with_workspace_base(&cwd)
.resolve_for_target(
&self.profile,
ProfileResolveOptions {
worker_name: Some(self.worker_name),
},
ProfileExecutionTarget::Standalone,
)
.map_err(|_| StandaloneLaunchError::ProfileResolutionFailed)?;
Ok(ResolvedStandaloneLaunch {
cwd,
state_dir: self.state_dir,
profile,
prompt_catalog: PromptCatalogSource::builtins_only(),
})
}
}
fn canonical_directory(path: &Path) -> Result<PathBuf, StandaloneLaunchError> {
let path = std::fs::canonicalize(path)
.map_err(|_| StandaloneLaunchError::WorkingDirectoryUnavailable)?;
if !path.is_dir() {
return Err(StandaloneLaunchError::WorkingDirectoryUnavailable);
}
Ok(path)
}
+17
View File
@@ -0,0 +1,17 @@
//! In-process standalone host for one top-level Yoi Worker.
//!
//! The crate composes existing `worker`, `manifest`, `session-store`, and
//! `workdir` contracts. It intentionally owns no TUI, Runtime, Workspace
//! Server, HTTP, WebSocket, subprocess Worker, or alternative execution path.
pub mod host;
pub mod launch;
pub mod store;
pub use host::{StandaloneHost, StandaloneShutdownError, StandaloneStartupError};
pub use launch::{ResolvedStandaloneLaunch, StandaloneLaunchConfig, StandaloneLaunchError};
pub use protocol::WorkerId;
pub use store::{
StaleLeasePolicy, StandaloneCwdIdentity, StandaloneListScope, StandaloneShutdownReason,
StandaloneStoreError, StandaloneWorkerRecord, StandaloneWorkerStatus, StandaloneWorkerStore,
};
+741
View File
@@ -0,0 +1,741 @@
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use fs4::fs_std::FileExt;
use manifest::WorkerManifest;
use protocol::WorkerId;
use serde::{Deserialize, Serialize};
use session_store::{SegmentId, SessionId};
use thiserror::Error;
use uuid::Uuid;
const RECORD_FILE: &str = "record.json";
const COMMIT_MARKER: &str = "commit.pending";
const LEASE_FILE: &str = "lease.json";
const LEASE_LOCK_FILE: &str = "lease.lock";
const SESSIONS_DIR: &str = "sessions";
const WORKER_DIR: &str = "worker";
const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StandaloneCwdIdentity {
pub canonical_path: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inode: Option<u64>,
}
impl StandaloneCwdIdentity {
pub fn capture(path: impl AsRef<Path>) -> Result<Self, StandaloneStoreError> {
let canonical_path =
fs::canonicalize(path).map_err(StandaloneStoreError::CwdUnavailable)?;
let metadata =
fs::metadata(&canonical_path).map_err(StandaloneStoreError::CwdUnavailable)?;
if !metadata.is_dir() {
return Err(StandaloneStoreError::CwdNotDirectory);
}
#[cfg(unix)]
let (device, inode) = {
use std::os::unix::fs::MetadataExt;
(Some(metadata.dev()), Some(metadata.ino()))
};
#[cfg(not(unix))]
let (device, inode) = (None, None);
Ok(Self {
canonical_path,
device,
inode,
})
}
pub fn verify(&self) -> Result<PathBuf, StandaloneStoreError> {
let current = Self::capture(&self.canonical_path)?;
if current != *self {
return Err(StandaloneStoreError::CwdIdentityMismatch);
}
Ok(current.canonical_path)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StandaloneWorkerStatus {
Active,
Stopped,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StandaloneShutdownReason {
UserExit,
StartupFailed,
ControllerError,
ProcessInterrupted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StandaloneWorkerRecord {
pub schema_version: u32,
pub revision: u64,
pub worker_id: WorkerId,
/// User-facing Worker name resolved from the profile.
pub worker_name: String,
/// Internal key used by the current name-keyed Worker store.
pub storage_key: String,
pub cwd: StandaloneCwdIdentity,
pub manifest: WorkerManifest,
pub active_session_id: SessionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_segment_id: Option<SegmentId>,
pub status: StandaloneWorkerStatus,
pub created_at_unix_ms: u64,
pub updated_at_unix_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shutdown_reason: Option<StandaloneShutdownReason>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StandaloneListScope {
CurrentCwd,
All,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StaleLeasePolicy {
Reject,
Recover,
}
#[derive(Debug, Clone)]
pub struct StandaloneWorkerStore {
root: PathBuf,
}
impl StandaloneWorkerStore {
pub fn open(root: impl Into<PathBuf>) -> Result<Self, StandaloneStoreError> {
let root = root.into();
fs::create_dir_all(&root).map_err(StandaloneStoreError::Io)?;
if !fs::metadata(&root)
.map_err(StandaloneStoreError::Io)?
.is_dir()
{
return Err(StandaloneStoreError::NotDirectory);
}
Ok(Self { root })
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
pub fn allocate(
&self,
cwd: impl AsRef<Path>,
policy: StaleLeasePolicy,
) -> Result<StandaloneWorkerAllocation, StandaloneStoreError> {
let worker_id = WorkerId::now_v7();
let cwd = StandaloneCwdIdentity::capture(cwd)?;
let dir = self.worker_dir(worker_id);
fs::create_dir(&dir).map_err(StandaloneStoreError::Io)?;
fs::create_dir(dir.join(SESSIONS_DIR)).map_err(StandaloneStoreError::Io)?;
fs::create_dir(dir.join(WORKER_DIR)).map_err(StandaloneStoreError::Io)?;
let lease = self.acquire_lease(worker_id, policy)?;
Ok(StandaloneWorkerAllocation {
worker_id,
cwd,
lease,
})
}
pub fn commit_created(
&self,
allocation: &StandaloneWorkerAllocation,
manifest: WorkerManifest,
storage_key: String,
active_session_id: SessionId,
active_segment_id: Option<SegmentId>,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let now = now_unix_ms()?;
let record = StandaloneWorkerRecord {
schema_version: SCHEMA_VERSION,
revision: 1,
worker_id: allocation.worker_id,
worker_name: manifest.worker.name.clone(),
storage_key,
cwd: allocation.cwd.clone(),
manifest,
active_session_id,
active_segment_id,
status: StandaloneWorkerStatus::Active,
created_at_unix_ms: now,
updated_at_unix_ms: now,
shutdown_reason: None,
};
self.commit_record(None, &record)?;
Ok(record)
}
pub fn load(&self, id: WorkerId) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let dir = self.worker_dir(id);
if dir.join(COMMIT_MARKER).exists() {
return Err(StandaloneStoreError::IncompleteCommit(id));
}
let bytes = fs::read(dir.join(RECORD_FILE)).map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
StandaloneStoreError::WorkerNotFound(id)
} else {
StandaloneStoreError::Io(error)
}
})?;
let record: StandaloneWorkerRecord = serde_json::from_slice(&bytes)
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })?;
if record.schema_version > SCHEMA_VERSION {
return Err(StandaloneStoreError::NewerSchema {
id,
found: record.schema_version,
supported: SCHEMA_VERSION,
});
}
if record.schema_version != SCHEMA_VERSION || record.worker_id != id {
return Err(StandaloneStoreError::InvalidRecord(id));
}
Ok(record)
}
pub fn list(
&self,
cwd: impl AsRef<Path>,
scope: StandaloneListScope,
limit: usize,
) -> Result<Vec<StandaloneWorkerRecord>, StandaloneStoreError> {
let current_cwd = (scope == StandaloneListScope::CurrentCwd)
.then(|| StandaloneCwdIdentity::capture(cwd))
.transpose()?;
let mut records = Vec::new();
for entry in fs::read_dir(&self.root).map_err(StandaloneStoreError::Io)? {
let entry = entry.map_err(StandaloneStoreError::Io)?;
if !entry
.file_type()
.map_err(StandaloneStoreError::Io)?
.is_dir()
{
continue;
}
let Ok(id) = entry.file_name().to_string_lossy().parse() else {
continue;
};
let record = self.load(id)?;
if current_cwd.as_ref().is_none_or(|cwd| &record.cwd == cwd) {
records.push(record);
}
}
records.sort_by(|left, right| {
right
.updated_at_unix_ms
.cmp(&left.updated_at_unix_ms)
.then_with(|| right.worker_id.to_string().cmp(&left.worker_id.to_string()))
});
records.truncate(limit);
Ok(records)
}
pub fn acquire_lease(
&self,
id: WorkerId,
policy: StaleLeasePolicy,
) -> Result<StandaloneWorkerLease, StandaloneStoreError> {
let dir = self.worker_dir(id);
let path = dir.join(LEASE_FILE);
let _guard = LeaseMutationGuard::acquire(&dir)?;
let lease = LeaseRecord::current()?;
loop {
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(mut file) => {
serde_json::to_writer(&mut file, &lease).map_err(StandaloneStoreError::Json)?;
file.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
file.sync_all().map_err(StandaloneStoreError::Io)?;
sync_directory(&dir)?;
return Ok(StandaloneWorkerLease {
path,
lease_id: lease.lease_id,
released: false,
});
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
let existing = read_lease(&path, id)?;
match existing.liveness() {
LeaseLiveness::Live => {
return Err(StandaloneStoreError::WorkerLeased(id));
}
LeaseLiveness::Unknown => {
return Err(StandaloneStoreError::LeaseLivenessUnknown(id));
}
LeaseLiveness::Stale => {}
}
if policy == StaleLeasePolicy::Reject {
return Err(StandaloneStoreError::StaleLease(id));
}
fs::remove_file(&path).map_err(StandaloneStoreError::Io)?;
sync_directory(&dir)?;
}
Err(error) => return Err(StandaloneStoreError::Io(error)),
}
}
}
pub fn update_active_pointer(
&self,
record: &StandaloneWorkerRecord,
active_session_id: SessionId,
active_segment_id: Option<SegmentId>,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let mut next = record.clone();
next.revision = next.revision.saturating_add(1);
next.updated_at_unix_ms = now_unix_ms()?;
next.active_session_id = active_session_id;
next.active_segment_id = active_segment_id;
next.status = StandaloneWorkerStatus::Active;
next.shutdown_reason = None;
self.commit_record(Some(record.revision), &next)?;
Ok(next)
}
pub fn mark_stopped(
&self,
record: &StandaloneWorkerRecord,
active_session_id: SessionId,
active_segment_id: Option<SegmentId>,
reason: StandaloneShutdownReason,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let mut next = record.clone();
next.revision = next.revision.saturating_add(1);
next.updated_at_unix_ms = now_unix_ms()?;
next.active_session_id = active_session_id;
next.active_segment_id = active_segment_id;
next.status = StandaloneWorkerStatus::Stopped;
next.shutdown_reason = Some(reason);
self.commit_record(Some(record.revision), &next)?;
Ok(next)
}
pub fn delete(&self, id: WorkerId) -> Result<(), StandaloneStoreError> {
let record = self.load(id)?;
if record.status != StandaloneWorkerStatus::Stopped {
return Err(StandaloneStoreError::DeleteActive(id));
}
let worker_dir = self.worker_dir(id);
let _guard = LeaseMutationGuard::acquire(&worker_dir)?;
let lease_path = worker_dir.join(LEASE_FILE);
if lease_path.exists() {
let lease = read_lease(&lease_path, id)?;
return Err(match lease.liveness() {
LeaseLiveness::Live => StandaloneStoreError::WorkerLeased(id),
LeaseLiveness::Stale => StandaloneStoreError::StaleLease(id),
LeaseLiveness::Unknown => StandaloneStoreError::LeaseLivenessUnknown(id),
});
}
fs::remove_dir_all(self.worker_dir(id)).map_err(StandaloneStoreError::Io)?;
sync_directory(&self.root)
}
#[must_use]
pub fn sessions_dir(&self, id: WorkerId) -> PathBuf {
self.worker_dir(id).join(SESSIONS_DIR)
}
#[must_use]
pub fn worker_metadata_dir(&self, id: WorkerId) -> PathBuf {
self.worker_dir(id).join(WORKER_DIR)
}
#[must_use]
pub(crate) fn runtime_dir(&self, id: WorkerId) -> PathBuf {
self.worker_dir(id).join("runtime")
}
pub(crate) fn abandon_allocation(
&self,
allocation: StandaloneWorkerAllocation,
) -> Result<(), StandaloneStoreError> {
let worker_id = allocation.worker_id;
allocation.lease.release()?;
fs::remove_dir_all(self.worker_dir(worker_id)).map_err(StandaloneStoreError::Io)?;
sync_directory(&self.root)
}
fn commit_record(
&self,
expected_revision: Option<u64>,
next: &StandaloneWorkerRecord,
) -> Result<(), StandaloneStoreError> {
let dir = self.worker_dir(next.worker_id);
let marker = dir.join(COMMIT_MARKER);
let mut marker_file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&marker)
.map_err(|error| {
if error.kind() == io::ErrorKind::AlreadyExists {
StandaloneStoreError::IncompleteCommit(next.worker_id)
} else {
StandaloneStoreError::Io(error)
}
})?;
writeln!(marker_file, "{}", next.revision).map_err(StandaloneStoreError::Io)?;
marker_file.sync_all().map_err(StandaloneStoreError::Io)?;
sync_directory(&dir)?;
if let Some(expected) = expected_revision {
let current = self.load_record_while_committing(next.worker_id)?;
if current.revision != expected {
let _ = fs::remove_file(&marker);
return Err(StandaloneStoreError::RevisionConflict {
id: next.worker_id,
expected,
found: current.revision,
});
}
}
let temporary = dir.join(format!("record.{}.tmp", Uuid::now_v7()));
let result = (|| {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)
.map_err(StandaloneStoreError::Io)?;
serde_json::to_writer_pretty(&mut file, next).map_err(StandaloneStoreError::Json)?;
file.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
file.sync_all().map_err(StandaloneStoreError::Io)?;
fs::rename(&temporary, dir.join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
sync_directory(&dir)?;
fs::remove_file(&marker).map_err(StandaloneStoreError::Io)?;
sync_directory(&dir)
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
fn load_record_while_committing(
&self,
id: WorkerId,
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let bytes =
fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
serde_json::from_slice(&bytes)
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
}
fn worker_dir(&self, id: WorkerId) -> PathBuf {
self.root.join(id.to_string())
}
}
#[derive(Debug)]
pub struct StandaloneWorkerAllocation {
worker_id: WorkerId,
cwd: StandaloneCwdIdentity,
lease: StandaloneWorkerLease,
}
impl StandaloneWorkerAllocation {
#[must_use]
pub fn worker_id(&self) -> WorkerId {
self.worker_id
}
#[must_use]
pub fn cwd(&self) -> &StandaloneCwdIdentity {
&self.cwd
}
pub fn into_lease(self) -> StandaloneWorkerLease {
self.lease
}
}
#[derive(Debug)]
pub struct StandaloneWorkerLease {
path: PathBuf,
lease_id: Uuid,
released: bool,
}
impl StandaloneWorkerLease {
pub fn release(mut self) -> Result<(), StandaloneStoreError> {
self.release_inner()
}
pub(crate) fn retain(mut self) {
self.released = true;
}
fn release_inner(&mut self) -> Result<(), StandaloneStoreError> {
if self.released {
return Ok(());
}
if self.path.exists() {
let parent = self.path.parent().expect("lease parent");
let _guard = LeaseMutationGuard::acquire(parent)?;
let bytes = fs::read(&self.path).map_err(StandaloneStoreError::Io)?;
let current: LeaseRecord =
serde_json::from_slice(&bytes).map_err(StandaloneStoreError::Json)?;
if current.lease_id != self.lease_id {
return Err(StandaloneStoreError::LeaseOwnershipLost);
}
fs::remove_file(&self.path).map_err(StandaloneStoreError::Io)?;
sync_directory(self.path.parent().expect("lease parent"))?;
}
self.released = true;
Ok(())
}
}
impl Drop for StandaloneWorkerLease {
fn drop(&mut self) {
let _ = self.release_inner();
}
}
struct LeaseMutationGuard {
file: File,
}
impl LeaseMutationGuard {
fn acquire(dir: &Path) -> Result<Self, StandaloneStoreError> {
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(dir.join(LEASE_LOCK_FILE))
.map_err(StandaloneStoreError::Io)?;
file.lock_exclusive().map_err(StandaloneStoreError::Io)?;
Ok(Self { file })
}
}
impl Drop for LeaseMutationGuard {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.file);
}
}
#[derive(Debug, Serialize, Deserialize)]
struct LeaseRecord {
lease_id: Uuid,
pid: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
process_start_marker: Option<u64>,
acquired_at_unix_ms: u64,
}
impl LeaseRecord {
fn current() -> Result<Self, StandaloneStoreError> {
Ok(Self {
lease_id: Uuid::now_v7(),
pid: std::process::id(),
process_start_marker: match observe_process(std::process::id()) {
ProcessObservation::Running { start_marker } => Some(start_marker),
ProcessObservation::Missing | ProcessObservation::Unobservable => None,
},
acquired_at_unix_ms: now_unix_ms()?,
})
}
fn liveness(&self) -> LeaseLiveness {
classify_lease_liveness(self.process_start_marker, observe_process(self.pid))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LeaseLiveness {
Live,
Stale,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProcessObservation {
Running { start_marker: u64 },
Missing,
Unobservable,
}
fn classify_lease_liveness(
recorded_start_marker: Option<u64>,
observation: ProcessObservation,
) -> LeaseLiveness {
match (recorded_start_marker, observation) {
(Some(recorded), ProcessObservation::Running { start_marker })
if recorded == start_marker =>
{
LeaseLiveness::Live
}
(Some(_), ProcessObservation::Running { .. }) | (_, ProcessObservation::Missing) => {
LeaseLiveness::Stale
}
(None, ProcessObservation::Running { .. }) | (_, ProcessObservation::Unobservable) => {
LeaseLiveness::Unknown
}
}
}
fn read_lease(path: &Path, id: WorkerId) -> Result<LeaseRecord, StandaloneStoreError> {
let bytes = fs::read(path).map_err(StandaloneStoreError::Io)?;
serde_json::from_slice(&bytes)
.map_err(|source| StandaloneStoreError::CorruptLease { id, source })
}
#[cfg(target_os = "linux")]
fn observe_process(pid: u32) -> ProcessObservation {
let stat = match fs::read_to_string(format!("/proc/{pid}/stat")) {
Ok(stat) => stat,
Err(error) if error.kind() == io::ErrorKind::NotFound => {
return if pid != std::process::id() && linux_proc_is_observable() {
ProcessObservation::Missing
} else {
ProcessObservation::Unobservable
};
}
Err(_) => return ProcessObservation::Unobservable,
};
parse_linux_process_start_marker(&stat)
.map(|start_marker| ProcessObservation::Running { start_marker })
.unwrap_or(ProcessObservation::Unobservable)
}
#[cfg(target_os = "linux")]
fn linux_proc_is_observable() -> bool {
fs::read_to_string("/proc/self/stat")
.ok()
.and_then(|stat| parse_linux_process_start_marker(&stat))
.is_some()
}
#[cfg(target_os = "linux")]
fn parse_linux_process_start_marker(stat: &str) -> Option<u64> {
let (_, tail) = stat.rsplit_once(") ")?;
tail.split_whitespace().nth(19)?.parse().ok()
}
#[cfg(not(target_os = "linux"))]
fn observe_process(pid: u32) -> ProcessObservation {
if pid == std::process::id() {
ProcessObservation::Running { start_marker: 0 }
} else {
ProcessObservation::Unobservable
}
}
fn now_unix_ms() -> Result<u64, StandaloneStoreError> {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| StandaloneStoreError::Clock)?;
u64::try_from(duration.as_millis()).map_err(|_| StandaloneStoreError::Clock)
}
fn sync_directory(path: &Path) -> Result<(), StandaloneStoreError> {
File::open(path)
.and_then(|file| file.sync_all())
.map_err(StandaloneStoreError::Io)
}
#[derive(Debug, Error)]
pub enum StandaloneStoreError {
#[error("standalone state path is not a directory")]
NotDirectory,
#[error("standalone cwd is unavailable")]
CwdUnavailable(#[source] io::Error),
#[error("standalone cwd is not a directory")]
CwdNotDirectory,
#[error("standalone cwd identity no longer matches the persisted Worker")]
CwdIdentityMismatch,
#[error("standalone Worker {0} was not found")]
WorkerNotFound(WorkerId),
#[error("standalone Worker {0} has an incomplete metadata commit")]
IncompleteCommit(WorkerId),
#[error("standalone Worker {0} has invalid metadata")]
InvalidRecord(WorkerId),
#[error("standalone Worker {id} metadata is corrupt")]
CorruptRecord {
id: WorkerId,
#[source]
source: serde_json::Error,
},
#[error("standalone Worker {id} lease is corrupt")]
CorruptLease {
id: WorkerId,
#[source]
source: serde_json::Error,
},
#[error("standalone Worker {id} uses schema {found}, newer than supported schema {supported}")]
NewerSchema {
id: WorkerId,
found: u32,
supported: u32,
},
#[error("standalone Worker {0} is already active")]
WorkerLeased(WorkerId),
#[error("standalone Worker {0} lease liveness cannot be proven; recovery is rejected")]
LeaseLivenessUnknown(WorkerId),
#[error("standalone Worker {0} has a stale lease; explicit recovery is required")]
StaleLease(WorkerId),
#[error("standalone Worker lease ownership changed")]
LeaseOwnershipLost,
#[error("standalone Worker {0} must be stopped before deletion")]
DeleteActive(WorkerId),
#[error(
"standalone Worker {id} metadata revision changed (expected {expected}, found {found})"
)]
RevisionConflict {
id: WorkerId,
expected: u64,
found: u64,
},
#[error("system clock is before the Unix epoch or out of range")]
Clock,
#[error("standalone metadata serialization failed")]
Json(#[source] serde_json::Error),
#[error("standalone state I/O failed")]
Io(#[source] io::Error),
}
#[cfg(test)]
mod tests {
use super::{LeaseLiveness, ProcessObservation, classify_lease_liveness};
#[test]
fn lease_liveness_requires_positive_live_or_stale_evidence() {
assert_eq!(
classify_lease_liveness(Some(41), ProcessObservation::Running { start_marker: 41 }),
LeaseLiveness::Live
);
assert_eq!(
classify_lease_liveness(Some(41), ProcessObservation::Running { start_marker: 42 }),
LeaseLiveness::Stale
);
assert_eq!(
classify_lease_liveness(Some(41), ProcessObservation::Missing),
LeaseLiveness::Stale
);
assert_eq!(
classify_lease_liveness(None, ProcessObservation::Running { start_marker: 41 }),
LeaseLiveness::Unknown
);
assert_eq!(
classify_lease_liveness(Some(41), ProcessObservation::Unobservable),
LeaseLiveness::Unknown
);
assert_eq!(
classify_lease_liveness(None, ProcessObservation::Unobservable),
LeaseLiveness::Unknown
);
}
}
+612
View File
@@ -0,0 +1,612 @@
use std::collections::VecDeque;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use agen::llm_client::client::LlmClient;
use agen::llm_client::error::ClientError;
use agen::llm_client::event::{Event as LlmEvent, StopReason};
use agen::llm_client::types::Request;
use async_trait::async_trait;
use client::Client;
use client::transport::in_process::Socket as InProcessSocket;
use futures::{Stream, stream};
use protocol::{Event, Method};
use standalone::{
StaleLeasePolicy, StandaloneHost, StandaloneLaunchConfig, StandaloneListScope,
StandaloneStartupError, StandaloneStoreError, StandaloneWorkerStatus, StandaloneWorkerStore,
};
use uuid::Uuid;
#[derive(Clone)]
struct ScriptedClient {
responses: Arc<Mutex<VecDeque<Vec<LlmEvent>>>>,
requests: Arc<Mutex<Vec<Request>>>,
}
impl ScriptedClient {
fn new(responses: Vec<Vec<LlmEvent>>) -> Self {
Self {
responses: Arc::new(Mutex::new(responses.into())),
requests: Arc::new(Mutex::new(Vec::new())),
}
}
fn requests(&self) -> Vec<Request> {
self.requests.lock().expect("requests lock").clone()
}
}
#[async_trait]
impl LlmClient for ScriptedClient {
async fn stream(
&self,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
self.requests.lock().expect("requests lock").push(request);
let response = self
.responses
.lock()
.expect("responses lock")
.pop_front()
.expect("scripted response");
Ok(Box::pin(stream::iter(response.into_iter().map(Ok))))
}
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
}
#[tokio::test]
async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
let temp = tempfile::tempdir().expect("tempdir");
std::fs::write(temp.path().join("probe.txt"), "standalone tool evidence\n")
.expect("write probe");
let worker_name = format!("standalone-{}", Uuid::now_v7());
let launch = StandaloneLaunchConfig::new(
temp.path(),
temp.path().join("state"),
manifest::ProfileSelector::Default,
&worker_name,
)
.resolve()
.expect("resolve standalone profile");
let client = ScriptedClient::new(vec![
vec![
LlmEvent::tool_use_start(0, "read-1", "Read"),
LlmEvent::tool_input_delta(0, r#"{"file_path":"probe.txt"}"#),
LlmEvent::tool_use_stop(0),
],
vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, "standalone response"),
LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)),
],
]);
let inspection = client.clone();
let host = StandaloneHost::start_with_model_client(launch, client)
.await
.expect("start in-process host");
assert_eq!(host.record().worker_name, worker_name);
assert_eq!(host.record().manifest.worker.name, worker_name);
assert_eq!(
host.record().storage_key,
format!("standalone-{}", host.worker_id())
);
let mut protocol_client = host.connect();
protocol_client
.send(&Method::run_text("read the probe"))
.await
.expect("submit input");
tokio::time::timeout(Duration::from_secs(30), async {
let mut saw_user_message = false;
let mut saw_text = false;
let mut saw_tool_result = false;
loop {
match protocol_client
.next_event()
.await
.expect("protocol event")
.expect("worker event")
{
Event::UserMessage { segments }
if format!("{segments:?}").contains("read the probe") =>
{
saw_user_message = true;
}
Event::TextDelta { text } if text.contains("standalone response") => {
saw_text = true;
}
Event::ToolResult { .. } => {
saw_tool_result = true;
}
Event::RunEnd { .. } => {
assert!(
saw_user_message,
"stream must expose the committed user message"
);
assert!(saw_text, "stream must expose the model text delta");
assert!(saw_tool_result, "stream must expose the tool result");
break;
}
_ => {}
}
}
})
.await
.expect("run completed");
let requests = inspection.requests();
assert_eq!(requests.len(), 2);
let tool_names = requests[0]
.tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert!(tool_names.contains(&"Read"));
assert!(tool_names.contains(&"TaskCreate"));
assert!(tool_names.contains(&"SubWorkerSpawn"));
assert!(format!("{:?}", requests[1].items).contains("standalone tool evidence"));
assert!(
!temp
.path()
.join("state/runtime")
.join(&worker_name)
.join("worker.sock")
.exists()
);
host.shutdown().await.expect("graceful shutdown");
}
#[tokio::test]
async fn startup_preserves_occupied_scope_conflict_details() {
let temp = tempfile::tempdir().expect("tempdir");
let cwd = temp.path().join("project");
std::fs::create_dir(&cwd).expect("create project");
let first_launch = StandaloneLaunchConfig::new(
&cwd,
temp.path().join("first-state"),
manifest::ProfileSelector::Default,
"first-worker",
)
.resolve()
.expect("resolve first launch");
let first_host =
StandaloneHost::start_with_model_client(first_launch, ScriptedClient::new(Vec::new()))
.await
.expect("start first host");
let competitor = first_host.record().storage_key.clone();
let second_launch = StandaloneLaunchConfig::new(
&cwd,
temp.path().join("second-state"),
manifest::ProfileSelector::Default,
"second-worker",
)
.resolve()
.expect("resolve second launch");
let error =
StandaloneHost::start_with_model_client(second_launch, ScriptedClient::new(Vec::new()))
.await
.err()
.expect("occupied scope rejected");
first_host.shutdown().await.expect("shutdown first host");
let canonical_cwd = cwd.canonicalize().expect("canonical cwd");
match &error {
StandaloneStartupError::ScopeConflict {
competitor: actual_competitor,
requested_rule,
competitor_rule,
} => {
assert_eq!(actual_competitor, &competitor);
assert_eq!(requested_rule.target, canonical_cwd);
assert_eq!(competitor_rule.target, canonical_cwd);
}
other => panic!("expected scope conflict, got {other:?}"),
}
assert_eq!(
error.to_string(),
format!(
"requested scope `{}` conflicts with worker allocation `{competitor}` rule `{}`",
canonical_cwd.display(),
canonical_cwd.display()
)
);
}
#[tokio::test]
async fn state_store_failure_is_redacted_and_starts_no_controller() {
let temp = tempfile::tempdir().expect("tempdir");
let state_path = temp.path().join("state-file-with-secret-name");
std::fs::write(&state_path, "not a directory").expect("write blocking file");
let launch = StandaloneLaunchConfig::new(
temp.path(),
&state_path,
manifest::ProfileSelector::Default,
format!("standalone-failure-{}", Uuid::now_v7()),
)
.resolve()
.expect("resolve launch");
let client = ScriptedClient::new(Vec::new());
let error = StandaloneHost::start_with_model_client(launch, client)
.await
.err()
.expect("state store startup rejected");
assert_eq!(error, standalone::StandaloneStartupError::StateStore);
assert_eq!(
error.to_string(),
"the standalone state store could not be opened or validated"
);
assert!(!error.to_string().contains("secret-name"));
assert!(
!temp
.path()
.join("state-file-with-secret-name/runtime")
.exists()
);
}
#[test]
fn standalone_crate_has_no_tui_runtime_or_workspace_server_dependency() {
let manifest = include_str!("../Cargo.toml");
let dependencies = manifest
.split("[dependencies]")
.nth(1)
.expect("dependencies section")
.split("[dev-dependencies]")
.next()
.expect("dependency body");
for forbidden in ["tui", "worker-runtime", "yoi-workspace-server"] {
assert!(
!dependencies.lines().any(|line| {
line.split_once('=')
.is_some_and(|(name, _)| name.trim() == forbidden)
}),
"standalone must not depend on {forbidden}"
);
}
}
#[test]
fn launch_rejects_path_profile_before_worker_startup() {
let temp = tempfile::tempdir().expect("tempdir");
let error = StandaloneLaunchConfig::new(
temp.path(),
temp.path().join("state"),
manifest::ProfileSelector::Path {
path: temp.path().join("profile.dcdl"),
},
"standalone-path-profile",
)
.resolve()
.err()
.expect("path profile rejected");
assert_eq!(
error,
standalone::StandaloneLaunchError::PathProfileUnsupported
);
}
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[tokio::test]
async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope() -> TestResult {
let temp = tempfile::tempdir()?;
let cwd = temp.path().join("project");
let state_dir = temp.path().join("client").join("standalone-workers");
std::fs::create_dir_all(&cwd)?;
let launch = StandaloneLaunchConfig::new(
&cwd,
&state_dir,
manifest::ProfileSelector::Default,
"display-name-is-not-session-identity",
)
.resolve()?;
let first_client = ScriptedClient::new(vec![
vec![
LlmEvent::tool_use_start(0, "task-1", "TaskCreate"),
LlmEvent::tool_input_delta(
0,
r#"{"subject":"persisted task","description":"survives restore"}"#,
),
LlmEvent::tool_use_stop(0),
],
vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, "first answer"),
LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)),
],
vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, "notification acknowledged"),
LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)),
],
]);
let host = StandaloneHost::start_with_model_client(launch, first_client).await?;
let worker_id = host.worker_id();
let mut protocol_client = host.connect();
protocol_client
.send(&Method::run_text("first request"))
.await?;
wait_for_run_end(&mut protocol_client).await?;
protocol_client
.send(&Method::Notify {
message: "persisted notification".to_string(),
auto_run: true,
})
.await?;
wait_for_run_end(&mut protocol_client).await?;
host.shutdown().await?;
let store = StandaloneWorkerStore::open(&state_dir)?;
let current = store.list(&cwd, StandaloneListScope::CurrentCwd, 100)?;
assert_eq!(current.len(), 1);
assert_eq!(current[0].worker_id, worker_id);
assert_eq!(current[0].status, StandaloneWorkerStatus::Stopped);
let other_cwd = temp.path().join("other");
std::fs::create_dir(&other_cwd)?;
assert!(
store
.list(&other_cwd, StandaloneListScope::CurrentCwd, 100)?
.is_empty()
);
assert_eq!(
store.list(&other_cwd, StandaloneListScope::All, 100)?.len(),
1
);
let second_client = ScriptedClient::new(vec![vec![
LlmEvent::text_block_start(0),
LlmEvent::text_delta(0, "second answer"),
LlmEvent::text_block_stop(0, Some(StopReason::EndTurn)),
]]);
let second_inspection = second_client.clone();
let host =
StandaloneHost::restore_with_model_client(state_dir.clone(), worker_id, second_client)
.await?;
assert_eq!(
host.record().worker_name,
"display-name-is-not-session-identity"
);
assert_eq!(host.record().storage_key, format!("standalone-{worker_id}"));
let mut protocol_client = host.connect();
let snapshot = format!(
"{:?}",
protocol_client
.next_event()
.await
.expect("restored protocol stream")
.expect("restored snapshot")
);
assert!(snapshot.contains("first request"), "{snapshot}");
assert!(snapshot.contains("first answer"), "{snapshot}");
assert!(snapshot.contains("persisted task"), "{snapshot}");
assert!(snapshot.contains("persisted notification"), "{snapshot}");
protocol_client
.send(&Method::run_text("continue after restore"))
.await?;
wait_for_run_end(&mut protocol_client).await?;
let request = second_inspection
.requests()
.into_iter()
.next()
.expect("restored run request");
let projected = format!("{:?}", request.items);
assert!(projected.contains("first answer"), "{projected}");
assert!(projected.contains("persisted notification"), "{projected}");
assert!(projected.contains("persisted task"), "{projected}");
host.shutdown().await?;
store.delete(worker_id)?;
assert!(cwd.exists(), "deleting session state must not mutate cwd");
assert!(matches!(
store.load(worker_id),
Err(StandaloneStoreError::WorkerNotFound(_))
));
Ok(())
}
#[tokio::test]
async fn standalone_restore_rejects_concurrent_lease_and_missing_cwd() -> TestResult {
let temp = tempfile::tempdir()?;
let cwd = temp.path().join("project");
let moved = temp.path().join("moved-project");
let state_dir = temp.path().join("state");
std::fs::create_dir(&cwd)?;
let launch = StandaloneLaunchConfig::new(
&cwd,
&state_dir,
manifest::ProfileSelector::Default,
"standalone-lease-test",
)
.resolve()?;
let host =
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
let worker_id = host.worker_id();
let store = StandaloneWorkerStore::open(&state_dir)?;
assert!(matches!(
store.acquire_lease(worker_id, StaleLeasePolicy::Recover),
Err(StandaloneStoreError::WorkerLeased(id)) if id == worker_id
));
let restore = StandaloneHost::restore_with_model_client(
state_dir.clone(),
worker_id,
ScriptedClient::new(Vec::new()),
)
.await;
assert!(matches!(restore, Err(StandaloneStartupError::WorkerActive)));
host.shutdown().await?;
std::fs::rename(&cwd, &moved)?;
let restore = StandaloneHost::restore_with_model_client(
state_dir,
worker_id,
ScriptedClient::new(Vec::new()),
)
.await;
assert!(matches!(
restore,
Err(StandaloneStartupError::WorkingDirectoryUnavailable)
));
Ok(())
}
#[tokio::test]
async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult {
let temp = tempfile::tempdir()?;
let state_dir = temp.path().join("state");
let mut launch = StandaloneLaunchConfig::new(
temp.path(),
&state_dir,
manifest::ProfileSelector::Default,
"standalone-stale-lease-test",
)
.resolve()?;
launch.profile.manifest.profile = Some(manifest::ProfileManifestSnapshot {
source: manifest::ProfileSource::Registry {
source: manifest::ProfileRegistrySource::User,
name: "user-standalone".to_string(),
path: None,
provenance: Some("user-config-revision-7".to_string()),
},
profile: Some(manifest::ProfileMetadata {
name: Some("User standalone".to_string()),
description: None,
format: None,
}),
});
let host =
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
let worker_id = host.worker_id();
host.shutdown().await?;
let store = StandaloneWorkerStore::open(&state_dir)?;
assert!(matches!(
store.load(worker_id)?.manifest.profile,
Some(manifest::ProfileManifestSnapshot {
source: manifest::ProfileSource::Registry {
source: manifest::ProfileRegistrySource::User,
..
},
..
})
));
let worker_dir = state_dir.join(worker_id.to_string());
std::fs::write(
worker_dir.join("lease.json"),
serde_json::to_vec(&serde_json::json!({
"lease_id": uuid::Uuid::now_v7(),
"pid": u32::MAX,
"process_start_marker": 1,
"acquired_at_unix_ms": 1
}))?,
)?;
let host = StandaloneHost::restore_with_model_client(
state_dir,
worker_id,
ScriptedClient::new(Vec::new()),
)
.await?;
host.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn standalone_restore_rejects_lease_with_missing_start_marker() -> TestResult {
let temp = tempfile::tempdir()?;
let state_dir = temp.path().join("state");
let launch = StandaloneLaunchConfig::new(
temp.path(),
&state_dir,
manifest::ProfileSelector::Default,
"standalone-unknown-lease-test",
)
.resolve()?;
let host =
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
let worker_id = host.worker_id();
host.shutdown().await?;
let worker_dir = state_dir.join(worker_id.to_string());
std::fs::write(
worker_dir.join("lease.json"),
serde_json::to_vec(&serde_json::json!({
"lease_id": uuid::Uuid::now_v7(),
"pid": std::process::id(),
"acquired_at_unix_ms": 1
}))?,
)?;
let store = StandaloneWorkerStore::open(&state_dir)?;
assert!(matches!(
store.acquire_lease(worker_id, StaleLeasePolicy::Recover),
Err(StandaloneStoreError::LeaseLivenessUnknown(id)) if id == worker_id
));
let restore = StandaloneHost::restore_with_model_client(
state_dir,
worker_id,
ScriptedClient::new(Vec::new()),
)
.await;
assert!(matches!(
restore,
Err(StandaloneStartupError::LeaseLivenessUnknown)
));
Ok(())
}
#[tokio::test]
async fn standalone_metadata_fails_closed_on_incomplete_or_newer_records() -> TestResult {
let temp = tempfile::tempdir()?;
let state_dir = temp.path().join("state");
let launch = StandaloneLaunchConfig::new(
temp.path(),
&state_dir,
manifest::ProfileSelector::Default,
"standalone-schema-test",
)
.resolve()?;
let host =
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
let worker_id = host.worker_id();
host.shutdown().await?;
let store = StandaloneWorkerStore::open(&state_dir)?;
let worker_dir = state_dir.join(worker_id.to_string());
std::fs::write(worker_dir.join("commit.pending"), b"interrupted\n")?;
assert!(matches!(
store.load(worker_id),
Err(StandaloneStoreError::IncompleteCommit(id)) if id == worker_id
));
std::fs::remove_file(worker_dir.join("commit.pending"))?;
let record_path = worker_dir.join("record.json");
let mut record: serde_json::Value = serde_json::from_slice(&std::fs::read(&record_path)?)?;
record["schema_version"] = serde_json::json!(u32::MAX);
std::fs::write(&record_path, serde_json::to_vec_pretty(&record)?)?;
assert!(matches!(
store.load(worker_id),
Err(StandaloneStoreError::NewerSchema { id, .. }) if id == worker_id
));
Ok(())
}
async fn wait_for_run_end(client: &mut Client<InProcessSocket>) -> TestResult {
tokio::time::timeout(Duration::from_secs(10), async {
loop {
if matches!(client.next_event().await, Ok(Some(Event::RunEnd { .. }))) {
break;
}
}
})
.await?;
Ok(())
}
+134 -6
View File
@@ -21,6 +21,7 @@ struct BashParams {
pub(crate) struct BashTool { pub(crate) struct BashTool {
session: WorkdirSessionHandle, session: WorkdirSessionHandle,
output_dir: PathBuf,
state: Arc<Mutex<BashExecutionState>>, state: Arc<Mutex<BashExecutionState>>,
} }
@@ -117,6 +118,7 @@ impl Tool for BashTool {
command: params.command, command: params.command,
timeout_secs, timeout_secs,
output_limit: INLINE_BYTE_BUDGET, output_limit: INLINE_BYTE_BUDGET,
spill_dir: Some(self.output_dir.clone()),
tool_call_id: Some(call_id.clone()), tool_call_id: Some(call_id.clone()),
}) })
.await .await
@@ -183,10 +185,15 @@ impl Tool for BashTool {
let content = if output.content.is_empty() { let content = if output.content.is_empty() {
None None
} else if output.truncated { } else if output.truncated {
Some(format!( let notice = match output.output_path {
"[showing bounded WorkdirSession command output; additional output was truncated]\n{}", Some(path) => format!(
output.content "[showing bounded WorkdirSession command output; full output saved to {}]",
)) path.display()
),
None => "[showing bounded WorkdirSession command output; additional output was truncated]"
.to_owned(),
};
Some(format!("{notice}\n{}", output.content))
} else { } else {
Some(output.content) Some(output.content)
}; };
@@ -259,16 +266,137 @@ fn truncate_for_summary(command: &str) -> String {
summary summary
} }
pub fn bash_tool(session: WorkdirSessionHandle, _output_dir: PathBuf) -> ToolDefinition { pub fn bash_tool(session: WorkdirSessionHandle, output_dir: PathBuf) -> ToolDefinition {
Arc::new(move || { Arc::new(move || {
let schema = schemars::schema_for!(BashParams); let schema = schemars::schema_for!(BashParams);
let meta = ToolMeta::new("Bash") let meta = ToolMeta::new("Bash")
.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.") .description("Execute a shell command in the bound Workdir. Process start, bounded inline output, full-output spill, 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")); .input_schema(serde_json::to_value(schema).expect("Bash schema serialization"));
let tool: Arc<dyn Tool> = Arc::new(BashTool { let tool: Arc<dyn Tool> = Arc::new(BashTool {
session: session.clone(), session: session.clone(),
output_dir: output_dir.clone(),
state: Arc::new(Mutex::new(BashExecutionState::default())), state: Arc::new(Mutex::new(BashExecutionState::default())),
}); });
(meta, tool) (meta, tool)
}) })
} }
#[cfg(test)]
mod tests {
use std::sync::Arc;
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
use tempfile::TempDir;
use workdir::{LocalWorkdirSession, WorkdirSessionHandle};
use super::bash_tool;
use crate::{grep::grep_tool, read::read_tool, tracker::Tracker};
fn session_with_output_scope(root: &TempDir, output: &TempDir) -> WorkdirSessionHandle {
let scope = Scope::from_config(&ScopeConfig {
allow: vec![
ScopeRule {
target: root.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
},
ScopeRule {
target: output.path().to_path_buf(),
permission: Permission::Read,
recursive: true,
},
],
deny: Vec::new(),
})
.unwrap();
Arc::new(LocalWorkdirSession::new(scope, root.path().to_path_buf()))
}
#[tokio::test]
async fn long_output_is_spilled_and_available_to_read_and_grep() {
let root = TempDir::new().unwrap();
let output = TempDir::new().unwrap();
let session = session_with_output_scope(&root, &output);
let (_, bash) = bash_tool(session.clone(), output.path().to_path_buf())();
let command = "i=0; while [ $i -lt 2000 ]; do printf 'line-%04d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'";
let result = bash
.execute(
&serde_json::json!({ "command": command }).to_string(),
Default::default(),
)
.await
.unwrap();
let rendered = result.content.expect("bounded Bash output");
let artifact = std::fs::read_dir(output.path())
.unwrap()
.next()
.expect("artifact entry")
.unwrap()
.path();
assert!(rendered.contains("full output saved to"));
assert!(rendered.contains(&artifact.display().to_string()));
let retained = std::fs::read_to_string(&artifact).unwrap();
assert!(retained.starts_with("line-0000\n"));
assert!(retained.ends_with("FINAL-NEEDLE\n"));
assert_eq!(retained.lines().count(), 2001);
let (_, read) = read_tool(session.clone(), Tracker::new())();
let read_result = read
.execute(
&serde_json::json!({
"file_path": artifact,
"offset": 2000,
"limit": 1,
})
.to_string(),
Default::default(),
)
.await
.unwrap();
assert!(
read_result
.content
.expect("Read content")
.contains("FINAL-NEEDLE")
);
let (_, grep) = grep_tool(session)();
let grep_result = grep
.execute(
&serde_json::json!({
"pattern": "FINAL-NEEDLE",
"path": artifact,
"output_mode": "content",
})
.to_string(),
Default::default(),
)
.await
.unwrap();
let grep_content = grep_result.content.expect("Grep content");
assert!(
grep_content.contains("FINAL-NEEDLE"),
"unexpected Grep content: {grep_content:?}"
);
}
#[tokio::test]
async fn short_output_does_not_leave_a_spill_artifact() {
let root = TempDir::new().unwrap();
let output = TempDir::new().unwrap();
let session = session_with_output_scope(&root, &output);
let (_, bash) = bash_tool(session, output.path().to_path_buf())();
let result = bash
.execute(
&serde_json::json!({ "command": "printf short" }).to_string(),
Default::default(),
)
.await
.unwrap();
assert_eq!(result.content.as_deref(), Some("short"));
assert_eq!(std::fs::read_dir(output.path()).unwrap().count(), 0);
}
}
+1
View File
@@ -42,6 +42,7 @@ impl From<ToolsError> for ToolError {
workdir::WorkdirError::NotFound(_) workdir::WorkdirError::NotFound(_)
| workdir::WorkdirError::Io { .. } | workdir::WorkdirError::Io { .. }
| workdir::WorkdirError::Unavailable(_) | workdir::WorkdirError::Unavailable(_)
| workdir::WorkdirError::OperationFailed
| workdir::WorkdirError::Transport(_), | workdir::WorkdirError::Transport(_),
) => ToolError::ExecutionFailed(err.to_string()), ) => ToolError::ExecutionFailed(err.to_string()),
ToolsError::FileSystem(_) ToolsError::FileSystem(_)
+3 -3
View File
@@ -22,7 +22,7 @@ enum OutputMode {
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
struct GrepParams { struct GrepParams {
pattern: String, pattern: String,
/// Logical Workdir-relative path to search. Defaults to the Workdir root. /// Workdir-relative path, or an absolute path covered by readable scope. Defaults to the Workdir root.
#[serde(default)] #[serde(default)]
path: Option<String>, path: Option<String>,
#[serde(default)] #[serde(default)]
@@ -61,7 +61,7 @@ impl Tool for GrepTool {
let params: GrepParams = serde_json::from_str(input_json) let params: GrepParams = serde_json::from_str(input_json)
.map_err(|error| ToolError::InvalidArgument(format!("invalid Grep input: {error}")))?; .map_err(|error| ToolError::InvalidArgument(format!("invalid Grep input: {error}")))?;
let path = match params.path { let path = match params.path {
Some(path) => WorkdirPath::new(&path).map_err(ToolsError::from)?, Some(path) => WorkdirPath::new_scoped(&path).map_err(ToolsError::from)?,
None => WorkdirPath::root(), None => WorkdirPath::root(),
}; };
let mode = match params.output_mode.unwrap_or_default() { let mode = match params.output_mode.unwrap_or_default() {
@@ -129,7 +129,7 @@ pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
Arc::new(move || { Arc::new(move || {
let schema = schemars::schema_for!(GrepParams); let schema = schemars::schema_for!(GrepParams);
let meta = ToolMeta::new("Grep") let meta = ToolMeta::new("Grep")
.description("Search Workdir file contents with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.") .description("Search a Workdir file or directory with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Directory traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization")); .input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
let tool: Arc<dyn Tool> = Arc::new(GrepTool { let tool: Arc<dyn Tool> = Arc::new(GrepTool {
session: session.clone(), session: session.clone(),
+3 -3
View File
@@ -13,14 +13,14 @@ use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
const DESCRIPTION: &str = "Read a text file from the local filesystem. \ const DESCRIPTION: &str = "Read a text file from the local filesystem. \
Supports offset/limit for large files. Returns line-numbered output (1-based). \ 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 \ Directories cannot be read. The file must be read before Write or Edit can \
modify it. Paths are relative to the bound Workdir."; modify it. Paths are Workdir-relative unless an absolute path is explicitly readable.";
const DEFAULT_LIMIT: usize = 2000; const DEFAULT_LIMIT: usize = 2000;
const PROVIDER_BYTE_LIMIT: usize = 256 * 1024; const PROVIDER_BYTE_LIMIT: usize = 256 * 1024;
#[derive(Debug, Deserialize, schemars::JsonSchema)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
pub(crate) struct ReadParams { pub(crate) struct ReadParams {
/// Logical path relative to the bound Workdir root. /// Workdir-relative path, or an absolute path covered by readable scope.
pub file_path: String, pub file_path: String,
/// 0-based line offset from the start. Defaults to 0. /// 0-based line offset from the start. Defaults to 0.
#[serde(default)] #[serde(default)]
@@ -47,7 +47,7 @@ impl Tool for ReadTool {
let offset = params.offset.unwrap_or(0); let offset = params.offset.unwrap_or(0);
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1); let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
let path = WorkdirPath::new(&params.file_path).map_err(ToolsError::from)?; let path = WorkdirPath::new_scoped(&params.file_path).map_err(ToolsError::from)?;
tracing::debug!(path = %path, offset, limit, "Read"); tracing::debug!(path = %path, offset, limit, "Read");
let result = self let result = self
+8 -5
View File
@@ -224,20 +224,23 @@ async fn very_long_single_line() {
} }
#[tokio::test] #[tokio::test]
async fn absolute_path_is_rejected() { async fn absolute_path_requires_matching_read_scope() {
let (dir, _spill, reg) = setup(); let (_dir, _spill, reg) = setup();
let outside = tempfile::tempdir().unwrap();
let outside_file = outside.path().join("outside.txt");
std::fs::write(&outside_file, "secret").unwrap();
let read = reg.get("Read"); let read = reg.get("Read");
let err = read let err = read
.execute( .execute(
&json!({ "file_path": dir.path().join("outside.txt") }).to_string(), &json!({ "file_path": outside_file }).to_string(),
Default::default(), Default::default(),
) )
.await .await
.unwrap_err(); .unwrap_err();
let msg = format!("{err}"); let msg = format!("{err}");
assert!( assert!(
msg.contains("invalid logical filesystem path"), msg.contains("outside allowed scope"),
"absolute path was not rejected as invalid: {msg}" "absolute path escaped readable scope: {msg}"
); );
} }
+10 -3
View File
@@ -394,14 +394,21 @@ async fn bash_inherits_workdir_cwd() {
} }
#[tokio::test] #[tokio::test]
async fn bash_provider_output_does_not_expose_internal_paths() { async fn bash_provider_output_exposes_readable_retained_path() {
let (_dir, spill, reg) = setup(); let (_dir, spill, reg) = setup();
let bash = reg.get("Bash"); let bash = reg.get("Bash");
let out = call(&bash, json!({ "command": "printf 'x%.0s' {1..20480}" })).await; let out = call(&bash, json!({ "command": "printf 'x%.0s' {1..20480}" })).await;
let body = out.content.unwrap(); let body = out.content.unwrap();
assert!(body.contains("bounded WorkdirSession command output")); assert!(body.contains("bounded WorkdirSession command output"));
assert!(!body.contains(spill.path().to_str().unwrap())); assert!(body.contains("full output saved to"));
assert_eq!(std::fs::read_dir(spill.path()).unwrap().count(), 0); assert!(body.contains(spill.path().to_str().unwrap()));
let artifact = std::fs::read_dir(spill.path())
.unwrap()
.next()
.expect("retained output")
.unwrap()
.path();
assert_eq!(std::fs::metadata(artifact).unwrap().len(), 20_480);
} }
#[tokio::test] #[tokio::test]
+3 -3
View File
@@ -10,11 +10,13 @@ e2e-test = []
[dependencies] [dependencies]
client = { workspace = true } client = { workspace = true }
standalone = { workspace = true }
thiserror.workspace = true
protocol = { workspace = true } protocol = { workspace = true }
ratatui = { version = "0.30.0", features = ["scrolling-regions"] } ratatui = { version = "0.30.0", features = ["scrolling-regions"] }
base64 = "0.22.1" base64 = "0.22.1"
crossterm = "0.28" crossterm = "0.28"
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "process"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] }
serde_json = { workspace = true } serde_json = { workspace = true }
unicode-width = "0.2.2" unicode-width = "0.2.2"
uuid = { workspace = true } uuid = { workspace = true }
@@ -22,10 +24,8 @@ toml = { workspace = true }
manifest = { workspace = true } manifest = { workspace = true }
secrets = { workspace = true } secrets = { workspace = true }
session-store = { workspace = true } session-store = { workspace = true }
fs4 = { workspace = true }
ticket = { workspace = true } ticket = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
worker = { path = "../worker" }
pulldown-cmark = { version = "0.13.3", default-features = false } pulldown-cmark = { version = "0.13.3", default-features = false }
agen.workspace = true agen.workspace = true
+190 -181
View File
@@ -249,6 +249,9 @@ pub struct App {
pub running: bool, pub running: bool,
/// True while the Worker is in `WorkerStatus::Paused`. /// True while the Worker is in `WorkerStatus::Paused`.
pub paused: bool, pub paused: bool,
/// Local observation time for the current run. Used only for live UI
/// elapsed time and spinner animation; it is not persisted in history.
pub run_started_at: Option<Instant>,
pub run_requests: usize, pub run_requests: usize,
/// Sum of `input_tokens - cache_read_input_tokens` across the /// Sum of `input_tokens - cache_read_input_tokens` across the
/// current turn's LLM requests — i.e. the net tokens this turn /// current turn's LLM requests — i.e. the net tokens this turn
@@ -281,6 +284,9 @@ pub struct App {
/// records the instant; a second press within the timeout exits the /// records the instant; a second press within the timeout exits the
/// TUI (the Worker itself stays alive). /// TUI (the Worker itself stays alive).
pub quit_confirm: Option<std::time::Instant>, pub quit_confirm: Option<std::time::Instant>,
/// Independent 2-tap guard for `Ctrl-X` when the Worker is idle or
/// stopped. A second press within the timeout shuts down the Worker.
pub shutdown_confirm: Option<std::time::Instant>,
/// Full display history in render order. /// Full display history in render order.
pub blocks: Vec<Block>, pub blocks: Vec<Block>,
/// Turn/protocol errors retained when a real `SegmentStart` replaces the /// Turn/protocol errors retained when a real `SegmentStart` replaces the
@@ -352,6 +358,7 @@ impl App {
worker_status: WorkerStatus::Idle, worker_status: WorkerStatus::Idle,
running: false, running: false,
paused: false, paused: false,
run_started_at: None,
run_requests: 0, run_requests: 0,
run_upload_tokens: 0, run_upload_tokens: 0,
run_output_tokens: 0, run_output_tokens: 0,
@@ -369,6 +376,7 @@ impl App {
command_completion_selected: None, command_completion_selected: None,
quit: false, quit: false,
quit_confirm: None, quit_confirm: None,
shutdown_confirm: None,
blocks: Vec::new(), blocks: Vec::new(),
run_error_messages: Vec::new(), run_error_messages: Vec::new(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
@@ -553,11 +561,18 @@ impl App {
} }
pub fn set_worker_status(&mut self, status: WorkerStatus) { pub fn set_worker_status(&mut self, status: WorkerStatus) {
let was_running = self.running;
self.worker_status = status; self.worker_status = status;
self.running = status == WorkerStatus::Running; self.running = status == WorkerStatus::Running;
self.paused = status == WorkerStatus::Paused; self.paused = status == WorkerStatus::Paused;
if self.running { if self.running {
if !was_running {
self.run_started_at = Some(Instant::now());
}
self.quit_confirm = None; self.quit_confirm = None;
self.shutdown_confirm = None;
} else {
self.run_started_at = None;
} }
} }
@@ -765,7 +780,7 @@ impl App {
fn method_for_run(&mut self, segments: Vec<Segment>) -> Method { fn method_for_run(&mut self, segments: Vec<Segment>) -> Method {
// TurnHeader / UserMessage blocks are pushed only after the Worker // TurnHeader / UserMessage blocks are pushed only after the Worker
// emits `Event::UserMessage` from a committed `LogEntry::UserInput`. // emits `Event::UserMessage` from a committed `LogEntry::AnnotatedUserInput`.
// Locally we only clear the input buffer and forward the method, // Locally we only clear the input buffer and forward the method,
// while remembering enough local state to undo the visible submit if // while remembering enough local state to undo the visible submit if
// the accepted run produced no assistant output and was rolled back. // the accepted run produced no assistant output and was rolled back.
@@ -1098,10 +1113,9 @@ impl App {
self.blocks.push(Block::UserMessage { segments }); self.blocks.push(Block::UserMessage { segments });
self.assistant_streaming = false; self.assistant_streaming = false;
} }
Event::SegmentRotated { entry } => { Event::SegmentRotated { session } => {
let retained_run_errors = self.run_error_messages.clone(); let retained_run_errors = self.run_error_messages.clone();
self.reset_for_rotation(); self.restore_session(&session, self.greeting.clone());
self.apply_log_entry_raw(&entry);
for message in retained_run_errors { for message in retained_run_errors {
self.blocks.push(Block::Alert { self.blocks.push(Block::Alert {
level: AlertLevel::Error, level: AlertLevel::Error,
@@ -1122,11 +1136,13 @@ impl App {
self.latest_llm_wait_event = None; self.latest_llm_wait_event = None;
self.assistant_streaming = false; self.assistant_streaming = false;
} }
// UI consumers of Invoke / LlmCall semantics are out of scope Event::InvokeStart { .. } => {
// for `tickets/invoke-turn-llmcall-semantics.md`; events flow self.set_worker_status(WorkerStatus::Running);
// through to subscribers but the TUI currently derives its }
// turn header from `UserMessage` / `SystemItem` arrivals. // UI consumers of per-attempt LlmCall semantics remain out of scope;
Event::InvokeStart { .. } | Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => { // the run-level status starts at InvokeStart and TurnStart counts each
// LLM request within that run.
Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => {
self.latest_llm_wait_event = None; self.latest_llm_wait_event = None;
} }
Event::LlmRetry { Event::LlmRetry {
@@ -1408,14 +1424,14 @@ impl App {
self.latest_memory_worker_event = Some(event.message); self.latest_memory_worker_event = Some(event.message);
} }
Event::Snapshot { Event::Snapshot {
entries, session,
greeting, greeting,
status, status,
in_flight, in_flight,
internal_workers, internal_workers,
} => { } => {
self.rewind_refresh_fence = false; self.rewind_refresh_fence = false;
self.restore_snapshot(&entries, greeting, in_flight); self.restore_snapshot(&session, greeting, in_flight);
self.replace_internal_worker_snapshots(internal_workers); self.replace_internal_worker_snapshots(internal_workers);
self.set_worker_status(status); self.set_worker_status(status);
} }
@@ -1455,11 +1471,11 @@ impl App {
} }
} }
Event::RewindApplied { Event::RewindApplied {
entries, session,
input, input,
summary, summary,
} => { } => {
self.restore_rewind_snapshot(&entries); self.restore_rewind_snapshot(&session);
self.rewind_refresh_fence = true; self.rewind_refresh_fence = true;
let restored_composer = if self.input.is_empty() { let restored_composer = if self.input.is_empty() {
self.input.replace_with_segments(&input); self.input.replace_with_segments(&input);
@@ -2173,7 +2189,7 @@ impl App {
) -> InternalWorkerView { ) -> InternalWorkerView {
let mut app = App::new(snapshot.worker.name.clone()); let mut app = App::new(snapshot.worker.name.clone());
app.mode = mode; app.mode = mode;
app.restore_entries(&snapshot.entries, None); app.restore_session(&snapshot.session, None);
app.apply_in_flight_snapshot(snapshot.in_flight); app.apply_in_flight_snapshot(snapshot.in_flight);
app.set_worker_status(snapshot.status); app.set_worker_status(snapshot.status);
if let Some(error) = snapshot.error { if let Some(error) = snapshot.error {
@@ -2254,14 +2270,14 @@ impl App {
fn restore_snapshot( fn restore_snapshot(
&mut self, &mut self,
entries: &[serde_json::Value], session: &protocol::SessionSnapshot,
greeting: protocol::Greeting, greeting: protocol::Greeting,
in_flight: InFlightSnapshot, in_flight: InFlightSnapshot,
) { ) {
self.greeting = Some(greeting.clone()); self.greeting = Some(greeting.clone());
self.context_window = greeting.context_window; self.context_window = greeting.context_window;
self.session_context_tokens = greeting.context_tokens; self.session_context_tokens = greeting.context_tokens;
self.restore_entries(entries, Some(greeting)); self.restore_session(session, Some(greeting));
self.apply_in_flight_snapshot(in_flight); self.apply_in_flight_snapshot(in_flight);
} }
@@ -2270,7 +2286,7 @@ impl App {
/// session tail; always clear/replay from it even if this TUI instance has /// session tail; always clear/replay from it even if this TUI instance has
/// somehow lost connect-time greeting metadata. Skipping the restore in /// somehow lost connect-time greeting metadata. Skipping the restore in
/// that case would leave old post-target output visible after success. /// that case would leave old post-target output visible after success.
fn restore_rewind_snapshot(&mut self, entries: &[serde_json::Value]) { fn restore_rewind_snapshot(&mut self, session: &protocol::SessionSnapshot) {
let greeting = self.greeting.clone().or_else(|| { let greeting = self.greeting.clone().or_else(|| {
self.blocks.iter().find_map(|b| match b { self.blocks.iter().find_map(|b| match b {
Block::Greeting(g) => Some(g.clone()), Block::Greeting(g) => Some(g.clone()),
@@ -2283,7 +2299,7 @@ impl App {
self.session_context_tokens = greeting.context_tokens; self.session_context_tokens = greeting.context_tokens;
} }
let missing_greeting = greeting.is_none(); let missing_greeting = greeting.is_none();
self.restore_entries(entries, greeting); self.restore_session(session, greeting);
if missing_greeting { if missing_greeting {
self.blocks.push(Block::Alert { self.blocks.push(Block::Alert {
level: AlertLevel::Warn, level: AlertLevel::Warn,
@@ -2293,9 +2309,9 @@ impl App {
} }
} }
fn restore_entries( fn restore_session(
&mut self, &mut self,
entries: &[serde_json::Value], session: &protocol::SessionSnapshot,
greeting: Option<protocol::Greeting>, greeting: Option<protocol::Greeting>,
) { ) {
self.run_error_messages.clear(); self.run_error_messages.clear();
@@ -2309,78 +2325,83 @@ impl App {
} }
self.assistant_streaming = false; self.assistant_streaming = false;
for entry in entries { for entry in &session.entries {
self.apply_log_entry_raw(entry); use protocol::{SessionContentPart, SessionMessageRole, SessionSnapshotEntryData};
} match &entry.data {
SessionSnapshotEntryData::UserInput { segments } => {
self.mark_orphan_tool_calls_incomplete_pass();
}
/// Drop the derived view in preparation for replaying a new
/// `SegmentStart` (compaction / fork). Greeting is preserved
/// because the Worker identity hasn't changed.
fn reset_for_rotation(&mut self) {
let greeting = self.blocks.iter().find_map(|b| match b {
Block::Greeting(g) => Some(g.clone()),
_ => None,
});
self.turn_index = 0;
self.blocks.clear();
self.cache = FileCache::new();
self.task_store = TaskStore::new();
self.task_pane_scroll = 0;
if let Some(g) = greeting {
self.greeting = Some(g.clone());
self.blocks.push(Block::Greeting(g));
}
}
/// Walk a single `LogEntry` JSON value and translate it into blocks
/// the live event path would have produced. Shared between
/// `restore_snapshot` (replay path) and `apply_log_entry` (live
/// path).
fn apply_log_entry_raw(&mut self, value: &serde_json::Value) {
let Ok(entry) = serde_json::from_value::<session_store::LogEntry>(value.clone()) else {
return;
};
match entry {
session_store::LogEntry::SegmentStart { history, .. } => {
for logged in history {
let item: agen::Item = logged.into();
let item_value = serde_json::to_value(&item).expect("Item is Serialize");
self.push_history_item(&item_value);
}
}
session_store::LogEntry::UserInput { segments, .. } => {
self.turn_index += 1; self.turn_index += 1;
self.blocks.push(Block::TurnHeader { self.blocks.push(Block::TurnHeader {
turn: self.turn_index, turn: self.turn_index,
}); });
if !segments.is_empty() { if !segments.is_empty() {
self.blocks.push(Block::UserMessage { segments }); self.blocks.push(Block::UserMessage {
segments: segments.clone(),
});
} }
} }
session_store::LogEntry::AssistantItem { item, .. } SessionSnapshotEntryData::Message { role, content } => {
| session_store::LogEntry::ToolResult { item, .. } => { let role = match role {
let it: agen::Item = item.into(); SessionMessageRole::User => agen::Role::User,
let item_value = serde_json::to_value(&it).expect("Item is Serialize"); SessionMessageRole::Assistant => agen::Role::Assistant,
self.push_history_item(&item_value); };
let item = agen::Item::Message {
id: None,
role,
content: content
.iter()
.map(|part| match part {
SessionContentPart::Text { text } => {
agen::ContentPart::Text { text: text.clone() }
} }
session_store::LogEntry::SystemItem { item, .. } => { SessionContentPart::Refusal { refusal } => {
let value = serde_json::to_value(&item).expect("SystemItem is Serialize"); agen::ContentPart::Refusal {
self.apply_system_item(&value); refusal: refusal.clone(),
} }
session_store::LogEntry::Extension {
domain, payload, ..
} if domain == "yoi.compaction" => {
self.apply_compaction_extension(&payload);
} }
session_store::LogEntry::RunErrored { message, .. } => { })
self.push_run_error(message); .collect(),
status: None,
};
let value = serde_json::to_value(item).expect("Item is Serialize");
self.push_history_item(&value);
} }
// Non-history-bearing variants don't affect the block view. SessionSnapshotEntryData::ToolCall {
_ => {} call_id,
name,
arguments,
} => {
let item =
agen::Item::tool_call(call_id.clone(), name.clone(), arguments.clone());
let value = serde_json::to_value(item).expect("Item is Serialize");
self.push_history_item(&value);
} }
SessionSnapshotEntryData::ToolResult {
call_id,
summary,
content,
is_error,
..
} => {
let item = agen::Item::tool_result_item(
call_id.clone(),
summary.clone(),
content.clone(),
*is_error,
);
let value = serde_json::to_value(item).expect("Item is Serialize");
self.push_history_item(&value);
}
SessionSnapshotEntryData::SystemItem { data, .. } => {
if let Some(data) = data {
self.apply_system_item(data);
}
}
SessionSnapshotEntryData::RunError { message } => {
self.push_run_error(message.clone());
}
}
}
self.mark_orphan_tool_calls_incomplete_pass();
} }
/// Dispatch one `SystemItem` JSON value into the appropriate block. /// Dispatch one `SystemItem` JSON value into the appropriate block.
@@ -2388,58 +2409,6 @@ impl App {
/// Kind-based routing replaces the old free-text `[Notification]` / /// Kind-based routing replaces the old free-text `[Notification]` /
/// `[File: …]` parsing path: each kind maps directly to a typed /// `[File: …]` parsing path: each kind maps directly to a typed
/// block (`Block::Notify`, `Block::WorkerEvent`, …). /// block (`Block::Notify`, `Block::WorkerEvent`, …).
fn apply_compaction_extension(&mut self, payload: &serde_json::Value) {
if payload.get("kind").and_then(|value| value.as_str()) != Some("compaction_block") {
return;
}
match payload.get("state").and_then(|value| value.as_str()) {
Some("running") => {
if self.last_streaming_compact_mut().is_none() {
self.blocks.push(Block::Compact(CompactEvent::Streaming {
started_at: Instant::now(),
}));
}
}
Some("done") => {
let new_segment_id = payload
.get("new_segment_id")
.and_then(|value| value.as_str())
.and_then(|value| value.parse::<uuid::Uuid>().ok())
.unwrap_or_else(uuid::Uuid::nil);
if let Some(evt) = self.last_streaming_compact_mut() {
*evt = CompactEvent::Done {
new_segment_id,
elapsed_secs: None,
};
} else {
self.blocks.push(Block::Compact(CompactEvent::Done {
new_segment_id,
elapsed_secs: None,
}));
}
}
Some("failed") => {
let error = payload
.get("error")
.and_then(|value| value.as_str())
.unwrap_or("compact failed")
.to_string();
if let Some(evt) = self.last_streaming_compact_mut() {
*evt = CompactEvent::Failed {
error,
elapsed_secs: None,
};
} else {
self.blocks.push(Block::Compact(CompactEvent::Failed {
error,
elapsed_secs: None,
}));
}
}
_ => {}
}
}
fn apply_system_item(&mut self, value: &serde_json::Value) { fn apply_system_item(&mut self, value: &serde_json::Value) {
let Ok(item) = serde_json::from_value::<session_store::SystemItem>(value.clone()) else { let Ok(item) = serde_json::from_value::<session_store::SystemItem>(value.clone()) else {
// Unknown / forward-compat shape: fall back to rendering the // Unknown / forward-compat shape: fall back to rendering the
@@ -2542,6 +2511,15 @@ fn fmt_millis(ms: u64) -> String {
} }
} }
#[cfg(test)]
fn public_session(values: Vec<serde_json::Value>) -> protocol::SessionSnapshot {
let entries = values
.into_iter()
.map(|value| serde_json::from_value(value).expect("LogEntry deserializes"))
.collect::<Vec<session_store::LogEntry>>();
session_store::public_snapshot::project_current_session_snapshot(&entries)
}
fn message_text(item: &serde_json::Value) -> String { fn message_text(item: &serde_json::Value) -> String {
item["content"] item["content"]
.as_array() .as_array()
@@ -2685,7 +2663,7 @@ mod rewind_refresh_tests {
}); });
app.handle_worker_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
entries: vec![], session: protocol::SessionSnapshot { entries: vec![] },
input: vec![Segment::text("selected rewind input")], input: vec![Segment::text("selected rewind input")],
summary: summary(3), summary: summary(3),
}); });
@@ -2704,7 +2682,7 @@ mod rewind_refresh_tests {
}); });
app.handle_worker_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
entries: vec![], session: protocol::SessionSnapshot { entries: vec![] },
input: vec![Segment::text("rewound input")], input: vec![Segment::text("rewound input")],
summary: summary(1), summary: summary(1),
}); });
@@ -2747,7 +2725,7 @@ mod rewind_refresh_tests {
}); });
app.handle_worker_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
entries: vec![], session: protocol::SessionSnapshot { entries: vec![] },
input: vec![Segment::text("rewound input")], input: vec![Segment::text("rewound input")],
summary: summary(2), summary: summary(2),
}); });
@@ -2976,6 +2954,17 @@ mod composer_history_persistence_tests {
mod completion_flow_tests { mod completion_flow_tests {
use super::*; use super::*;
fn annotated(item: agen::Item) -> session_store::LoggedHistoryEntry {
session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::from(item),
metadata: session_store::LoggedSessionHistoryMetadata {
entry_id: session_store::LoggedSessionHistoryEntryId::new(),
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
}
}
#[test] #[test]
fn typing_at_creates_completion_state_and_emits_query() { fn typing_at_creates_completion_state_and_emits_query() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
@@ -3278,7 +3267,7 @@ mod completion_flow_tests {
#[test] #[test]
fn committed_user_message_survives_fresh_segment_rotation() { fn committed_user_message_survives_fresh_segment_rotation() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
let start = session_store::LogEntry::SegmentStart { let start = session_store::LogEntry::AnnotatedSegmentStart {
ts: session_store::segment_log::now_millis(), ts: session_store::segment_log::now_millis(),
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: None, system_prompt: None,
@@ -3289,7 +3278,9 @@ mod completion_flow_tests {
}; };
app.handle_worker_event(Event::SegmentRotated { app.handle_worker_event(Event::SegmentRotated {
entry: serde_json::to_value(start).expect("LogEntry is Serialize"), session: public_session(vec![
serde_json::to_value(start).expect("LogEntry is Serialize"),
]),
}); });
app.handle_worker_event(Event::UserMessage { app.handle_worker_event(Event::UserMessage {
segments: vec![Segment::text("first persisted message")], segments: vec![Segment::text("first persisted message")],
@@ -3403,6 +3394,17 @@ mod completion_flow_tests {
} }
} }
#[test]
fn running_status_starts_and_stops_live_run_clock() {
let mut app = App::new("test".into());
app.set_worker_status(WorkerStatus::Running);
assert!(app.run_started_at.is_some());
app.set_worker_status(WorkerStatus::Idle);
assert!(app.run_started_at.is_none());
}
#[test] #[test]
fn running_submit_is_queued_locally_and_clears_composer() { fn running_submit_is_queued_locally_and_clears_composer() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
@@ -3533,23 +3535,23 @@ mod completion_flow_tests {
} }
#[test] #[test]
fn snapshot_renders_system_message_block_from_session_start() { fn snapshot_excludes_system_prompt_history_from_public_blocks() {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
let session_start = session_store::LogEntry::SegmentStart { let session_start = session_store::LogEntry::AnnotatedSegmentStart {
ts: 1, ts: 1,
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: None, system_prompt: None,
config: Default::default(), config: Default::default(),
history: vec![session_store::LoggedItem::from( history: vec![annotated(agen::Item::system_message(
&agen::Item::system_message("[File: src/main.rs]\nfn main() {}"), "[File: src/main.rs]\nfn main() {}",
)], ))],
forked_from: None, forked_from: None,
compacted_from: None, compacted_from: None,
}; };
let session_start_value = serde_json::to_value(&session_start).unwrap(); let session_start_value = serde_json::to_value(&session_start).unwrap();
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
entries: vec![session_start_value], session: public_session(vec![session_start_value]),
status: WorkerStatus::Running, status: WorkerStatus::Running,
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
@@ -3557,10 +3559,8 @@ mod completion_flow_tests {
assert!(matches!(app.worker_status, WorkerStatus::Running)); assert!(matches!(app.worker_status, WorkerStatus::Running));
assert!(app.running); assert!(app.running);
assert!(matches!( assert_eq!(app.blocks.len(), 1);
app.blocks.get(1), assert!(matches!(app.blocks.first(), Some(Block::Greeting(_))));
Some(Block::SystemMessage { text }) if text == "[File: src/main.rs]\nfn main() {}"
));
} }
#[test] #[test]
@@ -3595,7 +3595,7 @@ mod completion_flow_tests {
}; };
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
entries: vec![serde_json::to_value(run_errored).unwrap()], session: public_session(vec![serde_json::to_value(run_errored).unwrap()]),
status: WorkerStatus::Idle, status: WorkerStatus::Idle,
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
@@ -3623,7 +3623,7 @@ mod completion_flow_tests {
code: ErrorCode::ProviderError, code: ErrorCode::ProviderError,
message: "provider unavailable".into(), message: "provider unavailable".into(),
}); });
let segment_start = session_store::LogEntry::SegmentStart { let segment_start = session_store::LogEntry::AnnotatedSegmentStart {
ts: 5, ts: 5,
session_id: uuid::Uuid::nil(), session_id: uuid::Uuid::nil(),
system_prompt: None, system_prompt: None,
@@ -3633,7 +3633,7 @@ mod completion_flow_tests {
compacted_from: None, compacted_from: None,
}; };
app.handle_worker_event(Event::SegmentRotated { app.handle_worker_event(Event::SegmentRotated {
entry: serde_json::to_value(segment_start).unwrap(), session: public_session(vec![serde_json::to_value(segment_start).unwrap()]),
}); });
let errors = app let errors = app
@@ -3656,7 +3656,9 @@ mod completion_flow_tests {
let mut app = App::new("test".into()); let mut app = App::new("test".into());
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
status: WorkerStatus::Running, status: WorkerStatus::Running,
in_flight: InFlightSnapshot { in_flight: InFlightSnapshot {
blocks: vec![ blocks: vec![
@@ -3762,7 +3764,9 @@ mod completion_flow_tests {
}, },
revision, revision,
status: WorkerStatus::Idle, status: WorkerStatus::Idle,
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
in_flight: protocol::InFlightSnapshot::default(), in_flight: protocol::InFlightSnapshot::default(),
error: None, error: None,
internal_workers: Vec::new(), internal_workers: Vec::new(),
@@ -3977,7 +3981,9 @@ mod completion_flow_tests {
assert_eq!(app.selected_worker_view().worker_name, "parent"); assert_eq!(app.selected_worker_view().worker_name, "parent");
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
status: WorkerStatus::Idle, status: WorkerStatus::Idle,
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
@@ -4026,7 +4032,9 @@ mod completion_flow_tests {
}); });
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
status: WorkerStatus::Idle, status: WorkerStatus::Idle,
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: vec![InternalWorkerSnapshot { internal_workers: vec![InternalWorkerSnapshot {
@@ -4037,7 +4045,9 @@ mod completion_flow_tests {
kind: protocol::InternalWorkerKind::SubWorker, kind: protocol::InternalWorkerKind::SubWorker,
}, },
revision: 4, revision: 4,
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
status: WorkerStatus::Running, status: WorkerStatus::Running,
error: None, error: None,
in_flight: Default::default(), in_flight: Default::default(),
@@ -4193,7 +4203,9 @@ mod completion_flow_tests {
greeting.context_tokens = 45_000; greeting.context_tokens = 45_000;
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
greeting, greeting,
status: WorkerStatus::Idle, status: WorkerStatus::Idle,
in_flight: Default::default(), in_flight: Default::default(),
@@ -4363,40 +4375,37 @@ mod completion_flow_tests {
}); });
let assistant_item_entries = vec![ let assistant_item_entries = vec![
serde_json::json!({ serde_json::to_value(session_store::LogEntry::AnnotatedAssistantItem {
"kind": "assistant_item", ts: 1,
"ts": 1, entry: annotated(agen::Item::tool_call(
"item": { "c1",
"kind": "tool_call", "TaskCreate",
"call_id": "c1", r#"{"subject":"a","description":"A"}"#,
"name": "TaskCreate", )),
"arguments": r#"{"subject":"a","description":"A"}"#, })
}, .unwrap(),
}), serde_json::to_value(session_store::LogEntry::AnnotatedAssistantItem {
serde_json::json!({ ts: 2,
"kind": "assistant_item", entry: annotated(agen::Item::tool_call(
"ts": 2, "c2",
"item": { "TaskCreate",
"kind": "tool_call", r#"{"subject":"b","description":"B"}"#,
"call_id": "c2", )),
"name": "TaskCreate", })
"arguments": r#"{"subject":"b","description":"B"}"#, .unwrap(),
}, serde_json::to_value(session_store::LogEntry::AnnotatedAssistantItem {
}), ts: 3,
serde_json::json!({ entry: annotated(agen::Item::tool_call(
"kind": "assistant_item", "u1",
"ts": 3, "TaskUpdate",
"item": { r#"{"taskid":2,"status":"inprogress"}"#,
"kind": "tool_call", )),
"call_id": "u1", })
"name": "TaskUpdate", .unwrap(),
"arguments": r#"{"taskid":2,"status":"inprogress"}"#,
},
}),
]; ];
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
entries: assistant_item_entries, session: public_session(assistant_item_entries),
status: WorkerStatus::Running, status: WorkerStatus::Running,
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
+289 -365
View File
@@ -1,8 +1,7 @@
use std::error::Error;
use std::fmt; use std::fmt;
use std::future::Future; use std::future::Future;
use std::io; use std::io;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::sync::{ use std::sync::{
Arc, Arc,
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
@@ -21,26 +20,19 @@ use protocol::{Event, Method, WorkerStatus};
use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment}; use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
use ratatui::Terminal; use ratatui::Terminal;
use ratatui::backend::CrosstermBackend; use ratatui::backend::CrosstermBackend;
use session_store::SegmentId; use standalone::{StandaloneHost, StandaloneLaunchConfig};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use client::{BackendRuntimeClient, BackendRuntimeTarget, WorkerClient, WorkerRuntimeCommand}; use client::transport::Socket;
use client::{BackendRuntimeTarget, Client, StandaloneWorkerResumeIntent, connect_backend_runtime};
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App}; use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
use crate::composer_keys::{ComposerEditAction, composer_edit_action}; use crate::composer_keys::{ComposerEditAction, composer_edit_action};
use crate::picker::PickerOutcome; use crate::ui;
use crate::spawn::{SpawnOutcome, SpawnReady};
use crate::{picker, spawn, ui};
pub(crate) type ConsoleTerminal = Terminal<CrosstermBackend<io::Stdout>>; pub(crate) type ConsoleTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// Narrow request bridge used when the workspace Dashboard opens a Worker Console.
pub(crate) struct DashboardConsoleOpenRequest {
pub(crate) worker_name: String,
pub(crate) socket_override: Option<PathBuf>,
}
/// Enable SGR coordinates plus normal mouse tracking. This captures clicks, /// Enable SGR coordinates plus normal mouse tracking. This captures clicks,
/// releases, and wheel events without drag-capture modes (`?1002h`/`?1003h`) /// releases, and wheel events without drag-capture modes (`?1002h`/`?1003h`)
/// so terminal-native drag selection remains available during startup. /// so terminal-native drag selection remains available during startup.
@@ -128,281 +120,145 @@ fn copy_selection_to_terminal(app: &mut App) -> bool {
copy_selection_to_writer(app, &mut stdout) copy_selection_to_writer(app, &mut stdout)
} }
fn resolve_socket(worker_name: &str, override_path: Option<PathBuf>) -> PathBuf { struct ConsoleConnection<T> {
if let Some(p) = override_path { client: Client<T>,
return p; standalone_host: Option<StandaloneHost>,
}
manifest::paths::worker_socket_path(worker_name).unwrap_or_else(|| {
PathBuf::from("/tmp")
.join("yoi")
.join(worker_name)
.join("sock")
})
} }
pub(crate) async fn run_worker_name( impl<T: Socket> ConsoleConnection<T> {
worker_name: String, fn new(client: Client<T>) -> Self {
socket_override: Option<PathBuf>, Self {
runtime_command: WorkerRuntimeCommand, client,
) -> Result<(), Box<dyn std::error::Error>> { standalone_host: None,
#[cfg(feature = "e2e-test")]
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
let mut terminal = enter_fullscreen()?;
terminal.clear()?;
let result = run_e2e_rewind_fixture(&mut terminal, worker_name).await;
let _ = leave_fullscreen(&mut terminal);
return result;
}
if let Some(client) = try_connect_live_pod(&worker_name, socket_override.clone()).await {
let mut terminal = enter_fullscreen()?;
run_connected_pod(&mut terminal, worker_name, client, runtime_command.clone()).await?;
return Ok(());
}
let ready = match spawn::run_worker_name(worker_name, runtime_command.clone()).await? {
SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()),
};
let mut terminal = enter_fullscreen()?;
terminal.clear()?;
let result = run_ready_pod(&mut terminal, ready, runtime_command).await;
let _ = leave_fullscreen(&mut terminal);
result
}
enum ConsoleConnection {
LegacySocket(WorkerClient),
BackendRuntime(BackendRuntimeClient),
}
impl ConsoleConnection {
fn try_next_event(&mut self) -> Option<Event> {
match self {
Self::LegacySocket(client) => client.try_next_event(),
Self::BackendRuntime(client) => client.try_next_event(),
} }
} }
async fn next_event(&mut self) -> Option<Event> { fn with_standalone_host(client: Client<T>, host: StandaloneHost) -> Self {
match self { Self {
Self::LegacySocket(client) => client.next_event().await, client,
Self::BackendRuntime(client) => client.next_event().await, standalone_host: Some(host),
} }
} }
fn try_next_event(&mut self) -> Result<Option<Event>, Box<dyn std::error::Error>> {
Ok(self.client.try_next_event()?)
}
async fn next_event(&mut self) -> Result<Option<Event>, Box<dyn std::error::Error>> {
Ok(self.client.next_event().await?)
}
async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> { async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> {
match self { Ok(self.client.send(method).await?)
Self::LegacySocket(client) => Ok(client.send(method).await?),
Self::BackendRuntime(client) => Ok(client.send(method).await?),
} }
async fn shutdown(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if let Some(host) = self.standalone_host.take() {
host.shutdown().await?;
} }
Ok(())
}
}
pub(crate) async fn run_standalone(
workspace_root: PathBuf,
state_dir: PathBuf,
worker_name: Option<String>,
profile: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let worker_name = worker_name.unwrap_or_else(|| "local".to_string());
let profile = profile.map_or(manifest::ProfileSelector::Default, |profile| {
manifest::ProfileSelector::parse_cli(&profile)
});
let history_root = workspace_root.clone();
let launch = StandaloneLaunchConfig {
state_dir,
cwd: workspace_root,
profile,
worker_name: worker_name.clone(),
}
.resolve()
.map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("Standalone launch configuration failed: {error}"),
)
})?;
let host = StandaloneHost::start(launch)
.await
.map_err(|error| io::Error::other(format!("Standalone Worker startup failed: {error}")))?;
run_standalone_host(host, worker_name, history_root).await
}
pub(crate) async fn run_standalone_restore(
intent: StandaloneWorkerResumeIntent,
) -> Result<(), Box<dyn std::error::Error>> {
let worker_id = intent.worker_id.parse().map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("Invalid standalone Worker ID: {error}"),
)
})?;
let host = StandaloneHost::restore(intent.state_dir, worker_id)
.await
.map_err(|error| io::Error::other(format!("Standalone restore failed: {error}")))?;
let worker_label = host.record().worker_name.clone();
let history_root = host.record().cwd.canonical_path.clone();
run_standalone_host(host, worker_label, history_root).await
}
fn standalone_console_app(worker_label: String, history_root: &Path) -> App {
let mut app = App::new_with_persistent_input_history(worker_label, history_root);
app.connected = true;
app
}
async fn run_standalone_host(
host: StandaloneHost,
worker_label: String,
history_root: PathBuf,
) -> Result<(), Box<dyn std::error::Error>> {
let client = host.connect();
let mut connection = ConsoleConnection::with_standalone_host(client, host);
let mut terminal = match enter_fullscreen() {
Ok(terminal) => terminal,
Err(error) => {
let _ = connection.shutdown().await;
return Err(error);
}
};
let mut app = standalone_console_app(worker_label, &history_root);
let run_result = run_loop(&mut terminal, &mut app, &mut connection).await;
let shutdown_result = connection
.shutdown()
.await
.map_err(|error| io::Error::other(format!("Standalone Worker shutdown failed: {error}")));
let leave_result = leave_fullscreen(&mut terminal);
if let Err(error) = run_result {
return Err(error);
}
shutdown_result?;
leave_result?;
Ok(())
} }
pub(crate) async fn run_backend_runtime( pub(crate) async fn run_backend_runtime(
target: BackendRuntimeTarget, target: BackendRuntimeTarget,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let worker_label = target.display_label(); let worker_label = target.display_label();
let client = BackendRuntimeClient::connect(target).await?; let client = connect_backend_runtime(target).await?;
let mut terminal = enter_fullscreen()?; let mut terminal = enter_fullscreen()?;
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root); let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root);
app.connected = true; app.connected = true;
let result = run_loop( let mut connection = ConsoleConnection::new(client);
&mut terminal, let result = run_loop(&mut terminal, &mut app, &mut connection).await;
&mut app,
ConsoleConnection::BackendRuntime(client),
None,
)
.await;
let _ = leave_fullscreen(&mut terminal); let _ = leave_fullscreen(&mut terminal);
result result
} }
async fn run_connected_pod(
terminal: &mut ConsoleTerminal,
worker_name: String,
client: WorkerClient,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let mut app = App::new_with_persistent_input_history(worker_name, &workspace_root);
app.connected = true;
run_loop(
terminal,
&mut app,
ConsoleConnection::LegacySocket(client),
Some(runtime_command),
)
.await
}
pub(crate) async fn open_from_dashboard(
terminal: &mut ConsoleTerminal,
request: DashboardConsoleOpenRequest,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let DashboardConsoleOpenRequest {
worker_name,
socket_override,
} = request;
if let Some(client) = try_connect_live_pod(&worker_name, socket_override).await {
return run_connected_pod(terminal, worker_name, client, runtime_command.clone()).await;
}
let ready =
spawn_worker_name_from_fullscreen(terminal, &worker_name, runtime_command.clone()).await?;
run_ready_pod(terminal, ready, runtime_command).await
}
async fn spawn_worker_name_from_fullscreen(
terminal: &mut ConsoleTerminal,
worker_name: &str,
runtime_command: WorkerRuntimeCommand,
) -> Result<SpawnReady, Box<dyn std::error::Error>> {
leave_fullscreen(terminal)?;
let outcome = spawn::run_worker_name(worker_name.to_string(), runtime_command).await;
enter_fullscreen_existing(terminal)?;
terminal.clear()?;
match outcome? {
SpawnOutcome::Ready(ready) => Ok(ready),
SpawnOutcome::Cancelled => Err(Box::new(NestedOpenCancelled)),
}
}
async fn try_connect_live_pod(
worker_name: &str,
socket_override: Option<PathBuf>,
) -> Option<WorkerClient> {
let preferred_socket = resolve_socket(worker_name, socket_override.clone());
connect_live_pod(worker_name, preferred_socket, socket_override.is_none())
.await
.map(|(_, client)| client)
}
#[derive(Debug)]
struct NestedOpenCancelled;
impl std::fmt::Display for NestedOpenCancelled {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Worker open was cancelled")
}
}
impl std::error::Error for NestedOpenCancelled {}
async fn run_ready_pod(
terminal: &mut ConsoleTerminal,
ready: SpawnReady,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let SpawnReady {
worker_name,
socket_path,
} = ready;
run(terminal, worker_name, &socket_path, runtime_command).await
}
async fn connect_live_pod(
worker_name: &str,
preferred_socket: PathBuf,
allow_registry_fallback: bool,
) -> Option<(PathBuf, WorkerClient)> {
if let Ok(client) = WorkerClient::connect(&preferred_socket).await {
return Some((preferred_socket, client));
}
if !allow_registry_fallback {
return None;
}
let registry_socket = picker::live_socket_for_worker(worker_name)?;
if registry_socket == preferred_socket {
return None;
}
WorkerClient::connect(&registry_socket)
.await
.ok()
.map(|client| (registry_socket, client))
}
pub(crate) async fn run_resume(
runtime_command: WorkerRuntimeCommand,
workspace_root: PathBuf,
all: bool,
) -> Result<(), Box<dyn std::error::Error>> {
run_worker_picker(runtime_command, workspace_root, all, true).await
}
pub(crate) async fn run_worker_picker(
runtime_command: WorkerRuntimeCommand,
workspace_root: PathBuf,
all: bool,
include_stopped: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// Pick a Worker in its own inline viewport, dropping the viewport before
// attaching/restoring so each phase gets fresh vertical room.
let picker_options = if all {
picker::PickerOptions::all()
} else {
picker::PickerOptions::workspace(workspace_root)
}
.with_stopped(include_stopped);
let (worker_name, socket_override) = match picker::run(picker_options).await? {
PickerOutcome::Picked {
worker_name,
socket_override,
} => (worker_name, socket_override),
PickerOutcome::Cancelled => return Ok(()),
};
run_worker_name(worker_name, socket_override, runtime_command).await
}
pub(crate) fn is_recoverable_dashboard_open_error(error: &(dyn Error + 'static)) -> bool {
error.is::<spawn::SpawnError>() || error.is::<NestedOpenCancelled>()
}
pub(crate) async fn run_spawn(
resume_from: Option<SegmentId>,
worker_name: Option<String>,
profile: Option<String>,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(feature = "e2e-test")]
if std::env::var_os("YOI_TUI_TEST_REWIND_FIXTURE").is_some() {
let mut terminal = enter_fullscreen()?;
terminal.clear()?;
let fixture_worker_name = worker_name.unwrap_or_else(|| "e2e-rewind".to_string());
let result = run_e2e_rewind_fixture(&mut terminal, fixture_worker_name).await;
let _ = leave_fullscreen(&mut terminal);
return result;
}
let ready = match spawn::run(resume_from, worker_name, profile, runtime_command.clone()).await?
{
SpawnOutcome::Ready(r) => r,
SpawnOutcome::Cancelled => return Ok(()),
};
let SpawnReady {
worker_name,
socket_path,
} = ready;
let mut terminal = enter_fullscreen()?;
let result = run(&mut terminal, worker_name, &socket_path, runtime_command).await;
// Leave alt-screen explicitly before `main`'s terminal restore path.
let _ = execute!(
terminal.backend_mut(),
DisableMouseCapture,
LeaveAlternateScreen
);
result
}
fn enter_fullscreen() -> Result<ConsoleTerminal, Box<dyn std::error::Error>> { fn enter_fullscreen() -> Result<ConsoleTerminal, Box<dyn std::error::Error>> {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
// Enable button-event tracking so the transcript can own drag selection; // Enable button-event tracking so the transcript can own drag selection;
@@ -421,19 +277,6 @@ pub(crate) fn enter_dashboard_fullscreen() -> Result<ConsoleTerminal, Box<dyn st
Ok(Terminal::new(backend)?) Ok(Terminal::new(backend)?)
} }
fn enter_fullscreen_existing(
terminal: &mut ConsoleTerminal,
) -> Result<(), Box<dyn std::error::Error>> {
// Re-enable the same least-intrusive wheel mouse mode after returning from
// nested inline screens.
execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableSinglePodMouseCapture
)?;
Ok(())
}
fn leave_fullscreen(terminal: &mut ConsoleTerminal) -> io::Result<()> { fn leave_fullscreen(terminal: &mut ConsoleTerminal) -> io::Result<()> {
execute!( execute!(
terminal.backend_mut(), terminal.backend_mut(),
@@ -446,40 +289,6 @@ pub(crate) fn leave_dashboard_fullscreen(terminal: &mut ConsoleTerminal) -> io::
leave_fullscreen(terminal) leave_fullscreen(terminal)
} }
async fn run(
terminal: &mut ConsoleTerminal,
worker_name: String,
socket_path: &std::path::Path,
runtime_command: WorkerRuntimeCommand,
) -> Result<(), Box<dyn std::error::Error>> {
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let mut app = App::new_with_persistent_input_history(worker_name, &workspace_root);
match WorkerClient::connect(socket_path).await {
Ok(client) => {
app.connected = true;
// The Worker sends `Event::Snapshot` automatically on connect;
// no explicit method call is required to fetch history.
run_loop(
terminal,
&mut app,
ConsoleConnection::LegacySocket(client),
Some(runtime_command),
)
.await?;
}
Err(e) => {
app.push_error(format!(
"Failed to connect to {}: {e}",
socket_path.display()
));
terminal.draw(|f| ui::draw(f, &mut app))?;
run_disconnected(&mut app)?;
}
}
Ok(())
}
type TerminalEventResult = io::Result<TermEvent>; type TerminalEventResult = io::Result<TermEvent>;
const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(50); const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(50);
@@ -547,7 +356,9 @@ async fn run_e2e_rewind_fixture(
let mut app = App::new_with_persistent_input_history(worker_name.clone(), &workspace_root); let mut app = App::new_with_persistent_input_history(worker_name.clone(), &workspace_root);
app.connected = true; app.connected = true;
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
status: WorkerStatus::Idle, status: WorkerStatus::Idle,
greeting: Greeting { greeting: Greeting {
worker_name: worker_name.clone(), worker_name: worker_name.clone(),
@@ -673,7 +484,9 @@ async fn run_e2e_rewind_fixture(
if let Some(submitted_at) = pending_apply { if let Some(submitted_at) = pending_apply {
if submitted_at.elapsed() >= apply_delay { if submitted_at.elapsed() >= apply_delay {
app.handle_worker_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
input: vec![Segment::text("rewind-live-refresh")], input: vec![Segment::text("rewind-live-refresh")],
summary: RewindSummary { summary: RewindSummary {
truncated_to_entries: 1, truncated_to_entries: 1,
@@ -715,16 +528,20 @@ enum E2eRewindInput {
enum LoopInput<P> { enum LoopInput<P> {
Terminal(TerminalEventResult), Terminal(TerminalEventResult),
Worker(Option<P>), Worker(P),
Tick,
} }
async fn next_loop_input<P, F>( async fn next_loop_input<P, F, T>(
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>, term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
connected: bool, connected: bool,
pod_next: F, pod_next: F,
animate: bool,
animation_tick: T,
) -> LoopInput<P> ) -> LoopInput<P>
where where
F: Future<Output = Option<P>>, F: Future<Output = P>,
T: Future,
{ {
tokio::select! { tokio::select! {
biased; biased;
@@ -738,21 +555,21 @@ where
})) }))
} }
event = pod_next, if connected => LoopInput::Worker(event), event = pod_next, if connected => LoopInput::Worker(event),
_ = animation_tick, if animate => LoopInput::Tick,
} }
} }
async fn drain_terminal_events( async fn drain_terminal_events<T: Socket>(
app: &mut App, app: &mut App,
client: &mut ConsoleConnection, client: &mut ConsoleConnection<T>,
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>, term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
runtime_command: Option<&WorkerRuntimeCommand>,
) -> Result<bool, Box<dyn std::error::Error>> { ) -> Result<bool, Box<dyn std::error::Error>> {
let mut handled = false; let mut handled = false;
for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT { for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
match term_rx.try_recv() { match term_rx.try_recv() {
Ok(event) => { Ok(event) => {
handled = true; handled = true;
handle_terminal_event(app, client, event?, runtime_command).await?; handle_terminal_event(app, client, event?).await?;
if app.quit { if app.quit {
break; break;
} }
@@ -769,13 +586,13 @@ async fn drain_terminal_events(
Ok(handled) Ok(handled)
} }
async fn drain_worker_events( async fn drain_worker_events<T: Socket>(
app: &mut App, app: &mut App,
client: &mut ConsoleConnection, client: &mut ConsoleConnection<T>,
) -> Result<bool, Box<dyn std::error::Error>> { ) -> Result<bool, Box<dyn std::error::Error>> {
let mut handled = false; let mut handled = false;
for _ in 0..POD_EVENT_DRAIN_LIMIT { for _ in 0..POD_EVENT_DRAIN_LIMIT {
match client.try_next_event() { match client.try_next_event()? {
Some(ev) => { Some(ev) => {
handled = true; handled = true;
if let Some(method) = app.handle_worker_event(ev) { if let Some(method) = app.handle_worker_event(ev) {
@@ -788,13 +605,14 @@ async fn drain_worker_events(
Ok(handled) Ok(handled)
} }
async fn run_loop( async fn run_loop<T: Socket>(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut App, app: &mut App,
mut client: ConsoleConnection, client: &mut ConsoleConnection<T>,
runtime_command: Option<WorkerRuntimeCommand>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?; let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
let mut animation_tick = tokio::time::interval(Duration::from_millis(80));
animation_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
terminal.draw(|f| ui::draw(f, app))?; terminal.draw(|f| ui::draw(f, app))?;
@@ -803,23 +621,29 @@ async fn run_loop(
break; break;
} }
let handled_term_event = let handled_term_event = drain_terminal_events(app, client, &mut term_rx).await?;
drain_terminal_events(app, &mut client, &mut term_rx, runtime_command.as_ref()).await?;
if app.quit { if app.quit {
break; break;
} }
let handled_worker_event = drain_worker_events(app, &mut client).await?; let handled_worker_event = drain_worker_events(app, client).await?;
if handled_term_event || handled_worker_event { if handled_term_event || handled_worker_event {
terminal.draw(|f| ui::draw(f, app))?; terminal.draw(|f| ui::draw(f, app))?;
continue; continue;
} }
match next_loop_input(&mut term_rx, app.connected, client.next_event()).await { match next_loop_input(
&mut term_rx,
app.connected,
client.next_event(),
app.running,
animation_tick.tick(),
)
.await
{
LoopInput::Terminal(term_event) => { LoopInput::Terminal(term_event) => {
handle_terminal_event(app, &mut client, term_event?, runtime_command.as_ref()) handle_terminal_event(app, client, term_event?).await?;
.await?;
} }
LoopInput::Worker(event) => match event { LoopInput::Worker(event) => match event? {
Some(ev) => { Some(ev) => {
if let Some(method) = app.handle_worker_event(ev) { if let Some(method) = app.handle_worker_event(ev) {
client.send(&method).await?; client.send(&method).await?;
@@ -831,6 +655,7 @@ async fn run_loop(
app.push_error("Connection lost"); app.push_error("Connection lost");
} }
}, },
LoopInput::Tick => {}
} }
terminal.draw(|f| ui::draw(f, app))?; terminal.draw(|f| ui::draw(f, app))?;
@@ -839,11 +664,10 @@ async fn run_loop(
Ok(()) Ok(())
} }
async fn handle_terminal_event( async fn handle_terminal_event<T: Socket>(
app: &mut App, app: &mut App,
client: &mut ConsoleConnection, client: &mut ConsoleConnection<T>,
event: TermEvent, event: TermEvent,
_runtime_command: Option<&WorkerRuntimeCommand>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
match event { match event {
TermEvent::Key(key) => { TermEvent::Key(key) => {
@@ -865,19 +689,6 @@ async fn handle_terminal_event(
Ok(()) Ok(())
} }
fn run_disconnected(_app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
loop {
if event::poll(std::time::Duration::from_millis(100))?
&& let TermEvent::Key(key) = event::read()?
&& let KeyCode::Char('c') = key.code
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
break;
}
}
Ok(())
}
/// Lines per wheel notch. Faster than Shift+↑/↓ (which is 1 line) so /// Lines per wheel notch. Faster than Shift+↑/↓ (which is 1 line) so
/// hand-rolling through long histories isn't tedious, but slow enough /// hand-rolling through long histories isn't tedious, but slow enough
/// that a single notch doesn't blow past the section the user is /// that a single notch doesn't blow past the section the user is
@@ -1011,13 +822,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
Some(None) Some(None)
} }
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)), KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
KeyCode::Char('x') if ctrl => Some(match app.worker_status { KeyCode::Char('x') if ctrl => Some(handle_cancel_or_shutdown(app)),
WorkerStatus::Running | WorkerStatus::Paused => {
app.clear_queued_inputs();
Some(Method::Cancel)
}
WorkerStatus::Idle | WorkerStatus::Stopped => Some(Method::Shutdown),
}),
KeyCode::Char('d') if ctrl => { KeyCode::Char('d') if ctrl => {
app.quit = true; app.quit = true;
Some(None) Some(None)
@@ -1274,6 +1079,33 @@ fn handle_command_key(app: &mut App, key: KeyEvent) -> Option<Method> {
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
/// Running / Paused → send `Method::Cancel` immediately.
/// Idle / Stopped → 2-tap to shut down the Worker.
fn handle_cancel_or_shutdown(app: &mut App) -> Option<Method> {
if matches!(
app.worker_status,
WorkerStatus::Running | WorkerStatus::Paused
) {
app.shutdown_confirm = None;
app.clear_queued_inputs();
return Some(Method::Cancel);
}
if let Some(pressed_at) = app.shutdown_confirm
&& pressed_at.elapsed() < CONFIRM_TIMEOUT
{
app.shutdown_confirm = None;
return Some(Method::Shutdown);
}
app.shutdown_confirm = Some(std::time::Instant::now());
app.flash_actionbar_notice(
"Press Ctrl-X again within 3 s to shut down the Worker.",
ActionbarNoticeLevel::Warn,
ActionbarNoticeSource::Tui,
CONFIRM_TIMEOUT,
);
None
}
/// Running → send `Method::Pause`. /// Running → send `Method::Pause`.
/// Idle / Paused → 2-tap to quit the TUI (the Worker keeps running). /// Idle / Paused → 2-tap to quit the TUI (the Worker keeps running).
fn handle_pause_or_quit(app: &mut App) -> Option<Method> { fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
@@ -1304,6 +1136,14 @@ mod tests {
use crate::text_selection::{HistoryViewport, SelectionRow}; use crate::text_selection::{HistoryViewport, SelectionRow};
use protocol::{Event, RewindTarget, RewindTargetId, Segment}; use protocol::{Event, RewindTarget, RewindTargetId, Segment};
#[test]
fn standalone_console_starts_with_in_process_connection_ready() {
let temp = tempfile::tempdir().expect("tempdir");
let app = standalone_console_app("standalone".to_string(), temp.path());
assert!(app.connected);
}
#[test] #[test]
fn single_worker_mouse_capture_avoids_drag_and_all_motion_modes() { fn single_worker_mouse_capture_avoids_drag_and_all_motion_modes() {
let mut ansi = String::new(); let mut ansi = String::new();
@@ -1411,6 +1251,23 @@ mod tests {
); );
} }
#[tokio::test]
async fn animation_tick_wakes_loop_while_running() {
let (_tx, mut rx) = mpsc::unbounded_channel::<TerminalEventResult>();
assert!(matches!(
next_loop_input(
&mut rx,
true,
std::future::pending::<Option<u8>>(),
true,
std::future::ready(()),
)
.await,
LoopInput::Tick
));
}
#[tokio::test] #[tokio::test]
async fn terminal_event_is_selected_before_ready_worker_event() { async fn terminal_event_is_selected_before_ready_worker_event() {
let (tx, mut rx) = mpsc::unbounded_channel(); let (tx, mut rx) = mpsc::unbounded_channel();
@@ -1420,7 +1277,15 @@ mod tests {
)))) ))))
.unwrap(); .unwrap();
match next_loop_input(&mut rx, true, std::future::ready(Some(()))).await { match next_loop_input(
&mut rx,
true,
std::future::ready(Some(())),
false,
std::future::pending::<()>(),
)
.await
{
LoopInput::Terminal(Ok(TermEvent::Key(key))) => { LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
assert_eq!(key.code, KeyCode::Char('x')); assert_eq!(key.code, KeyCode::Char('x'));
} }
@@ -1432,7 +1297,15 @@ mod tests {
async fn terminal_event_is_preserved_after_worker_event_wins() { async fn terminal_event_is_preserved_after_worker_event_wins() {
let (tx, mut rx) = mpsc::unbounded_channel(); let (tx, mut rx) = mpsc::unbounded_channel();
match next_loop_input(&mut rx, true, std::future::ready(Some(1_u8))).await { match next_loop_input(
&mut rx,
true,
std::future::ready(Some(1_u8)),
false,
std::future::pending::<()>(),
)
.await
{
LoopInput::Worker(Some(1)) => {} LoopInput::Worker(Some(1)) => {}
_ => panic!("expected the first ready Worker event to win before any terminal input"), _ => panic!("expected the first ready Worker event to win before any terminal input"),
} }
@@ -1443,7 +1316,15 @@ mod tests {
)))) ))))
.unwrap(); .unwrap();
match next_loop_input(&mut rx, true, std::future::ready(Some(2_u8))).await { match next_loop_input(
&mut rx,
true,
std::future::ready(Some(2_u8)),
false,
std::future::pending::<()>(),
)
.await
{
LoopInput::Terminal(Ok(TermEvent::Key(key))) => { LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
assert_eq!(key.code, KeyCode::Char('y')); assert_eq!(key.code, KeyCode::Char('y'));
} }
@@ -1610,15 +1491,53 @@ mod tests {
} }
#[test] #[test]
fn ctrl_x_shutdown_while_idle_is_unchanged() { fn ctrl_x_requires_confirmation_before_shutdown_while_idle() {
let mut app = App::new("agent".to_string());
app.set_worker_status(WorkerStatus::Idle);
let ctrl_x = || KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
assert!(handle_key(&mut app, ctrl_x()).is_none());
assert!(app.shutdown_confirm.is_some());
let notice = app
.current_actionbar_notice(std::time::Instant::now())
.expect("first Ctrl-X should arm shutdown confirmation");
assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
assert_eq!(notice.source, ActionbarNoticeSource::Tui);
assert!(notice.text.contains("Ctrl-X"));
assert!(notice.text.contains("shut down the Worker"));
assert!(!has_alert(&app, "shut down the Worker"));
assert!(matches!(
handle_key(&mut app, ctrl_x()),
Some(Method::Shutdown)
));
assert!(app.shutdown_confirm.is_none());
}
#[test]
fn ctrl_c_and_ctrl_x_confirmations_do_not_authorize_each_other() {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.set_worker_status(WorkerStatus::Idle); app.set_worker_status(WorkerStatus::Idle);
let shutdown = handle_key( assert!(
handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
)
.is_none()
);
assert!(app.quit_confirm.is_some());
assert!(app.shutdown_confirm.is_none());
assert!(
handle_key(
&mut app, &mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
)
.is_none()
); );
assert!(matches!(shutdown, Some(Method::Shutdown))); assert!(!app.quit);
assert!(app.shutdown_confirm.is_some());
} }
#[test] #[test]
@@ -2023,13 +1942,13 @@ mod tests {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
entries: vec![], session: protocol::SessionSnapshot { entries: vec![] },
status: WorkerStatus::Idle, status: WorkerStatus::Idle,
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
}); });
app.handle_worker_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
entries: vec![], session: protocol::SessionSnapshot { entries: vec![] },
input: vec![Segment::Text { input: vec![Segment::Text {
content: "retry this".into(), content: "retry this".into(),
}], }],
@@ -2050,7 +1969,7 @@ mod tests {
let mut app = App::new("agent".to_string()); let mut app = App::new("agent".to_string());
app.handle_worker_event(Event::Snapshot { app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(), greeting: test_greeting(),
entries: vec![], session: protocol::SessionSnapshot { entries: vec![] },
status: WorkerStatus::Idle, status: WorkerStatus::Idle,
in_flight: Default::default(), in_flight: Default::default(),
internal_workers: Vec::new(), internal_workers: Vec::new(),
@@ -2058,7 +1977,7 @@ mod tests {
type_keys(&mut app, "draft"); type_keys(&mut app, "draft");
app.handle_worker_event(Event::RewindApplied { app.handle_worker_event(Event::RewindApplied {
entries: vec![], session: protocol::SessionSnapshot { entries: vec![] },
input: vec![Segment::Text { input: vec![Segment::Text {
content: "retry this".into(), content: "retry this".into(),
}], }],
@@ -2334,12 +2253,17 @@ mod tests {
handle_key(&mut app, key(KeyCode::Tab)); handle_key(&mut app, key(KeyCode::Tab));
assert_eq!(app.selected_worker_view().worker_name, "subworker-hoge"); assert_eq!(app.selected_worker_view().worker_name, "subworker-hoge");
let method = handle_key( let first = handle_key(
&mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
);
let second = handle_key(
&mut app, &mut app,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
); );
assert!(matches!(method, Some(Method::Shutdown))); assert!(first.is_none());
assert!(matches!(second, Some(Method::Shutdown)));
assert_eq!(app.worker_status, WorkerStatus::Idle); assert_eq!(app.worker_status, WorkerStatus::Idle);
} }
File diff suppressed because it is too large Load Diff
-784
View File
@@ -1,784 +0,0 @@
use super::*;
pub(super) fn draw(frame: &mut Frame<'_>, app: &mut DashboardApp) {
let area = frame.area();
let input_content_width = area.width.saturating_sub(2).max(1);
let mut input_render = app.input.render(input_content_width);
let input_height = input_area_height(&input_render, area.height);
app.input
.apply_cursor_viewport(&mut input_render, input_height);
let layout = dashboard_layout(area, input_height);
draw_title(frame, app, layout.title);
draw_list(frame, app, layout.list);
draw_separator(frame, layout.boundary);
draw_target_status(frame, app, layout.target_status);
draw_input(frame, &input_render, layout.input);
draw_actionbar(frame, app, layout.actionbar);
if app.panel_diagnostic_open {
render_panel_diagnostic(frame, app, area);
}
}
pub(super) fn panel_diagnostic_area(area: Rect) -> Rect {
let width = if area.width <= 20 {
area.width
} else {
area.width.saturating_sub(4).min(100).max(20)
};
let height = if area.height <= 8 {
area.height
} else {
area.height.saturating_sub(4).min(24).max(8)
};
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
Rect::new(x, y, width, height)
}
pub(super) fn render_panel_diagnostic(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) {
let Some(diagnostic) = app.panel_diagnostic.as_ref() else {
return;
};
let popup_area = panel_diagnostic_area(area);
let title = format!(" {} ", diagnostic.title);
let text = format!("{}\n\nF2/Esc: close", diagnostic.details);
let paragraph = Paragraph::new(text)
.block(Block::default().title(title).borders(Borders::ALL))
.wrap(Wrap { trim: false });
frame.render_widget(Clear, popup_area);
frame.render_widget(paragraph, popup_area);
}
pub(super) fn input_area_height(render: &crate::input::InputRender, terminal_height: u16) -> u16 {
let needed = render.lines.len().max(1) as u16;
let cap = (terminal_height / 3).max(1).min(10);
needed.clamp(1, cap)
}
pub(super) fn draw_title(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) {
frame.render_widget(Paragraph::new(title_line(app)), area);
}
pub(super) fn title_line(app: &DashboardApp) -> Line<'static> {
let mut spans = vec![Span::styled(
"workspace dashboard",
Style::default().add_modifier(Modifier::BOLD),
)];
if let Some(companion) = &app.panel.header.companion {
spans.push(Span::styled(
" · companion ",
Style::default().fg(Color::DarkGray),
));
spans.push(Span::styled(
companion.status.label(),
companion_status_style(companion.status),
));
if let Some(detail) = companion.detail.as_deref() {
spans.push(Span::styled(
format!(" ({detail})"),
Style::default().fg(Color::DarkGray),
));
}
}
if let Some(orchestrator) = &app.panel.header.orchestrator {
spans.push(Span::styled(
" · orchestrator ",
Style::default().fg(Color::DarkGray),
));
spans.push(Span::styled(
orchestrator.status.label(),
orchestrator_status_style(orchestrator.status),
));
}
Line::from(spans)
}
pub(super) fn companion_status_style(status: CompanionPanelStatus) -> Style {
match status {
CompanionPanelStatus::Live
| CompanionPanelStatus::Restored
| CompanionPanelStatus::Spawned => Style::default().fg(Color::Green),
CompanionPanelStatus::Stopped | CompanionPanelStatus::Missing => {
Style::default().fg(Color::Yellow)
}
CompanionPanelStatus::Unavailable => Style::default().fg(Color::Red),
}
}
pub(super) fn orchestrator_status_style(status: OrchestratorPanelStatus) -> Style {
match status {
OrchestratorPanelStatus::Live
| OrchestratorPanelStatus::Restored
| OrchestratorPanelStatus::Spawned => Style::default().fg(Color::Green),
OrchestratorPanelStatus::Stopped | OrchestratorPanelStatus::Missing => {
Style::default().fg(Color::Yellow)
}
OrchestratorPanelStatus::Unavailable => Style::default().fg(Color::Red),
}
}
pub(super) fn draw_list(frame: &mut Frame<'_>, app: &mut DashboardApp, area: Rect) {
if area.width == 0 || area.height == 0 {
app.row_hit_boxes.clear();
return;
}
let rows = list_rows(app, area.width, area.height);
app.set_row_hit_boxes(&rows, area);
let lines = rows.into_iter().map(|row| row.line).collect::<Vec<_>>();
Paragraph::new(lines).render(area, frame.buffer_mut());
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct PanelListRow {
pub(super) line: Line<'static>,
pub(super) key: Option<PanelRowKey>,
}
impl PanelListRow {
fn inert(line: Line<'static>) -> Self {
Self { line, key: None }
}
fn selectable(line: Line<'static>, key: PanelRowKey) -> Self {
Self {
line,
key: Some(key),
}
}
}
#[cfg(test)]
pub(super) fn list_lines(app: &DashboardApp, width: u16, height: u16) -> Vec<Line<'static>> {
list_rows(app, width, height)
.into_iter()
.map(|row| row.line)
.collect()
}
pub(super) fn list_rows(app: &DashboardApp, width: u16, height: u16) -> Vec<PanelListRow> {
let sections = sectioned_entries(&app.list);
let selected = app.selected_row.as_ref();
let diagnostic_rows = panel_diagnostic_lines(&app.panel, width)
.into_iter()
.map(PanelListRow::inert)
.collect::<Vec<_>>();
let action_rows = panel_action_rows(&app.panel, selected, width);
let live_rows = sections
.iter()
.filter(|section| section.kind != DashboardSectionKind::Closed)
.flat_map(|section| section_rows(&app.list, section, selected, width))
.collect::<Vec<_>>();
let closed_rows = sections
.iter()
.find(|section| section.kind == DashboardSectionKind::Closed)
.map(|section| section_rows(&app.list, section, selected, width))
.unwrap_or_default();
let available = height as usize;
let diagnostic_len = diagnostic_rows.len().min(available);
let remaining_after_diagnostics = available.saturating_sub(diagnostic_len);
let action_len = action_rows.len().min(remaining_after_diagnostics);
let remaining_after_actions = remaining_after_diagnostics.saturating_sub(action_len);
let closed_len = closed_rows.len().min(remaining_after_actions);
let live_len = live_rows
.len()
.min(remaining_after_actions.saturating_sub(closed_len));
let spacer_len = available.saturating_sub(diagnostic_len + action_len + live_len + closed_len);
let mut rows = Vec::with_capacity(available);
rows.extend(diagnostic_rows.into_iter().take(diagnostic_len));
rows.extend(action_rows.into_iter().take(action_len));
rows.extend(live_rows.into_iter().take(live_len));
rows.extend(
std::iter::repeat_with(|| PanelListRow::inert(Line::from(Span::raw("")))).take(spacer_len),
);
rows.extend(closed_rows.into_iter().take(closed_len));
rows
}
pub(super) fn row_hit_boxes(rows: &[PanelListRow], area: Rect) -> Vec<PanelRowHitBox> {
if area.width == 0 || area.height == 0 {
return Vec::new();
}
let mut hit_boxes: Vec<PanelRowHitBox> = Vec::new();
for (offset, row) in rows.iter().enumerate() {
let Some(key) = row.key.clone() else {
continue;
};
let Some(y) = area.y.checked_add(offset as u16) else {
continue;
};
if y >= area.y.saturating_add(area.height) {
continue;
}
if let Some(last) = hit_boxes.last_mut() {
if last.key == key
&& last.rect.x == area.x
&& last.rect.width == area.width
&& last.rect.y.saturating_add(last.rect.height) == y
{
last.rect.height = last.rect.height.saturating_add(1);
continue;
}
}
hit_boxes.push(PanelRowHitBox {
rect: Rect::new(area.x, y, area.width, 1),
key,
});
}
hit_boxes
}
pub(super) fn panel_diagnostic_lines(
panel: &WorkspacePanelViewModel,
width: u16,
) -> Vec<Line<'static>> {
panel
.header
.diagnostics
.iter()
.map(|diagnostic| {
Line::from(vec![
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(
truncate_with_ellipsis(diagnostic, width.saturating_sub(2) as usize),
Style::default().fg(Color::Yellow),
),
])
})
.collect()
}
pub(super) fn panel_action_rows(
panel: &WorkspacePanelViewModel,
selected: Option<&PanelRowKey>,
width: u16,
) -> Vec<PanelListRow> {
let rows = panel
.rows
.iter()
.filter(|row| row.is_ticket_section_row())
.collect::<Vec<_>>();
if rows.is_empty() {
return Vec::new();
}
let mut lines = Vec::with_capacity((rows.len() * 2) + 1);
lines.push(PanelListRow::inert(panel_action_header_line(
rows.len(),
width,
)));
for row in rows {
for line in panel_row_lines(row, selected == Some(&row.key), width) {
lines.push(PanelListRow::selectable(line, row.key.clone()));
}
}
lines
}
pub(super) fn panel_action_header_line(total: usize, width: u16) -> Line<'static> {
let detail = if total == 1 {
" 1 row".to_string()
} else {
format!(" {total} rows")
};
let text = truncate_with_ellipsis(&format!("--tickets{detail}---"), width as usize);
Line::from(Span::styled(
text,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
))
}
pub(super) const TICKET_STATE_COLUMN_WIDTH: usize = 10;
pub(super) const POD_STATUS_COLUMN_WIDTH: usize = 18;
pub(super) fn panel_row_lines(row: &PanelRow, selected: bool, width: u16) -> Vec<Line<'static>> {
if row.kind == PanelRowKind::TicketIntakeWorker {
vec![panel_intake_child_line(row, selected, width)]
} else {
vec![
panel_row_title_line(row, selected, width),
panel_row_detail_line(row, selected, width),
]
}
}
pub(super) fn panel_row_title_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
let title_style = if selected {
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Magenta)
};
let mut spans = Vec::new();
let mut remaining = width as usize;
push_ticket_primary_marker_span(&mut spans, selected, &mut remaining);
push_column_span(
&mut spans,
&row.status,
TICKET_STATE_COLUMN_WIDTH,
panel_priority_style(row.priority),
&mut remaining,
);
push_bounded_span(&mut spans, row.title.as_str(), title_style, &mut remaining);
Line::from(spans)
}
pub(super) fn panel_intake_child_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
let title_style = if selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
let mut spans = Vec::new();
let mut remaining = width as usize;
push_intake_child_marker_span(&mut spans, selected, &mut remaining);
push_column_span(
&mut spans,
&row.status,
TICKET_STATE_COLUMN_WIDTH,
intake_status_style(&row.status),
&mut remaining,
);
push_bounded_span(&mut spans, row.title.as_str(), title_style, &mut remaining);
Line::from(spans)
}
pub(super) fn panel_row_detail_line(row: &PanelRow, selected: bool, width: u16) -> Line<'static> {
let mut spans = Vec::new();
let mut remaining = width as usize;
push_ticket_detail_marker_span(&mut spans, selected, &mut remaining);
push_bounded_span(
&mut spans,
"meta ",
Style::default().fg(Color::DarkGray),
&mut remaining,
);
push_bounded_span(
&mut spans,
&panel_ticket_detail(row),
ticket_detail_style(row),
&mut remaining,
);
Line::from(spans)
}
pub(super) fn push_ticket_primary_marker_span(
spans: &mut Vec<Span<'static>>,
selected: bool,
remaining: &mut usize,
) {
let (marker, style) = if selected {
(
"",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)
} else {
(" ", Style::default().fg(Color::DarkGray))
};
push_bounded_span(spans, marker, style, remaining);
}
pub(super) fn push_ticket_detail_marker_span(
spans: &mut Vec<Span<'static>>,
selected: bool,
remaining: &mut usize,
) {
let (marker, style) = if selected {
(
"",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)
} else {
(" ", Style::default().fg(Color::DarkGray))
};
push_bounded_span(spans, marker, style, remaining);
}
pub(super) fn push_intake_child_marker_span(
spans: &mut Vec<Span<'static>>,
selected: bool,
remaining: &mut usize,
) {
let (marker, style) = if selected {
(
"",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
} else {
("", Style::default().fg(Color::DarkGray))
};
push_bounded_span(spans, marker, style, remaining);
}
pub(super) fn panel_ticket_detail(row: &PanelRow) -> String {
if row.kind == PanelRowKind::InvalidTicket {
let mut parts = vec![panel_ticket_reference(row), "Gate: unavailable".to_string()];
if let Some(reason) = panel_ticket_reason(row) {
parts.push(format!("Reason: {reason}"));
}
return parts.join(" · ");
}
if row.kind == PanelRowKind::TicketIntakeWorker {
let mut parts = row
.subtitle
.as_ref()
.map(|subtitle| vec![subtitle.clone()])
.unwrap_or_else(|| vec![panel_ticket_reference(row)]);
if let Some(action) = row.next_action {
parts.push(format!("Action: {}", action.label()));
}
if let Some(reason) = panel_ticket_reason(row) {
parts.push(format!("Reason: {reason}"));
}
return parts.join(" · ");
}
let mut parts = vec![panel_ticket_reference(row)];
if let Some(overlay_detail) = panel_ticket_overlay_detail(row) {
parts.push(overlay_detail);
}
if let Some(blocked_reason) = row
.ticket
.as_ref()
.and_then(|ticket| ticket.blocked_reason.as_deref())
{
parts.push(format!("Dependencies: {blocked_reason}"));
} else {
parts.push("Gate: clear".to_string());
}
if let Some(action) = row.next_action {
parts.push(format!(
"Action: {}",
panel_ticket_action_label(row, action)
));
}
if let Some(reason) = panel_ticket_reason(row) {
parts.push(format!("Reason: {reason}"));
}
parts.join(" · ")
}
pub(super) fn panel_ticket_action_label(row: &PanelRow, action: NextUserAction) -> &'static str {
if action == NextUserAction::Wait
&& row
.ticket
.as_ref()
.and_then(|ticket| ticket.blocked_reason.as_ref())
.is_some()
{
"queue disabled"
} else {
action.label()
}
}
pub(super) fn panel_ticket_overlay_detail(row: &PanelRow) -> Option<String> {
let ticket = row.ticket.as_ref()?;
let overlay = ticket.orchestration_overlay.as_ref()?;
let mut detail = format!(
"Overlay: local {} · {} {}",
ticket.workflow_state.as_str(),
overlay.source,
overlay.workflow_state.as_str()
);
if matches!(
overlay.workflow_state,
TicketWorkflowState::Done | TicketWorkflowState::Closed
) {
detail.push_str(" · merge pending");
}
Some(detail)
}
pub(super) fn panel_ticket_reason(row: &PanelRow) -> Option<&str> {
row.disabled_reason
.as_deref()
.or_else(|| row.key_hint.as_deref())
}
pub(super) fn ticket_detail_style(row: &PanelRow) -> Style {
if row.kind == PanelRowKind::InvalidTicket {
return Style::default().fg(Color::Yellow);
}
if row
.ticket
.as_ref()
.and_then(|ticket| ticket.blocked_reason.as_ref())
.is_some()
{
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
}
}
pub(super) fn panel_ticket_reference(row: &PanelRow) -> String {
row.ticket
.as_ref()
.map(|ticket| {
ticket
.resource_key
.clone()
.unwrap_or_else(|| "resource key unavailable".to_string())
})
.unwrap_or_else(|| match &row.key {
PanelRowKey::Ticket(id) | PanelRowKey::InvalidTicket(id) => id.clone(),
PanelRowKey::TicketIntakeWorker { ticket_id, .. } => ticket_id.clone(),
PanelRowKey::Worker(name) => name.clone(),
})
}
pub(super) fn push_column_span(
spans: &mut Vec<Span<'static>>,
value: &str,
column_width: usize,
style: Style,
remaining: &mut usize,
) {
if *remaining == 0 {
return;
}
let mut content = padded_cell(value, column_width);
content.push(' ');
push_bounded_span(spans, &content, style, remaining);
}
pub(super) fn push_bounded_span(
spans: &mut Vec<Span<'static>>,
value: &str,
style: Style,
remaining: &mut usize,
) {
if *remaining == 0 || value.is_empty() {
return;
}
let content = truncate_with_ellipsis(value, *remaining);
*remaining = remaining.saturating_sub(content.width());
spans.push(Span::styled(content, style));
}
pub(super) fn padded_cell(value: &str, width: usize) -> String {
let mut cell = truncate_with_ellipsis(value, width);
let padding = width.saturating_sub(cell.width());
cell.extend(std::iter::repeat_n(' ', padding));
cell
}
pub(super) fn panel_priority_style(priority: ActionPriority) -> Style {
match priority {
ActionPriority::ReadyForQueue => Style::default().fg(Color::Green),
ActionPriority::ActiveWork => Style::default().fg(Color::Cyan),
ActionPriority::Background => Style::default().fg(Color::DarkGray),
}
}
pub(super) fn intake_status_style(status: &str) -> Style {
match status {
"live" => Style::default().fg(Color::Green),
"restorable" => Style::default().fg(Color::Yellow),
"stale" => Style::default().fg(Color::DarkGray),
_ => Style::default().fg(Color::Cyan),
}
}
pub(super) fn section_rows(
list: &WorkerList,
section: &DashboardSection,
selected: Option<&PanelRowKey>,
width: u16,
) -> Vec<PanelListRow> {
let visible = visible_section_indices(section);
if visible.is_empty() {
return Vec::new();
}
let mut rows = Vec::with_capacity(visible.len() + 1);
rows.push(PanelListRow::inert(section_header_line(
section.kind,
section.entries.len(),
section.hidden_count(),
width,
)));
for index in visible {
if let Some(entry) = list.entries.get(index) {
let key = PanelRowKey::Worker(entry.name.clone());
let selected = selected == Some(&key);
rows.push(PanelListRow::selectable(
row_line(entry, selected, width),
key,
));
}
}
rows
}
pub(super) fn row_line(entry: &WorkerListEntry, selected: bool, width: u16) -> Line<'static> {
let marker = if selected { "" } else { " " };
let name_style = if selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
let (status, status_style) = row_status_label(entry);
let mut spans = Vec::new();
let mut remaining = width as usize;
push_bounded_span(
&mut spans,
marker,
if selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
},
&mut remaining,
);
push_column_span(
&mut spans,
status,
POD_STATUS_COLUMN_WIDTH,
status_style,
&mut remaining,
);
push_bounded_span(&mut spans, entry.name.as_str(), name_style, &mut remaining);
Line::from(spans)
}
pub(super) fn draw_separator(frame: &mut Frame<'_>, area: Rect) {
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"".repeat(area.width as usize),
Style::default().fg(Color::DarkGray),
))),
area,
);
}
pub(super) fn draw_target_status(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) {
frame.render_widget(Paragraph::new(target_status_line(app)), area);
}
pub(super) fn target_status_line(_app: &DashboardApp) -> Line<'static> {
Line::from(Span::raw(""))
}
pub(super) fn draw_input(frame: &mut Frame<'_>, render: &crate::input::InputRender, area: Rect) {
let mut lines: Vec<Line<'static>> = Vec::with_capacity(render.lines.len());
for (i, src) in render.lines.iter().enumerate() {
let absolute_row = render.viewport_start_row as usize + i;
let prefix = if absolute_row == 0 { "> " } else { " " };
let mut spans = vec![Span::styled(prefix, Style::default().fg(Color::DarkGray))];
spans.extend(src.spans.iter().cloned());
lines.push(Line::from(spans));
}
frame.render_widget(Paragraph::new(lines), area);
let cursor_x = area.x + 2 + render.cursor_col;
let cursor_y = area.y + render.cursor_row;
if cursor_y < area.y + area.height {
frame.set_cursor_position(Position::new(cursor_x, cursor_y));
}
}
pub(super) fn actionbar_left_text(app: &DashboardApp) -> String {
if app.sending && app.composer_target() == ComposerTarget::TicketIntake {
"launching Ticket Intake…".to_string()
} else if app.sending {
"working…".to_string()
} else if app.refreshing {
match app.notice.as_deref() {
Some(notice) if notice.contains("Refreshing") || notice.contains("refreshing") => {
notice.to_string()
}
Some(notice) => format!("{notice} Refreshing workspace…"),
None => "Refreshing workspace…".to_string(),
}
} else if let Some(notice) = app.notice.as_deref() {
notice.to_string()
} else {
String::new()
}
}
pub(super) fn actionbar_right_text(app: &DashboardApp) -> &'static str {
if app.panel_diagnostic_open {
"F2/Esc close details"
} else if app.panel_diagnostic.is_some() {
"F2 details"
} else {
""
}
}
pub(super) fn draw_actionbar(frame: &mut Frame<'_>, app: &DashboardApp, area: Rect) {
let left = actionbar_left_text(app);
let right = actionbar_right_text(app);
let left_width = area
.width
.saturating_sub(right.width() as u16)
.saturating_sub(2) as usize;
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
truncate_with_ellipsis(&left, left_width),
Style::default().fg(Color::DarkGray),
))),
area,
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
right,
Style::default().fg(Color::DarkGray),
)))
.alignment(ratatui::layout::Alignment::Right),
area,
);
}
pub(super) fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
if s.width() <= max_width {
return s.to_string();
}
if max_width == 1 {
return "".to_string();
}
let mut out = String::new();
let mut width = 0usize;
for c in s.chars() {
let cw = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if width + cw > max_width - 1 {
break;
}
out.push(c);
width += cw;
}
out.push('…');
out
}
File diff suppressed because it is too large Load Diff
+381 -74
View File
@@ -15,6 +15,64 @@ use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use unicode_width::UnicodeWidthChar; use unicode_width::UnicodeWidthChar;
pub const MAX_PLAIN_TEXT_PASTE_CHARS: usize = 50;
pub const MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PasteMeasurement {
pub chars: usize,
pub logical_lines: usize,
}
impl PasteMeasurement {
pub fn presentation(self) -> PastePresentation {
if self.chars <= MAX_PLAIN_TEXT_PASTE_CHARS
&& self.logical_lines <= MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
{
PastePresentation::Text
} else {
PastePresentation::Chip
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PastePresentation {
Text,
Chip,
}
pub fn measure_paste(content: &str) -> PasteMeasurement {
PasteMeasurement {
chars: content.chars().count(),
logical_lines: logical_line_count(content),
}
}
/// Empty content has zero logical lines. Otherwise LF, lone CR, and CRLF each
/// advance one line; a CRLF pair is one break rather than two.
pub fn logical_line_count(content: &str) -> usize {
if content.is_empty() {
return 0;
}
let mut lines = 1;
let mut chars = content.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
lines += 1;
}
'\n' => lines += 1,
_ => {}
}
}
lines
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct PasteRef { pub struct PasteRef {
pub id: u32, pub id: u32,
@@ -61,6 +119,7 @@ impl FlowRefAtom {
pub enum Atom { pub enum Atom {
Char(char), Char(char),
Paste(PasteRef), Paste(PasteRef),
PasteArtifact(protocol::PasteArtifactRef),
FileRef(FileRefAtom), FileRef(FileRefAtom),
FlowRef(FlowRefAtom), FlowRef(FlowRefAtom),
} }
@@ -72,6 +131,18 @@ impl Atom {
match self { match self {
Atom::Char(_) => None, Atom::Char(_) => None,
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())), Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
Atom::PasteArtifact(artifact) => Some((
Style::default().fg(Color::Magenta),
format!(
"[Paste artifact {} | {} chars, {} lines, {}, {}, created {} ms]",
artifact.artifact_id,
artifact.char_count,
artifact.line_count,
artifact.media_type.as_str(),
artifact.availability.as_str(),
artifact.created_at_ms
),
)),
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())), Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())),
Atom::FlowRef(r) => Some((Style::default().fg(Color::Yellow), r.label())), Atom::FlowRef(r) => Some((Style::default().fg(Color::Yellow), r.label())),
} }
@@ -102,7 +173,9 @@ enum WordKind {
fn atom_class(atom: &Atom) -> AtomClass { fn atom_class(atom: &Atom) -> AtomClass {
match atom { match atom {
Atom::Char(c) => char_class(*c), Atom::Char(c) => char_class(*c),
Atom::Paste(_) | Atom::FileRef(_) | Atom::FlowRef(_) => AtomClass::Chip, Atom::Paste(_) | Atom::PasteArtifact(_) | Atom::FileRef(_) | Atom::FlowRef(_) => {
AtomClass::Chip
}
} }
} }
@@ -190,6 +263,9 @@ impl InputBuffer {
content: content.clone(), content: content.clone(),
})); }));
} }
protocol::Segment::PasteArtifact { artifact } => {
self.atoms.push(Atom::PasteArtifact(artifact.clone()));
}
protocol::Segment::FileRef { path } => { protocol::Segment::FileRef { path } => {
self.atoms self.atoms
.push(Atom::FileRef(FileRefAtom { path: path.clone() })); .push(Atom::FileRef(FileRefAtom { path: path.clone() }));
@@ -225,6 +301,13 @@ impl InputBuffer {
match atom { match atom {
Atom::Char(c) => text.push(*c), Atom::Char(c) => text.push(*c),
Atom::Paste(paste) => text.push_str(&paste.content), Atom::Paste(paste) => text.push_str(&paste.content),
Atom::PasteArtifact(artifact) => {
text.push_str(&protocol::Segment::flatten_to_text(&[
protocol::Segment::PasteArtifact {
artifact: artifact.clone(),
},
]))
}
Atom::FileRef(file) => text.push_str(&file.path), Atom::FileRef(file) => text.push_str(&file.path),
Atom::FlowRef(flow) => text.push_str(&flow.selector), Atom::FlowRef(flow) => text.push_str(&flow.selector),
} }
@@ -237,16 +320,20 @@ impl InputBuffer {
} }
pub fn insert_paste(&mut self, content: String) { pub fn insert_paste(&mut self, content: String) {
let measurement = measure_paste(&content);
if measurement.presentation() == PastePresentation::Text {
self.insert_str(&content);
return;
}
let id = self.next_paste_id; let id = self.next_paste_id;
self.next_paste_id = self.next_paste_id.wrapping_add(1); self.next_paste_id = self.next_paste_id.wrapping_add(1);
let chars = content.chars().count();
let lines = content.lines().count().max(1);
self.atoms.insert( self.atoms.insert(
self.cursor, self.cursor,
Atom::Paste(PasteRef { Atom::Paste(PasteRef {
id, id,
chars, chars: measurement.chars,
lines, lines: measurement.logical_lines,
content, content,
}), }),
); );
@@ -395,80 +482,78 @@ impl InputBuffer {
self.cursor = 0; self.cursor = 0;
} }
fn logical_line_ranges(&self) -> Vec<(usize, usize)> {
let mut ranges = Vec::new();
let mut start = 0;
let mut index = 0;
while index < self.atoms.len() {
let break_len = match self.atoms[index] {
Atom::Char('\r') => {
if matches!(self.atoms.get(index + 1), Some(Atom::Char('\n'))) {
2
} else {
1
}
}
Atom::Char('\n') => 1,
_ => {
index += 1;
continue;
}
};
ranges.push((start, index));
index += break_len;
start = index;
}
ranges.push((start, self.atoms.len()));
ranges
}
fn logical_line_and_col(&self) -> (Vec<(usize, usize)>, usize, usize) {
let ranges = self.logical_line_ranges();
for (line, &(start, end)) in ranges.iter().enumerate() {
if self.cursor <= end {
return (ranges, line, self.cursor.saturating_sub(start));
}
if let Some(&(next_start, _)) = ranges.get(line + 1)
&& self.cursor < next_start
{
return (ranges, line + 1, 0);
}
}
let line = ranges.len().saturating_sub(1);
let col = self.cursor.saturating_sub(ranges[line].0);
(ranges, line, col)
}
pub fn move_home(&mut self) { pub fn move_home(&mut self) {
while self.cursor > 0 { let (ranges, line, _) = self.logical_line_and_col();
if matches!(self.atoms[self.cursor - 1], Atom::Char('\n')) { self.cursor = ranges[line].0;
break;
}
self.cursor -= 1;
}
} }
pub fn move_end(&mut self) { pub fn move_end(&mut self) {
while self.cursor < self.atoms.len() { let (ranges, line, _) = self.logical_line_and_col();
if matches!(self.atoms[self.cursor], Atom::Char('\n')) { self.cursor = ranges[line].1;
break;
}
self.cursor += 1;
}
} }
/// Move one logical line up, preserving column (atom count from /// Move one logical line up, preserving column (atom count from
/// current line start). No-op if already on the first line. /// current line start). No-op if already on the first line.
pub fn move_up(&mut self) { pub fn move_up(&mut self) {
let (line_start, col) = self.line_start_and_col(); let (ranges, line, col) = self.logical_line_and_col();
if line_start == 0 { if line == 0 {
return; return;
} }
// `atoms[line_start - 1]` is the '\n' that opens the current let (start, end) = ranges[line - 1];
// line; find the previous line's start. self.cursor = start + col.min(end - start);
let prev_end = line_start - 1;
let mut prev_start = 0;
for i in (0..prev_end).rev() {
if matches!(self.atoms[i], Atom::Char('\n')) {
prev_start = i + 1;
break;
}
}
let prev_len = prev_end - prev_start;
self.cursor = prev_start + col.min(prev_len);
} }
/// Move one logical line down, preserving column. /// Move one logical line down, preserving column.
pub fn move_down(&mut self) { pub fn move_down(&mut self) {
let (line_start, col) = self.line_start_and_col(); let (ranges, line, col) = self.logical_line_and_col();
// End of current line. let Some(&(start, end)) = ranges.get(line + 1) else {
let mut cur_end = self.atoms.len(); return;
for i in line_start..self.atoms.len() { };
if matches!(self.atoms[i], Atom::Char('\n')) { self.cursor = start + col.min(end - start);
cur_end = i;
break;
}
}
if cur_end == self.atoms.len() {
return; // no next line
}
let next_start = cur_end + 1;
let mut next_end = self.atoms.len();
for i in next_start..self.atoms.len() {
if matches!(self.atoms[i], Atom::Char('\n')) {
next_end = i;
break;
}
}
let next_len = next_end - next_start;
self.cursor = next_start + col.min(next_len);
}
fn line_start_and_col(&self) -> (usize, usize) {
let mut start = 0;
for i in (0..self.cursor).rev() {
if matches!(self.atoms[i], Atom::Char('\n')) {
start = i + 1;
break;
}
}
(start, self.cursor - start)
} }
/// Build the typed `Vec<Segment>` sent over the protocol. Adjacent /// Build the typed `Vec<Segment>` sent over the protocol. Adjacent
@@ -497,6 +582,12 @@ impl InputBuffer {
content: p.content.clone(), content: p.content.clone(),
}); });
} }
Atom::PasteArtifact(artifact) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::PasteArtifact {
artifact: artifact.clone(),
});
}
Atom::FileRef(r) => { Atom::FileRef(r) => {
flush_text(&mut buf, &mut out); flush_text(&mut buf, &mut out);
out.push(protocol::Segment::FileRef { out.push(protocol::Segment::FileRef {
@@ -535,6 +626,7 @@ impl InputBuffer {
let mut cursor_row: u16 = 0; let mut cursor_row: u16 = 0;
let mut cursor_col: u16 = 0; let mut cursor_col: u16 = 0;
let mut cursor_set = false; let mut cursor_set = false;
let mut previous_was_cr = false;
// Record cursor once, at the point right before `atom` would be // Record cursor once, at the point right before `atom` would be
// placed — accounting for a wrap that the atom itself will cause. // placed — accounting for a wrap that the atom itself will cause.
@@ -558,7 +650,7 @@ impl InputBuffer {
for (i, atom) in self.atoms.iter().enumerate() { for (i, atom) in self.atoms.iter().enumerate() {
if !cursor_set && i == self.cursor { if !cursor_set && i == self.cursor {
let leading = match atom { let leading = match atom {
Atom::Char('\n') => 0, Atom::Char('\n' | '\r') => 0,
Atom::Char(c) => UnicodeWidthChar::width(*c).unwrap_or(0), Atom::Char(c) => UnicodeWidthChar::width(*c).unwrap_or(0),
other => other other => other
.chip() .chip()
@@ -573,6 +665,21 @@ impl InputBuffer {
} }
match atom { match atom {
Atom::Char('\r') => {
flush_pending(
&mut pending,
&mut pending_width,
pending_style,
&mut rows,
&mut row_width,
);
rows.push(Vec::new());
row_width = 0;
previous_was_cr = true;
}
Atom::Char('\n') if previous_was_cr => {
previous_was_cr = false;
}
Atom::Char('\n') => { Atom::Char('\n') => {
flush_pending( flush_pending(
&mut pending, &mut pending,
@@ -583,8 +690,10 @@ impl InputBuffer {
); );
rows.push(Vec::new()); rows.push(Vec::new());
row_width = 0; row_width = 0;
previous_was_cr = false;
} }
Atom::Char(c) => { Atom::Char(c) => {
previous_was_cr = false;
let cw = UnicodeWidthChar::width(*c).unwrap_or(0); let cw = UnicodeWidthChar::width(*c).unwrap_or(0);
if pending_style != text_style && !pending.is_empty() { if pending_style != text_style && !pending.is_empty() {
flush_pending( flush_pending(
@@ -608,6 +717,7 @@ impl InputBuffer {
); );
} }
other => { other => {
previous_was_cr = false;
let (chip_style, label) = other.chip().expect("non-char atom has a chip"); let (chip_style, label) = other.chip().expect("non-char atom has a chip");
if pending_style != chip_style && !pending.is_empty() { if pending_style != chip_style && !pending.is_empty() {
flush_pending( flush_pending(
@@ -848,6 +958,161 @@ mod render_viewport_tests {
} }
} }
#[cfg(test)]
mod paste_policy_tests {
use super::*;
use protocol::Segment;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Fixture {
max_plain_text_chars: usize,
max_plain_text_logical_lines: usize,
cases: Vec<FixtureCase>,
}
#[derive(Debug, Deserialize)]
struct FixtureCase {
name: String,
parts: Vec<FixturePart>,
char_count: usize,
logical_line_count: usize,
presentation: FixturePresentation,
}
#[derive(Debug, Deserialize)]
struct FixturePart {
value: String,
repeat: usize,
}
#[derive(Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum FixturePresentation {
Text,
Chip,
}
fn fixture() -> Fixture {
serde_json::from_str(include_str!(
"../../../tests/fixtures/composer-paste-policy.json"
))
.expect("shared composer paste policy fixture must be valid")
}
fn fixture_content(case: &FixtureCase) -> String {
case.parts
.iter()
.map(|part| part.value.repeat(part.repeat))
.collect()
}
#[test]
fn tui_follows_shared_paste_presentation_contract() {
let fixture = fixture();
assert_eq!(fixture.max_plain_text_chars, MAX_PLAIN_TEXT_PASTE_CHARS);
assert_eq!(
fixture.max_plain_text_logical_lines,
MAX_PLAIN_TEXT_PASTE_LOGICAL_LINES
);
for case in fixture.cases {
let content = fixture_content(&case);
let measurement = measure_paste(&content);
let expected_presentation = match case.presentation {
FixturePresentation::Text => PastePresentation::Text,
FixturePresentation::Chip => PastePresentation::Chip,
};
assert_eq!(measurement.chars, case.char_count, "{} chars", case.name);
assert_eq!(
measurement.logical_lines, case.logical_line_count,
"{} logical lines",
case.name
);
assert_eq!(
measurement.presentation(),
expected_presentation,
"{} presentation",
case.name
);
}
}
#[test]
fn short_paste_is_editable_text_at_the_cursor() {
let mut buffer = InputBuffer::new();
buffer.insert_str("ac");
buffer.move_left();
buffer.insert_paste("b".to_owned());
assert_eq!(buffer.plain_text(), "abc");
assert!(
buffer
.atoms
.iter()
.all(|atom| matches!(atom, Atom::Char(_)))
);
assert_eq!(
buffer.submit_segments(),
vec![Segment::text("abc".to_owned())]
);
}
#[test]
fn short_multiline_paste_preserves_original_line_endings_as_text() {
let content = "ab\r\ncd\ref";
let mut buffer = InputBuffer::new();
buffer.insert_paste(content.to_owned());
assert_eq!(buffer.plain_text(), content);
assert!(
buffer
.atoms
.iter()
.all(|atom| matches!(atom, Atom::Char(_)))
);
assert_eq!(
buffer.submit_segments(),
vec![Segment::text(content.to_owned())]
);
let rendered: Vec<String> = buffer
.render(80)
.lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
})
.collect();
assert_eq!(rendered, vec!["ab", "cd", "ef"]);
buffer.move_up();
assert_eq!(buffer.cursor, 6);
buffer.move_up();
assert_eq!(buffer.cursor, 2);
buffer.move_down();
assert_eq!(buffer.cursor, 6);
buffer.move_home();
assert_eq!(buffer.cursor, 4);
buffer.move_end();
assert_eq!(buffer.cursor, 6);
}
#[test]
fn empty_paste_is_a_noop() {
let mut buffer = InputBuffer::new();
buffer.insert_str("unchanged");
let paste_id = buffer.next_paste_id;
buffer.insert_paste(String::new());
assert_eq!(buffer.plain_text(), "unchanged");
assert_eq!(buffer.next_paste_id, paste_id);
}
}
#[cfg(test)] #[cfg(test)]
mod submit_segments_tests { mod submit_segments_tests {
use super::*; use super::*;
@@ -873,7 +1138,8 @@ mod submit_segments_tests {
for c in "see ".chars() { for c in "see ".chars() {
buf.insert_char(c); buf.insert_char(c);
} }
buf.insert_paste("line1\nline2".into()); let pasted = "line1\nline2\nline3\nline4";
buf.insert_paste(pasted.into());
for c in " end".chars() { for c in " end".chars() {
buf.insert_char(c); buf.insert_char(c);
} }
@@ -890,9 +1156,9 @@ mod submit_segments_tests {
content, content,
.. ..
} => { } => {
assert_eq!(content, "line1\nline2"); assert_eq!(content, pasted);
assert_eq!(*chars, "line1\nline2".chars().count() as u32); assert_eq!(*chars, pasted.chars().count() as u32);
assert_eq!(*lines, 2); assert_eq!(*lines, 4);
} }
other => panic!("expected Paste, got {other:?}"), other => panic!("expected Paste, got {other:?}"),
} }
@@ -902,6 +1168,45 @@ mod submit_segments_tests {
} }
} }
#[test]
fn restored_direct_paste_remains_a_typed_segment_without_reclassification() {
let original = Segment::Paste {
id: 7,
chars: 1,
lines: 1,
content: "x".to_owned(),
};
let mut buf = InputBuffer::new();
buf.replace_with_segments(std::slice::from_ref(&original));
assert_eq!(buf.submit_segments(), vec![original]);
}
#[test]
fn restored_paste_artifact_remains_a_typed_segment() {
let artifact = protocol::PasteArtifactRef {
artifact_id: "019ca7c8-57b6-7f05-8edf-524147aba7b2".to_string(),
created_at_ms: 1_700_000_000_000,
media_type: protocol::PasteArtifactMediaType::TextPlainUtf8,
availability: protocol::PasteArtifactAvailability::Available,
byte_len: 65_536,
char_count: 65_530,
line_count: 200,
sha256: "a".repeat(64),
source_entry_id: "entry-1".to_string(),
};
let original = Segment::PasteArtifact {
artifact: artifact.clone(),
};
let mut buf = InputBuffer::new();
buf.replace_with_segments(std::slice::from_ref(&original));
assert_eq!(
buf.submit_segments(),
vec![Segment::PasteArtifact { artifact }]
);
}
#[test] #[test]
fn empty_buffer_yields_empty_segments() { fn empty_buffer_yields_empty_segments() {
let buf = InputBuffer::new(); let buf = InputBuffer::new();
@@ -911,7 +1216,7 @@ mod submit_segments_tests {
#[test] #[test]
fn leading_paste_does_not_emit_empty_text() { fn leading_paste_does_not_emit_empty_text() {
let mut buf = InputBuffer::new(); let mut buf = InputBuffer::new();
buf.insert_paste("X".into()); buf.insert_paste("X".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
let segs = buf.submit_segments(); let segs = buf.submit_segments();
assert_eq!(segs.len(), 1); assert_eq!(segs.len(), 1);
assert!(matches!(segs[0], Segment::Paste { .. })); assert!(matches!(segs[0], Segment::Paste { .. }));
@@ -1011,7 +1316,7 @@ mod completion_prefix_tests {
#[test] #[test]
fn trigger_after_chip_atom() { fn trigger_after_chip_atom() {
let mut buf = InputBuffer::new(); let mut buf = InputBuffer::new();
buf.insert_paste("X".into()); buf.insert_paste("X".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
for c in "@sr".chars() { for c in "@sr".chars() {
buf.insert_char(c); buf.insert_char(c);
} }
@@ -1120,7 +1425,7 @@ mod word_motion_tests {
for c in "foo ".chars() { for c in "foo ".chars() {
buf.insert_char(c); buf.insert_char(c);
} }
buf.insert_paste("anything".into()); buf.insert_paste("anything".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
for c in " bar".chars() { for c in " bar".chars() {
buf.insert_char(c); buf.insert_char(c);
} }
@@ -1219,7 +1524,9 @@ mod word_motion_tests {
for a in &buf.atoms { for a in &buf.atoms {
match a { match a {
Atom::Char(c) => out.push(*c), Atom::Char(c) => out.push(*c),
Atom::Paste(_) | Atom::FileRef(_) | Atom::FlowRef(_) => out.push_str("<P>"), Atom::Paste(_) | Atom::PasteArtifact(_) | Atom::FileRef(_) | Atom::FlowRef(_) => {
out.push_str("<P>")
}
} }
} }
out out
@@ -1277,7 +1584,7 @@ mod word_motion_tests {
for c in "foo ".chars() { for c in "foo ".chars() {
buf.insert_char(c); buf.insert_char(c);
} }
buf.insert_paste("anything".into()); buf.insert_paste("anything".repeat(MAX_PLAIN_TEXT_PASTE_CHARS + 1));
for c in " bar".chars() { for c in " bar".chars() {
buf.insert_char(c); buf.insert_char(c);
} }
+77 -96
View File
@@ -8,24 +8,20 @@ mod command;
mod composer_history; mod composer_history;
mod composer_keys; mod composer_keys;
mod console; mod console;
mod dashboard;
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
mod e2e_observer; mod e2e_observer;
mod input; mod input;
pub mod keys; pub mod keys;
mod markdown; mod markdown;
mod picker;
mod role_session_registry;
mod scroll; mod scroll;
pub mod setup_model; pub mod setup_model;
mod spawn; mod standalone_picker;
mod standalone_spawn;
mod task; mod task;
mod text_selection; mod text_selection;
mod tool; mod tool;
mod ui; mod ui;
mod view_mode; mod view_mode;
mod worker_list;
mod workspace_panel;
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
@@ -34,7 +30,6 @@ use std::process::ExitCode;
use crossterm::event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste}; use crossterm::event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste};
use crossterm::execute; use crossterm::execute;
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}; use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
use session_store::SegmentId;
use client::{Target, WorkerConnectionSelector, WorkerListRequest}; use client::{Target, WorkerConnectionSelector, WorkerListRequest};
@@ -47,42 +42,69 @@ pub struct LaunchOptions {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum LaunchMode { pub enum LaunchMode {
/// Start one client-owned in-process Standalone Worker.
Spawn { Spawn {
worker_name: Option<String>, worker_name: Option<String>,
profile: Option<String>, profile: Option<String>,
}, },
/// `yoi --worker <name>`: attach to a live Worker by name if possible; /// Restore one client-owned standalone Worker. The current cwd is the default scope;
/// otherwise launch the Worker runtime command with `--worker <name>` so it /// `include_all` opts into all standalone Workers under the same client data root.
/// resumes from name-keyed state or creates a fresh same-name Worker. StandaloneResume { include_all: bool },
WorkerName { /// List Backend Workers and attach to the selected Worker.
worker_name: String,
socket_override: Option<PathBuf>,
},
/// `yoi workers` / `yoi --backend <url>`: list workers through the selected
/// connection target, then attach to the selected Worker.
Workers { Workers {
runtime_id: Option<String>, runtime_id: Option<String>,
include_stopped: bool, include_stopped: bool,
all: bool,
}, },
/// `yoi --backend <url> --runtime-id <id> --worker-id <id>`: open one Worker /// Open one Backend Worker through the selected connection target.
/// through the selected connection target.
OpenWorker { OpenWorker {
runtime_id: String, runtime_id: String,
worker_id: String, worker_id: String,
}, },
/// `yoi resume`: open the Worker picker, then attach to the selected live Worker /// Open the Backend Workspace dashboard.
/// or restore the selected stopped Worker by name. Without `--all`, the picker Panel,
/// is scoped to the current runtime workspace. }
Resume { all: bool },
/// `yoi --session <UUID>`: skip the picker, go straight to the struct TerminalModeGuard {
/// resume name dialog with `id` baked in. active: bool,
ResumeWithSession { }
id: SegmentId,
worker_name: Option<String>, impl TerminalModeGuard {
}, fn new() -> Self {
/// `yoi panel`: open the workspace Dashboard from the current workspace. Self { active: true }
Panel { include_stopped: bool }, }
fn restore(&mut self) -> io::Result<()> {
if !self.active {
return Ok(());
}
self.active = false;
let mut stdout = io::stdout();
execute!(
stdout,
DisableMouseCapture,
LeaveAlternateScreen,
DisableBracketedPaste,
crossterm::cursor::Show
)?;
disable_raw_mode()
}
}
impl Drop for TerminalModeGuard {
fn drop(&mut self) {
if self.active {
let mut stdout = io::stdout();
let _ = execute!(
stdout,
DisableMouseCapture,
LeaveAlternateScreen,
DisableBracketedPaste,
crossterm::cursor::Show
);
let _ = disable_raw_mode();
self.active = false;
}
}
} }
pub async fn launch(options: LaunchOptions) -> ExitCode { pub async fn launch(options: LaunchOptions) -> ExitCode {
@@ -109,56 +131,46 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
eprintln!("yoi: {e}"); eprintln!("yoi: {e}");
return ExitCode::FAILURE; return ExitCode::FAILURE;
} }
let mut terminal_mode = TerminalModeGuard::new();
let result = match mode { let result = match mode {
LaunchMode::Spawn { LaunchMode::Spawn {
worker_name, worker_name,
profile, profile,
} => match target.spawn_worker() { } => match standalone_spawn::select(&workspace_root, worker_name, profile) {
Ok(Some(selection)) => match target.spawn_worker() {
Ok(spawn) => { Ok(spawn) => {
console::run_spawn(None, worker_name, profile, spawn.runtime_command).await console::run_standalone(
} workspace_root.clone(),
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>), spawn.state_dir,
}, Some(selection.worker_name),
LaunchMode::WorkerName { Some(selection.profile),
worker_name,
socket_override,
} => match target.worker_by_name() {
Ok(worker_by_name) => {
console::run_worker_name(
worker_name,
socket_override,
worker_by_name.runtime_command,
) )
.await .await
} }
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>), Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
}, },
Ok(None) => Ok(()),
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
},
LaunchMode::StandaloneResume { include_all } => {
match standalone_picker::pick(target.as_ref(), include_all) {
Ok(Some(intent)) => console::run_standalone_restore(intent).await,
Ok(None) => Ok(()),
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
}
}
LaunchMode::Workers { LaunchMode::Workers {
runtime_id, runtime_id,
include_stopped, include_stopped,
all,
} => match target.list_workers(if include_stopped { } => match target.list_workers(if include_stopped {
WorkerListRequest::with_stopped(runtime_id) WorkerListRequest::with_stopped(runtime_id)
} else { } else {
WorkerListRequest::new(runtime_id) WorkerListRequest::new(runtime_id)
}) { }) {
Ok(worker_list) => { Ok(worker_list) => {
if let Some(target) = worker_list.backend_target { backend_worker_picker::run(worker_list.backend_target, worker_list.include_stopped)
backend_worker_picker::run(target, worker_list.include_stopped).await
} else if let Some(runtime_command) = worker_list.local_runtime_command {
console::run_worker_picker(
runtime_command,
workspace_root.clone(),
all,
worker_list.include_stopped,
)
.await .await
} else {
Err(Box::new(io::Error::other(
"worker list target did not include a local or backend source",
)) as Box<dyn std::error::Error>)
}
} }
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>), Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
}, },
@@ -169,28 +181,12 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
Ok(connection) => console::run_backend_runtime(connection.target).await, Ok(connection) => console::run_backend_runtime(connection.target).await,
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>), Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
}, },
LaunchMode::Resume { all } => match target.resume_worker() { LaunchMode::Panel => match target.dashboard() {
Ok(resume) => { Ok(dashboard) => {
console::run_resume(resume.runtime_command, workspace_root.clone(), all).await backend_dashboard::launch(dashboard.base_url, dashboard.workspace_id).await
} }
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>), Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
}, },
LaunchMode::ResumeWithSession { id, worker_name } => match target.spawn_worker() {
Ok(spawn) => {
console::run_spawn(Some(id), worker_name, None, spawn.runtime_command).await
}
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},
LaunchMode::Panel { include_stopped } => match target.dashboard() {
Ok(client::Dashboard::Local { runtime_command }) => {
dashboard::launch(runtime_command, include_stopped).await
}
Ok(client::Dashboard::Backend {
base_url,
workspace_id,
}) => backend_dashboard::launch(base_url, workspace_id).await,
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
},
}; };
// Always restore the terminal first so any pending eprintln below // Always restore the terminal first so any pending eprintln below
@@ -198,15 +194,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
// alternate-screen buffer. // alternate-screen buffer.
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({})); e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({}));
let mut stdout = io::stdout(); let _ = terminal_mode.restore();
let _ = execute!(
stdout,
DisableMouseCapture,
LeaveAlternateScreen,
DisableBracketedPaste
);
let _ = disable_raw_mode();
let _ = execute!(stdout, crossterm::cursor::Show);
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({})); e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({}));
@@ -217,14 +205,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
ExitCode::SUCCESS ExitCode::SUCCESS
} }
Err(e) => { Err(e) => {
// SpawnError has already been painted into the inline
// viewport's final frame, so it's already visible in the
// user's scrollback — printing it again would be a noisy
// duplicate. Other errors (worker-name failures, terminal setup
// hiccups, etc.) need surfacing here.
if e.downcast_ref::<spawn::SpawnError>().is_none() {
eprintln!("yoi: {e}"); eprintln!("yoi: {e}");
}
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" })); e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" }));
ExitCode::FAILURE ExitCode::FAILURE
-525
View File
@@ -1,525 +0,0 @@
//! Inline-viewport "pick a Worker to attach or restore" UX.
//!
//! Reads live Worker allocations from the runtime registry and stopped Worker state
//! from the session-store worker metadata name-keyed metadata. Picking a live row attaches to
//! its socket; picking a stopped row restores via the Worker runtime command.
use std::io;
use std::path::PathBuf;
use std::time::Duration;
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::{Frame, TerminalOptions, Viewport};
use session_store::FsStore;
use session_store::FsWorkerStore;
use crate::worker_list::{
LiveWorkerInfo, StoredMetadataState, StoredWorkerInfo, WorkerList, WorkerListEntry,
WorkerVisibilitySource, live_socket_for_worker as worker_list_live_socket_for_worker,
read_reachable_live_worker_infos, read_stored_worker_infos,
};
const MAX_ROWS: usize = 10;
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
#[derive(Debug)]
pub enum PickerError {
Io(io::Error),
Store(session_store::StoreError),
NoWorkers { all: bool },
}
impl std::fmt::Display for PickerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "io error: {e}"),
Self::Store(e) => write!(f, "session store error: {e}"),
Self::NoWorkers { all: true } => write!(
f,
"no workers found — start a fresh Worker with `yoi` and try again"
),
Self::NoWorkers { all: false } => write!(
f,
"no workers found in this workspace — use `yoi resume --all` to list all host/data-dir Workers"
),
}
}
}
impl std::error::Error for PickerError {}
impl From<io::Error> for PickerError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
impl From<session_store::StoreError> for PickerError {
fn from(e: session_store::StoreError) -> Self {
Self::Store(e)
}
}
pub enum PickerOutcome {
/// User picked a Worker. `socket_override` is set for live rows when the
/// runtime registry knows the exact socket path; stopped rows leave it
/// empty so the caller restores by spawning the Worker runtime command.
Picked {
worker_name: String,
socket_override: Option<PathBuf>,
},
Cancelled,
}
#[derive(Debug, Clone)]
pub(crate) struct PickerOptions {
scope: PickerScope,
include_stopped: bool,
}
impl PickerOptions {
pub(crate) fn workspace(workspace_root: PathBuf) -> Self {
Self {
scope: PickerScope::Workspace(workspace_root),
include_stopped: true,
}
}
pub(crate) fn all() -> Self {
Self {
scope: PickerScope::All,
include_stopped: true,
}
}
pub(crate) fn with_stopped(mut self, include_stopped: bool) -> Self {
self.include_stopped = include_stopped;
self
}
}
#[derive(Debug, Clone)]
enum PickerScope {
Workspace(PathBuf),
All,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WorkerRowState {
Live,
Stopped,
Corrupt,
}
impl WorkerRowState {
fn label(self) -> &'static str {
match self {
Self::Live => "live",
Self::Stopped => "stopped",
Self::Corrupt => "corrupt",
}
}
fn style(self) -> Style {
match self {
Self::Live => Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
Self::Stopped => Style::default().fg(Color::Yellow),
Self::Corrupt => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
}
}
}
fn list_for_options(
options: &PickerOptions,
stored_workers: Vec<StoredWorkerInfo>,
live_workers: Vec<LiveWorkerInfo>,
) -> WorkerList {
let stored_workers = if options.include_stopped {
stored_workers
} else {
Vec::new()
};
match &options.scope {
PickerScope::Workspace(workspace_root) => WorkerList::from_workspace_sources(
WorkerVisibilitySource::ResumePicker,
stored_workers,
live_workers,
None,
MAX_ROWS,
workspace_root,
),
PickerScope::All => WorkerList::from_sources(
WorkerVisibilitySource::ResumePicker,
stored_workers,
live_workers,
None,
MAX_ROWS,
),
}
}
pub async fn run(options: PickerOptions) -> Result<PickerOutcome, PickerError> {
let store_dir = default_store_dir()?;
let store = FsStore::new(&store_dir)?;
let worker_metadata_store =
FsWorkerStore::new(default_worker_metadata_dir()?).map_err(io::Error::other)?;
let stored_workers = read_stored_worker_infos(&store, &worker_metadata_store)?;
let live_workers = read_reachable_live_worker_infos(&store)
.await
.unwrap_or_default();
let mut list = list_for_options(&options, stored_workers, live_workers);
if list.entries.is_empty() {
return Err(PickerError::NoWorkers {
all: matches!(options.scope, PickerScope::All),
});
}
let mut terminal = make_inline_terminal()?;
loop {
terminal.draw(|f| draw(f, &list))?;
match poll_event()? {
None => continue,
Some(Action::Up) => {
let selected = list.selected_index().saturating_sub(1);
list.select_index(selected);
}
Some(Action::Down) => {
let selected = list.selected_index();
if selected + 1 < list.entries.len() {
list.select_index(selected + 1);
}
}
Some(Action::Submit) => {
close_viewport(&mut terminal)?;
let entry = list.selected_entry().expect("non-empty worker list");
return Ok(PickerOutcome::Picked {
worker_name: entry.name.clone(),
socket_override: entry.attach_socket_path().map(PathBuf::from),
});
}
Some(Action::Cancel) => {
close_viewport(&mut terminal)?;
return Ok(PickerOutcome::Cancelled);
}
}
}
}
/// Park the cursor at the very bottom of the picker's inline viewport and emit
/// one newline before dropping the terminal. This keeps any next inline viewport
/// from drawing over the lower picker rows.
fn close_viewport(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
let area = terminal.get_frame().area();
let last_row = area.bottom().saturating_sub(1);
terminal.set_cursor_position((0, last_row))?;
use std::io::Write;
let mut out = io::stdout();
out.write_all(b"\r\n")?;
out.flush()?;
Ok(())
}
fn default_store_dir() -> Result<PathBuf, PickerError> {
manifest::paths::sessions_dir().ok_or_else(|| {
PickerError::Io(io::Error::new(
io::ErrorKind::NotFound,
"could not resolve sessions directory \
(set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)",
))
})
}
fn default_worker_metadata_dir() -> Result<PathBuf, PickerError> {
manifest::paths::data_dir()
.map(|dir| dir.join("workers"))
.ok_or_else(|| {
PickerError::Io(io::Error::new(
io::ErrorKind::NotFound,
"could not resolve worker state directory \
(set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME)",
))
})
}
pub(crate) fn live_socket_for_worker(worker_name: &str) -> Option<PathBuf> {
worker_list_live_socket_for_worker(worker_name)
}
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
let backend = CrosstermBackend::new(io::stdout());
Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(VIEWPORT_LINES),
},
)
}
enum Action {
Up,
Down,
Submit,
Cancel,
}
fn poll_event() -> io::Result<Option<Action>> {
if !event::poll(Duration::from_millis(100))? {
return Ok(None);
}
match event::read()? {
TermEvent::Key(k) if k.kind != KeyEventKind::Release => {
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
Ok(match k.code {
KeyCode::Up => Some(Action::Up),
KeyCode::Down => Some(Action::Down),
KeyCode::Char('k') if !ctrl => Some(Action::Up),
KeyCode::Char('j') if !ctrl => Some(Action::Down),
KeyCode::Enter => Some(Action::Submit),
KeyCode::Esc => Some(Action::Cancel),
KeyCode::Char('c') if ctrl => Some(Action::Cancel),
_ => None,
})
}
_ => Ok(None),
}
}
fn draw(f: &mut Frame<'_>, list: &WorkerList) {
let area = f.area();
let mut constraints: Vec<Constraint> = Vec::with_capacity(list.entries.len() + 3);
constraints.push(Constraint::Length(1)); // title
for _ in &list.entries {
constraints.push(Constraint::Length(1));
}
constraints.push(Constraint::Length(1)); // hint
constraints.push(Constraint::Length(1)); // spacer
let layout = Layout::vertical(constraints).split(area);
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
picker_title(),
Style::default().add_modifier(Modifier::BOLD),
)])),
layout[0],
);
let selected = list.selected_index();
for (i, entry) in list.entries.iter().enumerate() {
f.render_widget(
Paragraph::new(row_line(entry, i == selected)),
layout[i + 1],
);
}
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled("[↑/↓]", Style::default().fg(Color::DarkGray)),
Span::raw(" select "),
Span::styled("[enter]", Style::default().fg(Color::Green)),
Span::raw(" open/restore "),
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
Span::raw(" cancel"),
])),
layout[list.entries.len() + 1],
);
}
fn picker_title() -> &'static str {
"resume worker pick a worker"
}
fn row_line(entry: &WorkerListEntry, selected: bool) -> Line<'_> {
let marker = if selected { "" } else { " " };
let name_style = if selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Cyan)
};
let preview_style = if selected {
Style::default().fg(Color::White)
} else {
Style::default().fg(Color::DarkGray)
};
let state = row_state(entry);
let _visibility = entry.visibility;
let _source_kinds = &entry.source_kinds;
let mut spans = vec![
Span::raw(marker),
Span::styled(entry.name.as_str(), name_style),
Span::raw(" "),
Span::styled(format!("[{}]", state.label()), state.style()),
Span::raw(" "),
Span::styled(
format_updated_at(entry.summary.updated_at),
Style::default().fg(Color::DarkGray),
),
Span::raw(" "),
Span::styled(debug_ids(entry), Style::default().fg(Color::DarkGray)),
];
if let Some(preview) = entry.summary.preview.as_ref() {
spans.push(Span::raw(" "));
spans.push(Span::styled(preview.as_str(), preview_style));
}
Line::from(spans)
}
fn row_state(entry: &WorkerListEntry) -> WorkerRowState {
if entry.live.as_ref().is_some_and(|live| live.reachable) {
return WorkerRowState::Live;
}
if entry
.stored
.as_ref()
.is_some_and(|stored| matches!(stored.metadata_state, StoredMetadataState::Corrupt(_)))
{
return WorkerRowState::Corrupt;
}
WorkerRowState::Stopped
}
fn format_updated_at(updated_at: u64) -> String {
if updated_at == 0 {
"updated: —".to_string()
} else {
format!("updated: {updated_at}")
}
}
fn debug_ids(entry: &WorkerListEntry) -> String {
let session = entry
.summary
.active_session_id
.map(short_id)
.unwrap_or_else(|| "--------".to_string());
let segment = entry
.summary
.active_segment_id
.map(short_id)
.unwrap_or_else(|| "--------".to_string());
format!("s:{session} g:{segment}")
}
fn short_id<T: ToString>(id: T) -> String {
id.to_string().chars().take(8).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn picker_title_names_pods_not_sessions() {
assert_eq!(picker_title(), "resume worker pick a worker");
}
#[test]
fn picker_no_pods_message_mentions_all_for_workspace_scope() {
let message = PickerError::NoWorkers { all: false }.to_string();
assert!(message.contains("no workers found in this workspace"));
assert!(message.contains("yoi resume --all"));
}
#[test]
fn picker_no_pods_message_keeps_fresh_pod_hint_for_all_scope() {
let message = PickerError::NoWorkers { all: true }.to_string();
assert!(message.contains("start a fresh Worker with `yoi`"));
assert!(!message.contains("yoi resume --all"));
}
#[test]
fn picker_workspace_options_filter_by_workspace_metadata() {
let list = list_for_options(
&PickerOptions::workspace(PathBuf::from("/workspace/current")),
vec![
stored_pod("current", Some("/workspace/current"), 3),
stored_pod("other", Some("/workspace/other"), 2),
stored_pod("legacy", None, 1),
],
vec![],
);
let names: Vec<_> = list
.entries
.iter()
.map(|entry| entry.name.as_str())
.collect();
assert_eq!(names, vec!["current"]);
}
#[test]
fn picker_all_options_include_host_wide_and_legacy_pods() {
let list = list_for_options(
&PickerOptions::all(),
vec![
stored_pod("current", Some("/workspace/current"), 3),
stored_pod("other", Some("/workspace/other"), 2),
stored_pod("legacy", None, 1),
],
vec![],
);
let names: Vec<_> = list
.entries
.iter()
.map(|entry| entry.name.as_str())
.collect();
assert_eq!(names, vec!["current", "other", "legacy"]);
}
fn stored_pod(name: &str, workspace_root: Option<&str>, updated_at: u64) -> StoredWorkerInfo {
StoredWorkerInfo {
worker_name: name.to_string(),
metadata_state: StoredMetadataState::Present,
active_session_id: None,
active_segment_id: None,
updated_at,
workspace_root: workspace_root.map(PathBuf::from),
preview: None,
}
}
#[test]
fn picker_row_shows_live_pending_preview_and_runtime_segment_id() {
let segment_id = session_store::new_segment_id();
let entry = WorkerList::from_sources(
WorkerVisibilitySource::ResumePicker,
vec![],
vec![crate::worker_list::LiveWorkerInfo {
worker_name: "pending".to_string(),
socket_path: PathBuf::from("/tmp/pending.sock"),
status: Some(protocol::WorkerStatus::Idle),
reachable: true,
segment_id: Some(segment_id),
summary: crate::worker_list::WorkerEntrySummary::default(),
}],
None,
10,
)
.entries
.into_iter()
.next()
.unwrap();
let text = row_line(&entry, false)
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert!(text.contains("[live]"));
assert!(text.contains("[live, pending segment]"));
assert!(text.contains(&format!("g:{}", short_id(segment_id))));
}
}
-556
View File
@@ -1,556 +0,0 @@
use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
const REGISTRY_VERSION: u32 = 1;
const REGISTRY_FILE: &str = "role-sessions.json";
const REGISTRY_LOCK_FILE: &str = "role-sessions.lock";
const CLAIMS_DIR: &str = "ticket-claims";
#[derive(Debug, Clone)]
pub(crate) struct PanelRegistryStore {
root: PathBuf,
workspace_root: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct RoleSessionRegistry {
pub version: u32,
pub workspace_root: String,
pub sessions: BTreeMap<String, RoleSessionRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct RoleSessionRecord {
pub role: String,
pub worker_name: String,
pub origin: RoleSessionOrigin,
pub created_at: String,
pub updated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default)]
pub related_tickets: Vec<RelatedTicketRef>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum RoleSessionOrigin {
PreTicketIntake,
TicketClaim,
RoleLaunch,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct RelatedTicketRef {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slug: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct TicketClaim {
pub ticket_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ticket_slug: Option<String>,
pub worker_name: String,
pub role: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PanelRegistrySnapshot {
pub sessions: Vec<RoleSessionRecord>,
pub claims: Vec<TicketClaim>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TicketClaimResult {
Claimed,
AlreadyOwned(TicketClaim),
}
#[derive(Debug)]
pub(crate) enum PanelRegistryError {
Io(io::Error),
Json(serde_json::Error),
TicketAlreadyClaimed(TicketClaim),
}
impl std::fmt::Display for PanelRegistryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(f, "local role session registry I/O error: {error}"),
Self::Json(error) => write!(f, "local role session registry JSON error: {error}"),
Self::TicketAlreadyClaimed(claim) => write!(
f,
"Ticket {} is already claimed locally by {} ({})",
claim.ticket_id, claim.worker_name, claim.role
),
}
}
}
impl std::error::Error for PanelRegistryError {}
impl From<io::Error> for PanelRegistryError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<serde_json::Error> for PanelRegistryError {
fn from(error: serde_json::Error) -> Self {
Self::Json(error)
}
}
impl PanelRegistryStore {
pub(crate) fn default_for_workspace(workspace_root: &Path) -> Result<Self, PanelRegistryError> {
let data_dir = manifest::paths::data_dir().ok_or_else(|| {
PanelRegistryError::Io(io::Error::other("failed to resolve yoi data directory"))
})?;
Ok(Self::for_data_dir(data_dir, workspace_root))
}
pub(crate) fn for_data_dir(data_dir: impl AsRef<Path>, workspace_root: &Path) -> Self {
let workspace_root = normalized_workspace_key(workspace_root);
let leaf = workspace_leaf(&workspace_root);
let digest = fnv1a64_hex(workspace_root.as_bytes());
Self {
root: data_dir
.as_ref()
.join("panel")
.join("workspaces")
.join(format!("{leaf}-{digest}")),
workspace_root: Some(workspace_root),
}
}
pub(crate) fn from_root(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
workspace_root: None,
}
}
pub(crate) fn root(&self) -> &Path {
&self.root
}
pub(crate) fn snapshot(&self) -> Result<PanelRegistrySnapshot, PanelRegistryError> {
let registry = self.load_registry()?;
let claims = self.load_claims()?;
Ok(PanelRegistrySnapshot {
sessions: registry.sessions.into_values().collect(),
claims,
})
}
pub(crate) fn load_registry(&self) -> Result<RoleSessionRegistry, PanelRegistryError> {
match fs::read(self.registry_path()) {
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(RoleSessionRegistry {
version: REGISTRY_VERSION,
workspace_root: self.workspace_root.clone().unwrap_or_default(),
sessions: BTreeMap::new(),
}),
Err(error) => Err(error.into()),
}
}
pub(crate) fn record_session(
&self,
worker_name: impl Into<String>,
role: impl Into<String>,
origin: RoleSessionOrigin,
session_id: Option<String>,
related_tickets: impl IntoIterator<Item = RelatedTicketRef>,
) -> Result<(), PanelRegistryError> {
let worker_name = worker_name.into();
let role = role.into();
let related_tickets: Vec<RelatedTicketRef> = related_tickets.into_iter().collect();
self.update_registry(|registry| {
let now = now_timestamp_string();
let mut tickets: BTreeSet<RelatedTicketRef> = registry
.sessions
.get(&worker_name)
.map(|record| record.related_tickets.iter().cloned().collect())
.unwrap_or_default();
tickets.extend(related_tickets);
let created_at = registry
.sessions
.get(&worker_name)
.map(|record| record.created_at.clone())
.unwrap_or_else(|| now.clone());
registry.sessions.insert(
worker_name.clone(),
RoleSessionRecord {
role,
worker_name,
origin,
created_at,
updated_at: now,
session_id,
related_tickets: tickets.into_iter().collect(),
},
);
Ok(())
})
}
pub(crate) fn claim_ticket(
&self,
ticket_id: &str,
ticket_slug: Option<&str>,
worker_name: &str,
role: &str,
) -> Result<TicketClaimResult, PanelRegistryError> {
fs::create_dir_all(self.claims_dir())?;
let claim_path = self.claim_path(ticket_id);
let claim = TicketClaim {
ticket_id: ticket_id.to_string(),
ticket_slug: ticket_slug.map(ToOwned::to_owned),
worker_name: worker_name.to_string(),
role: role.to_string(),
};
match self.create_claim_file(&claim_path, &claim) {
Ok(()) => {
if let Err(error) = self.record_session(
worker_name.to_string(),
role.to_string(),
RoleSessionOrigin::TicketClaim,
None,
[RelatedTicketRef {
id: ticket_id.to_string(),
slug: ticket_slug.map(ToOwned::to_owned),
}],
) {
let _ = fs::remove_file(&claim_path);
return Err(error);
}
Ok(TicketClaimResult::Claimed)
}
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
let existing = self.load_claim(ticket_id)?;
if existing.worker_name == worker_name && existing.role == role {
Ok(TicketClaimResult::AlreadyOwned(existing))
} else {
Err(PanelRegistryError::TicketAlreadyClaimed(existing))
}
}
Err(error) => Err(error.into()),
}
}
pub(crate) fn load_claim(&self, ticket_id: &str) -> Result<TicketClaim, PanelRegistryError> {
let bytes = fs::read(self.claim_path(ticket_id))?;
Ok(serde_json::from_slice(&bytes)?)
}
pub(crate) fn claim_for_ticket(
&self,
ticket_id: &str,
) -> Result<Option<TicketClaim>, PanelRegistryError> {
match self.load_claim(ticket_id) {
Ok(claim) => Ok(Some(claim)),
Err(PanelRegistryError::Io(error)) if error.kind() == io::ErrorKind::NotFound => {
Ok(None)
}
Err(error) => Err(error),
}
}
fn update_registry(
&self,
update: impl FnOnce(&mut RoleSessionRegistry) -> Result<(), PanelRegistryError>,
) -> Result<(), PanelRegistryError> {
fs::create_dir_all(&self.root)?;
let _lock = self.acquire_registry_lock()?;
let mut registry = self.load_registry()?;
registry.version = REGISTRY_VERSION;
if let Some(workspace_root) = self.workspace_root.as_ref() {
registry.workspace_root = workspace_root.clone();
}
update(&mut registry)?;
self.save_registry(&registry)
}
fn acquire_registry_lock(&self) -> Result<RegistryLockGuard, PanelRegistryError> {
let lock_path = self.root.join(REGISTRY_LOCK_FILE);
for _ in 0..50 {
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(_) => return Ok(RegistryLockGuard { path: lock_path }),
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => return Err(error.into()),
}
}
Err(PanelRegistryError::Io(io::Error::new(
io::ErrorKind::WouldBlock,
"timed out acquiring panel role session registry lock",
)))
}
fn save_registry(&self, registry: &RoleSessionRegistry) -> Result<(), PanelRegistryError> {
let path = self.registry_path();
let temp_path = path.with_extension(format!("json.{}.tmp", now_timestamp_string()));
let bytes = serde_json::to_vec_pretty(registry)?;
fs::write(&temp_path, [&bytes[..], b"\n"].concat())?;
fs::rename(temp_path, path)?;
Ok(())
}
fn create_claim_file(&self, claim_path: &Path, claim: &TicketClaim) -> io::Result<()> {
let temp_path = self
.claims_dir()
.join(format!(".{}.tmp", now_timestamp_string()));
let bytes = serde_json::to_vec_pretty(claim).map_err(io::Error::other)?;
fs::write(&temp_path, [&bytes[..], b"\n"].concat())?;
let link_result = fs::hard_link(&temp_path, claim_path);
let remove_result = fs::remove_file(&temp_path);
match (link_result, remove_result) {
(Ok(()), Ok(())) | (Ok(()), Err(_)) => Ok(()),
(Err(error), _) => Err(error),
}
}
fn load_claims(&self) -> Result<Vec<TicketClaim>, PanelRegistryError> {
let mut claims: Vec<TicketClaim> = Vec::new();
match fs::read_dir(self.claims_dir()) {
Ok(entries) => {
for entry in entries {
let entry = entry?;
if entry.file_type()?.is_file()
&& entry
.path()
.extension()
.is_some_and(|extension| extension == "json")
{
let bytes = fs::read(entry.path())?;
claims.push(serde_json::from_slice(&bytes)?);
}
}
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
claims.sort_by(|left, right| left.ticket_id.cmp(&right.ticket_id));
Ok(claims)
}
fn registry_path(&self) -> PathBuf {
self.root.join(REGISTRY_FILE)
}
fn claims_dir(&self) -> PathBuf {
self.root.join(CLAIMS_DIR)
}
fn claim_path(&self, ticket_id: &str) -> PathBuf {
self.claims_dir()
.join(format!("{}.json", encode_path_component(ticket_id)))
}
}
struct RegistryLockGuard {
path: PathBuf,
}
impl Drop for RegistryLockGuard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
impl PanelRegistrySnapshot {
pub(crate) fn empty() -> Self {
Self {
sessions: Vec::new(),
claims: Vec::new(),
}
}
pub(crate) fn claim_for_ticket(&self, ticket_id: &str) -> Option<&TicketClaim> {
self.claims
.iter()
.find(|claim| claim.ticket_id == ticket_id)
}
}
fn normalized_workspace_key(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn workspace_leaf(workspace_root: &str) -> String {
let leaf = workspace_root
.rsplit('/')
.find(|part| !part.is_empty())
.unwrap_or("workspace");
let sanitized = leaf
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
ch
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string();
if sanitized.is_empty() {
"workspace".to_string()
} else {
sanitized
}
}
fn fnv1a64_hex(bytes: &[u8]) -> String {
let mut hash = 0xcbf29ce484222325u64;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
fn encode_path_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' => encoded.push(byte as char),
_ => encoded.push_str(&format!("%{byte:02X}")),
}
}
encoded
}
fn now_timestamp_string() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos().to_string())
.unwrap_or_else(|_| "0".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn registry_path_is_workspace_scoped_under_data_dir() {
let data_dir = TempDir::new().unwrap();
let store = PanelRegistryStore::for_data_dir(data_dir.path(), Path::new("/repo/yoi"));
let other = PanelRegistryStore::for_data_dir(data_dir.path(), Path::new("/repo/other"));
assert!(store.root().starts_with(data_dir.path()));
let root = store.root().to_string_lossy();
assert!(root.contains("panel/workspaces/yoi-"));
assert_ne!(store.root(), other.root());
store
.record_session(
"ticket-intake-preticket",
"intake",
RoleSessionOrigin::PreTicketIntake,
None,
[],
)
.unwrap();
assert_eq!(store.load_registry().unwrap().workspace_root, "/repo/yoi");
}
#[test]
fn claim_ticket_rejects_second_active_local_pod() {
let temp = TempDir::new().unwrap();
let store = PanelRegistryStore::from_root(temp.path().join("registry"));
assert!(matches!(
store.claim_ticket("T-1", Some("ticket-one"), "ticket-one-intake", "intake"),
Ok(TicketClaimResult::Claimed)
));
let error = store
.claim_ticket("T-1", Some("ticket-one"), "ticket-two-intake", "intake")
.unwrap_err();
assert!(matches!(error, PanelRegistryError::TicketAlreadyClaimed(_)));
let claim = store.claim_for_ticket("T-1").unwrap().unwrap();
assert_eq!(claim.worker_name, "ticket-one-intake");
assert_eq!(claim.ticket_slug.as_deref(), Some("ticket-one"));
}
#[test]
fn intake_session_relation_is_not_one_to_one_with_tickets() {
let temp = TempDir::new().unwrap();
let store = PanelRegistryStore::from_root(temp.path().join("registry"));
store
.record_session(
"ticket-intake-preticket",
"intake",
RoleSessionOrigin::PreTicketIntake,
None,
[],
)
.unwrap();
store
.record_session(
"ticket-intake-shared",
"intake",
RoleSessionOrigin::RoleLaunch,
None,
[
RelatedTicketRef {
id: "T-1".to_string(),
slug: Some("one".to_string()),
},
RelatedTicketRef {
id: "T-2".to_string(),
slug: Some("two".to_string()),
},
],
)
.unwrap();
let snapshot = store.snapshot().unwrap();
let preticket = snapshot
.sessions
.iter()
.find(|session| session.worker_name == "ticket-intake-preticket")
.unwrap();
let shared = snapshot
.sessions
.iter()
.find(|session| session.worker_name == "ticket-intake-shared")
.unwrap();
assert!(preticket.related_tickets.is_empty());
assert_eq!(shared.role, "intake");
assert_eq!(shared.origin, RoleSessionOrigin::RoleLaunch);
assert!(!shared.created_at.is_empty());
assert!(!shared.updated_at.is_empty());
assert_eq!(
shared.related_tickets,
vec![
RelatedTicketRef {
id: "T-1".to_string(),
slug: Some("one".to_string()),
},
RelatedTicketRef {
id: "T-2".to_string(),
slug: Some("two".to_string()),
},
]
);
}
}
-756
View File
@@ -1,756 +0,0 @@
//! Inline-viewport "spawn Worker and attach" UX.
//!
//! Rendered at the user's current cursor position when `yoi` is invoked
//! with no positional argument. Uses user-configured and bundled Profile
//! choices plus bundled profiles, defaults to the builtin profile, prompts for
//! the Worker's name, and on confirmation launches the Worker runtime command as an
//! independent process. Once the process reports its socket via the
//! `YOI-READY` stderr line, the dialog hands control back so main can
//! switch the terminal to alternate-screen mode.
//!
//! The viewport's last frame stays in the terminal's scrollback so the
//! user has a record of what was spawned (or why a spawn failed).
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
use client::{SpawnConfig, WorkerRuntimeCommand, spawn_worker};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use manifest::ProfileDiscovery;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::{Frame, TerminalOptions, Viewport};
use session_store::SegmentId;
const VIEWPORT_LINES: u16 = 6;
pub struct SpawnReady {
pub worker_name: String,
pub socket_path: PathBuf,
}
pub enum SpawnOutcome {
Ready(SpawnReady),
Cancelled,
}
#[derive(Debug)]
pub enum SpawnError {
Io(io::Error),
Spawn(client::SpawnError),
}
impl std::fmt::Display for SpawnError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "io error: {e}"),
Self::Spawn(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for SpawnError {}
impl From<io::Error> for SpawnError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
impl From<client::SpawnError> for SpawnError {
fn from(e: client::SpawnError) -> Self {
Self::Spawn(e)
}
}
type InlineTerminal = Terminal<CrosstermBackend<io::Stdout>>;
/// Source session for a resume run. `None` = fresh spawn (current
/// behaviour); `Some(id)` swaps the dialog into "Resume Worker" mode and
/// passes `--session <id>` to the spawned Worker runtime child.
pub async fn run(
resume_from: Option<SegmentId>,
worker_name: Option<String>,
profile: Option<String>,
runtime_command: WorkerRuntimeCommand,
) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?;
let mut profile_choices = if resume_from.is_some() {
Vec::new()
} else {
defaults.profile_choices
};
let profile_index = initial_profile_index(
&mut profile_choices,
profile.as_deref(),
defaults.default_profile_index,
);
let selected_name = worker_name.unwrap_or(defaults.default_name);
let immediate = resume_from.is_some() || profile.is_some() && !selected_name.is_empty();
let mut form = Form {
cwd: defaults.cwd.clone(),
scope_origin: defaults.scope_origin,
name_cursor: selected_name.chars().count(),
name: selected_name,
message: None,
editing: true,
resume_from,
profile_choices,
profile_index,
};
let mut terminal = make_inline_terminal()?;
// Phase 1: confirm / cancel.
if !immediate {
loop {
terminal.draw(|f| draw_form(f, &form))?;
match poll_event()? {
None => continue,
Some(Action::Submit) => {
if form.name.trim().is_empty() {
form.message = Some(("name is required".to_string(), MessageKind::Error));
continue;
}
break;
}
Some(Action::Cancel) => {
form.editing = false;
form.message = Some(("cancelled".to_string(), MessageKind::Info));
terminal.draw(|f| draw_form(f, &form))?;
drop(terminal);
return Ok(SpawnOutcome::Cancelled);
}
Some(Action::Char(c)) => form.insert_char(c),
Some(Action::Backspace) => form.backspace(),
Some(Action::Delete) => form.delete_forward(),
Some(Action::Left) => form.move_left(),
Some(Action::Right) => form.move_right(),
Some(Action::Home) => form.name_cursor = 0,
Some(Action::End) => form.name_cursor = form.name.chars().count(),
Some(Action::ProfileNext) => form.cycle_profile_next(),
Some(Action::ProfilePrev) => form.cycle_profile_prev(),
}
}
} else if form.name.trim().is_empty() {
return Err(SpawnError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
"name is required",
)));
}
// Phase 2: launch worker and wait for ready line. Drop the cursor
// out of the name field — subsequent frames are passive status
// updates, not input — so the cursor doesn't end up parked there
// when the inline terminal is finally dropped.
form.editing = false;
form.message = Some(("starting worker...".to_string(), MessageKind::Progress));
terminal.draw(|f| draw_form(f, &form))?;
match wait_for_ready(&mut terminal, &mut form, &runtime_command).await {
Ok(ready) => {
form.message = Some((
format!("ready: {} attaching...", ready.worker_name),
MessageKind::Ok,
));
terminal.draw(|f| draw_form(f, &form))?;
drop(terminal);
Ok(SpawnOutcome::Ready(ready))
}
Err(e) => {
form.message = Some((e.to_string(), MessageKind::Error));
let _ = terminal.draw(|f| draw_form(f, &form));
drop(terminal);
Err(e)
}
}
}
/// Launch a Worker runtime command with `--worker <name>` without opening the name dialog. The child Worker
/// resolves persisted Worker metadata if present, or creates a fresh same-name Worker
/// from the default profile.
pub async fn run_worker_name(
worker_name: String,
runtime_command: WorkerRuntimeCommand,
) -> Result<SpawnOutcome, SpawnError> {
let defaults = load_spawn_defaults()?;
let mut form = form_for_worker_name(worker_name, defaults);
let mut terminal = make_inline_terminal()?;
terminal.draw(|f| draw_form(f, &form))?;
match wait_for_ready(&mut terminal, &mut form, &runtime_command).await {
Ok(ready) => {
form.message = Some((
format!("ready: {} attaching...", ready.worker_name),
MessageKind::Ok,
));
terminal.draw(|f| draw_form(f, &form))?;
drop(terminal);
Ok(SpawnOutcome::Ready(ready))
}
Err(e) => {
form.message = Some((e.to_string(), MessageKind::Error));
let _ = terminal.draw(|f| draw_form(f, &form));
drop(terminal);
Err(e)
}
}
}
struct SpawnDefaults {
cwd: PathBuf,
scope_origin: ScopeOrigin,
default_name: String,
default_profile_index: usize,
profile_choices: Vec<ProfileChoice>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProfileChoice {
selector: Option<String>,
label: String,
is_default: bool,
}
fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
let cwd = std::env::current_dir().map_err(SpawnError::Io)?;
let default_name = cwd
.file_name()
.and_then(|s| s.to_str())
.map(sanitise_default_name)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "worker".to_string());
let (profile_choices, default_profile_index) = profile_choices_for_cwd(&cwd);
Ok(SpawnDefaults {
cwd,
scope_origin: ScopeOrigin::FromProfile,
default_name,
default_profile_index,
profile_choices,
})
}
fn profile_choices_for_cwd(cwd: &Path) -> (Vec<ProfileChoice>, usize) {
let Ok(registry) = ProfileDiscovery::for_cwd(cwd).discover() else {
return (Vec::new(), 0);
};
let mut choices = Vec::new();
for entry in registry.entries() {
let mut label = entry.qualified_name();
if entry.is_default {
label.push_str(" (default)");
}
if let Some(description) = entry.description.as_deref() {
label.push_str("");
label.push_str(description);
}
choices.push(ProfileChoice {
selector: Some(entry.qualified_name()),
label,
is_default: entry.is_default,
});
}
let default_index = choices
.iter()
.position(|choice| choice.is_default)
.unwrap_or(0);
(choices, default_index)
}
fn initial_profile_index(
choices: &mut Vec<ProfileChoice>,
explicit_profile: Option<&str>,
default_index: usize,
) -> usize {
let Some(selector) = explicit_profile else {
return default_index.min(choices.len().saturating_sub(1));
};
if let Some(index) = choices
.iter()
.position(|choice| choice.selector.as_deref() == Some(selector))
{
return index;
}
choices.push(ProfileChoice {
selector: Some(selector.to_string()),
label: selector.to_string(),
is_default: false,
});
choices.len() - 1
}
fn form_for_worker_name(worker_name: String, defaults: SpawnDefaults) -> Form {
Form {
cwd: defaults.cwd,
scope_origin: defaults.scope_origin,
name_cursor: worker_name.chars().count(),
name: worker_name,
message: Some(("resuming worker...".to_string(), MessageKind::Progress)),
editing: false,
resume_from: None,
profile_choices: Vec::new(),
profile_index: 0,
}
}
fn make_inline_terminal() -> io::Result<InlineTerminal> {
let backend = CrosstermBackend::new(io::stdout());
Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(VIEWPORT_LINES),
},
)
}
enum Action {
Submit,
Cancel,
Char(char),
Backspace,
Delete,
Left,
Right,
Home,
End,
ProfileNext,
ProfilePrev,
}
fn poll_event() -> io::Result<Option<Action>> {
if !event::poll(Duration::from_millis(100))? {
return Ok(None);
}
match event::read()? {
TermEvent::Key(k) if k.kind != KeyEventKind::Release => {
let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
Ok(match k.code {
KeyCode::Enter => Some(Action::Submit),
KeyCode::Esc => Some(Action::Cancel),
KeyCode::Char('c') if ctrl => Some(Action::Cancel),
KeyCode::Char('a') if ctrl => Some(Action::Home),
KeyCode::Char('e') if ctrl => Some(Action::End),
KeyCode::Char('u') if ctrl => Some(Action::Cancel),
KeyCode::Backspace => Some(Action::Backspace),
KeyCode::Delete => Some(Action::Delete),
KeyCode::Left => Some(Action::Left),
KeyCode::Right => Some(Action::Right),
KeyCode::Up | KeyCode::BackTab => Some(Action::ProfilePrev),
KeyCode::Down | KeyCode::Tab => Some(Action::ProfileNext),
KeyCode::Home => Some(Action::Home),
KeyCode::End => Some(Action::End),
KeyCode::Char(c) if !ctrl && is_safe_name_char(c) => Some(Action::Char(c)),
_ => None,
})
}
_ => Ok(None),
}
}
fn is_safe_name_char(c: char) -> bool {
// Filesystem-safe; worker.name becomes a runtime-dir name.
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')
}
fn sanitise_default_name(s: &str) -> String {
s.chars()
.map(|c| if is_safe_name_char(c) { c } else { '-' })
.collect()
}
async fn wait_for_ready(
terminal: &mut InlineTerminal,
form: &mut Form,
runtime_command: &WorkerRuntimeCommand,
) -> Result<SpawnReady, SpawnError> {
let config = SpawnConfig {
runtime_command: runtime_command.clone(),
worker_name: form.name.clone(),
profile: form.selected_profile_selector(),
workspace_root: form.cwd.clone(),
cwd: None,
resume_from: form.resume_from,
};
let ready = spawn_worker(config, |line| {
form.message = Some((line.to_string(), MessageKind::Progress));
let _ = terminal.draw(|f| draw_form(f, form));
})
.await?;
Ok(SpawnReady {
worker_name: ready.worker_name,
socket_path: ready.socket_path,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MessageKind {
Info,
Ok,
Error,
Progress,
}
enum ScopeOrigin {
FromProfile,
}
struct Form {
cwd: PathBuf,
/// Display label for the scope row in the dialog.
scope_origin: ScopeOrigin,
name: String,
/// Cursor position counted in **chars**, not bytes — `name`
/// currently only accepts ASCII so the two coincide, but we keep
/// char-based bookkeeping in case we relax `is_safe_name_char`.
name_cursor: usize,
message: Option<(String, MessageKind)>,
/// True while the dialog is accepting name input. Drives whether
/// the rendered frame parks the terminal cursor inside the name
/// field — when false (post-confirm / cancel / failure frames) the
/// cursor stays out so it does not collide with the shell prompt
/// after the inline terminal is dropped.
editing: bool,
/// `Some(id)` flips the dialog into "Resume Worker" mode: the title
/// switches, the source session is shown to the user, and the
/// child worker is launched with `--session <id>` so it restores
/// from `id` and appends to the same session log.
resume_from: Option<SegmentId>,
/// Optional profile choices passed with `--profile` for
/// fresh spawns. This is not used for resume/attach flows because those must
/// restore Worker state rather than re-evaluate a profile source.
profile_choices: Vec<ProfileChoice>,
profile_index: usize,
}
impl Form {
fn insert_char(&mut self, c: char) {
let byte = self.char_offset_to_byte(self.name_cursor);
self.name.insert(byte, c);
self.name_cursor += 1;
}
fn backspace(&mut self) {
if self.name_cursor == 0 {
return;
}
let end = self.char_offset_to_byte(self.name_cursor);
let start = self.char_offset_to_byte(self.name_cursor - 1);
self.name.replace_range(start..end, "");
self.name_cursor -= 1;
}
fn delete_forward(&mut self) {
let total = self.name.chars().count();
if self.name_cursor >= total {
return;
}
let start = self.char_offset_to_byte(self.name_cursor);
let end = self.char_offset_to_byte(self.name_cursor + 1);
self.name.replace_range(start..end, "");
}
fn move_left(&mut self) {
if self.name_cursor > 0 {
self.name_cursor -= 1;
}
}
fn move_right(&mut self) {
let total = self.name.chars().count();
if self.name_cursor < total {
self.name_cursor += 1;
}
}
fn selected_profile(&self) -> Option<&ProfileChoice> {
self.profile_choices
.get(self.profile_index)
.filter(|choice| choice.selector.is_some())
}
fn selected_profile_selector(&self) -> Option<String> {
self.selected_profile()
.and_then(|choice| choice.selector.clone())
}
fn cycle_profile_next(&mut self) {
if self.profile_choices.is_empty() {
return;
}
self.profile_index = (self.profile_index + 1) % self.profile_choices.len();
self.message = None;
}
fn cycle_profile_prev(&mut self) {
if self.profile_choices.is_empty() {
return;
}
self.profile_index = if self.profile_index == 0 {
self.profile_choices.len() - 1
} else {
self.profile_index - 1
};
self.message = None;
}
fn char_offset_to_byte(&self, char_off: usize) -> usize {
self.name
.char_indices()
.nth(char_off)
.map(|(b, _)| b)
.unwrap_or(self.name.len())
}
}
fn draw_form(f: &mut Frame<'_>, form: &Form) {
let area = f.area();
let layout = Layout::vertical([
Constraint::Length(1), // title
Constraint::Length(1), // name field
Constraint::Length(1), // context (profile or scope default)
Constraint::Length(1), // hint
Constraint::Length(1), // message
Constraint::Length(1), // spacer
])
.split(area);
let title_text = match form.resume_from {
Some(id) => format!("resume worker session: {}", short_segment(id)),
None => "spawn worker".to_string(),
};
let title = Paragraph::new(Line::from(vec![Span::styled(
title_text,
Style::default().add_modifier(Modifier::BOLD),
)]));
f.render_widget(title, layout[0]);
f.render_widget(Paragraph::new(name_line(form)), layout[1]);
f.render_widget(Paragraph::new(context_line(form)), layout[2]);
f.render_widget(Paragraph::new(hint_line()), layout[3]);
f.render_widget(Paragraph::new(message_line(form)), layout[4]);
if form.editing {
// Place the cursor inside the name field while the user is
// editing. Skipped on post-confirm frames so the inline
// viewport's drop leaves the cursor at the bottom of the
// rendered area rather than parked on the name line, which
// would let the shell prompt (or any later eprintln) clobber
// the rendered name field after exit.
let cursor_col = 2 + "name: ".len() + form.name_cursor;
f.set_cursor_position((layout[1].x + cursor_col as u16, layout[1].y));
}
}
/// First 8 hex digits of a UUID — short enough to skim, long enough
/// to disambiguate inside a 10-row picker.
pub(crate) fn short_segment(id: SegmentId) -> String {
let s = id.to_string();
s.chars().take(8).collect()
}
fn name_line(form: &Form) -> Line<'_> {
Line::from(vec![
Span::raw(" "),
Span::styled("name: ", Style::default().fg(Color::DarkGray)),
Span::styled(
form.name.as_str(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
])
}
fn context_line(form: &Form) -> Line<'_> {
if let Some(profile) = form.profile_choices.get(form.profile_index) {
return Line::from(vec![
Span::raw(" "),
Span::styled("profile: ", Style::default().fg(Color::DarkGray)),
Span::styled(profile.label.as_str(), Style::default().fg(Color::Green)),
Span::styled(
" (tab/down to change)",
Style::default().fg(Color::DarkGray),
),
]);
}
match form.scope_origin {
ScopeOrigin::FromProfile => Line::from(vec![
Span::raw(" "),
Span::styled("scope: ", Style::default().fg(Color::DarkGray)),
Span::styled("from selected profile", Style::default().fg(Color::Green)),
]),
}
}
fn hint_line() -> Line<'static> {
Line::from(vec![Span::styled(
" enter spawn · tab/down next profile · shift-tab/up prev · esc cancel",
Style::default().fg(Color::DarkGray),
)])
}
fn message_line(form: &Form) -> Line<'_> {
let Some((text, kind)) = form.message.as_ref() else {
return Line::from("");
};
let style = match kind {
MessageKind::Info => Style::default().fg(Color::DarkGray),
MessageKind::Ok => Style::default().fg(Color::Green),
MessageKind::Error => Style::default().fg(Color::Red),
MessageKind::Progress => Style::default().fg(Color::Yellow),
};
Line::from(vec![Span::raw(" "), Span::styled(text.as_str(), style)])
}
#[cfg(test)]
mod tests {
use super::*;
fn form(name: &str) -> Form {
Form {
cwd: PathBuf::from("/work/example"),
scope_origin: ScopeOrigin::FromProfile,
name: name.to_string(),
name_cursor: name.chars().count(),
message: None,
editing: true,
resume_from: None,
profile_choices: Vec::new(),
profile_index: 0,
}
}
#[test]
fn worker_name_form_restores_or_creates_by_worker_name() {
let defaults = SpawnDefaults {
cwd: PathBuf::from("/work/example"),
scope_origin: ScopeOrigin::FromProfile,
default_name: "ignored".to_string(),
default_profile_index: 0,
profile_choices: Vec::new(),
};
let f = form_for_worker_name("agent".to_string(), defaults);
assert_eq!(f.name, "agent");
assert_eq!(f.name_cursor, "agent".chars().count());
assert_eq!(f.resume_from, None);
assert!(!f.editing);
assert_eq!(
f.message,
Some(("resuming worker...".to_string(), MessageKind::Progress))
);
}
#[test]
fn profile_choices_ignore_repository_local_profile_registry() {
let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("project");
let yoi = project.join(".yoi");
std::fs::create_dir_all(&yoi).unwrap();
std::fs::write(
yoi.join("profiles.toml"),
"default = \"coder\"\n[profile]\ncoder = \"profiles/coder.toml\"\n",
)
.unwrap();
let (choices, default_index) = profile_choices_for_cwd(&project);
assert_eq!(default_index, 0);
assert!(
choices
.iter()
.all(|choice| { choice.selector.as_deref() != Some("project:coder") })
);
assert!(
choices
.iter()
.any(|choice| { choice.selector.as_deref() == Some("builtin:companion") })
);
}
#[test]
fn profile_cycle_selects_only_discovered_profiles() {
let mut form = form("coder");
form.profile_choices = vec![
ProfileChoice {
selector: Some("project:coder".to_string()),
label: "project:coder (default)".to_string(),
is_default: true,
},
ProfileChoice {
selector: Some("user:reviewer".to_string()),
label: "user:reviewer".to_string(),
is_default: false,
},
];
form.profile_index = 0;
assert_eq!(
form.selected_profile_selector().as_deref(),
Some("project:coder")
);
form.cycle_profile_next();
assert_eq!(
form.selected_profile_selector().as_deref(),
Some("user:reviewer")
);
form.cycle_profile_next();
assert_eq!(
form.selected_profile_selector().as_deref(),
Some("project:coder")
);
form.cycle_profile_prev();
assert_eq!(
form.selected_profile_selector().as_deref(),
Some("user:reviewer")
);
}
#[test]
fn initial_profile_index_adds_explicit_selector_not_in_discovery_list() {
let mut choices = Vec::new();
let selected = initial_profile_index(&mut choices, Some("coder"), 0);
assert_eq!(selected, 0);
assert_eq!(choices[0].selector.as_deref(), Some("coder"));
assert_eq!(choices[0].label, "coder");
}
#[test]
fn name_input_handles_insert_backspace_and_cursor() {
let mut f = form("");
for c in "abc".chars() {
f.insert_char(c);
}
assert_eq!(f.name, "abc");
assert_eq!(f.name_cursor, 3);
f.move_left();
f.move_left();
f.insert_char('X');
assert_eq!(f.name, "aXbc");
f.backspace();
assert_eq!(f.name, "abc");
assert_eq!(f.name_cursor, 1);
f.delete_forward();
assert_eq!(f.name, "ac");
}
#[test]
fn sanitise_default_name_replaces_unsafe_chars() {
assert_eq!(sanitise_default_name("my project!"), "my-project-");
assert_eq!(sanitise_default_name("ok-name_2.0"), "ok-name_2.0");
}
}
+169
View File
@@ -0,0 +1,169 @@
use std::io;
use std::time::Duration;
use client::{StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::prelude::{Color, Line, Modifier, Span, Style};
use ratatui::widgets::Paragraph;
use ratatui::{TerminalOptions, Viewport};
use standalone::{StandaloneListScope, StandaloneWorkerRecord, StandaloneWorkerStore};
use thiserror::Error;
const LIMIT: usize = 100;
pub(crate) fn pick(
target: &dyn Target,
include_all: bool,
) -> Result<Option<StandaloneWorkerResumeIntent>, StandalonePickerError> {
let intent = target
.standalone_worker_list(include_all)
.map_err(StandalonePickerError::Target)?;
let records = load_records(&intent)?;
if records.is_empty() {
return Err(StandalonePickerError::NoWorkers { include_all });
}
let selected = run_picker(records)?;
selected
.map(|record| {
target
.standalone_worker_resume(record.worker_id.to_string())
.map_err(StandalonePickerError::Target)
})
.transpose()
}
fn load_records(
intent: &StandaloneWorkerListIntent,
) -> Result<Vec<StandaloneWorkerRecord>, StandalonePickerError> {
let store = StandaloneWorkerStore::open(&intent.state_dir)
.map_err(StandalonePickerError::StateStore)?;
store
.list(
&intent.cwd,
if intent.include_all {
StandaloneListScope::All
} else {
StandaloneListScope::CurrentCwd
},
LIMIT,
)
.map_err(StandalonePickerError::StateStore)
}
fn run_picker(
records: Vec<StandaloneWorkerRecord>,
) -> Result<Option<StandaloneWorkerRecord>, StandalonePickerError> {
let height = u16::try_from(records.len().saturating_add(3).min(20)).unwrap_or(20);
let mut terminal = Terminal::with_options(
CrosstermBackend::new(io::stdout()),
TerminalOptions {
viewport: Viewport::Inline(height),
},
)
.map_err(StandalonePickerError::Io)?;
let mut selected = 0usize;
loop {
terminal
.draw(|frame| draw(frame, &records, selected))
.map_err(StandalonePickerError::Io)?;
if !event::poll(Duration::from_millis(100)).map_err(StandalonePickerError::Io)? {
continue;
}
let TermEvent::Key(key) = event::read().map_err(StandalonePickerError::Io)? else {
continue;
};
if key.kind == KeyEventKind::Release {
continue;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Up | KeyCode::Char('k') if !ctrl => {
selected = selected.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') if !ctrl => {
selected = (selected + 1).min(records.len() - 1);
}
KeyCode::Enter => return Ok(Some(records[selected].clone())),
KeyCode::Esc => return Ok(None),
KeyCode::Char('c') if ctrl => return Ok(None),
_ => {}
}
}
}
fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneWorkerRecord], selected: usize) {
let mut constraints = vec![Constraint::Length(1)];
constraints.extend(records.iter().map(|_| Constraint::Length(1)));
constraints.push(Constraint::Length(1));
let rows = Layout::vertical(constraints).split(frame.area());
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
"resume standalone Worker",
Style::default().add_modifier(Modifier::BOLD),
))),
rows[0],
);
for (index, record) in records.iter().enumerate() {
let active = index == selected;
let marker = if active { "" } else { " " };
let style = if active {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let cwd = record.cwd.canonical_path.display();
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(marker),
Span::styled(
format!("{} ({})", record.worker_name, record.worker_id.short()),
style,
),
Span::raw(format!(
" [{:?}] updated:{} {}",
record.status, record.updated_at_unix_ms, cwd
)),
])),
rows[index + 1],
);
}
frame.render_widget(
Paragraph::new(" [↑/↓] select [enter] restore [esc] cancel"),
rows[records.len() + 1],
);
}
#[derive(Debug, Error)]
pub(crate) enum StandalonePickerError {
#[error("standalone target error: {0}")]
Target(#[source] client::TargetError),
#[error("standalone Worker state is unavailable: {0}")]
StateStore(#[source] standalone::StandaloneStoreError),
#[error(
"no standalone Workers found for this cwd; use `yoi --local resume --all` to include all cwd identities"
)]
NoWorkers { include_all: bool },
#[error("standalone Worker picker I/O failed: {0}")]
Io(#[source] io::Error),
}
#[cfg(test)]
mod tests {
use client::StandaloneTarget;
use super::*;
#[test]
fn empty_picker_keeps_current_cwd_as_default_scope() {
let temp = tempfile::tempdir().expect("tempdir");
let target = StandaloneTarget::new(temp.path());
let error = pick(&target, false).expect_err("empty picker should fail explicitly");
assert!(error.to_string().contains("this cwd"));
assert!(error.to_string().contains("--all"));
}
}
+494
View File
@@ -0,0 +1,494 @@
use std::io::{self, Stdout};
use std::path::Path;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use manifest::ProfileDiscovery;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use thiserror::Error;
const VIEWPORT_HEIGHT: u16 = 6;
const FALLBACK_WORKER_NAME: &str = "worker";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StandaloneSpawnSelection {
pub worker_name: String,
pub profile: String,
}
#[derive(Debug, Error)]
pub(crate) enum StandaloneSpawnError {
#[error("profile discovery failed: {0}")]
ProfileDiscovery(#[from] manifest::ProfileError),
#[error("no profiles are available")]
NoProfiles,
#[error("standalone spawn picker terminal error: {0}")]
Terminal(#[from] io::Error),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ProfileChoice {
selector: String,
label: String,
is_default: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StatusKind {
Info,
Progress,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpawnAction {
None,
Submit,
Cancel,
}
struct SpawnForm {
worker_name: String,
cursor: usize,
profile_choices: Vec<ProfileChoice>,
selected_profile: usize,
status: Option<(String, StatusKind)>,
}
impl SpawnForm {
fn new(
worker_name: Option<String>,
default_worker_name: String,
profile_choices: Vec<ProfileChoice>,
) -> Self {
let worker_name = worker_name.unwrap_or(default_worker_name);
let cursor = worker_name.chars().count();
let selected_profile = profile_choices
.iter()
.position(|choice| choice.is_default)
.unwrap_or(0);
Self {
worker_name,
cursor,
profile_choices,
selected_profile,
status: None,
}
}
fn selected_profile(&self) -> &ProfileChoice {
&self.profile_choices[self.selected_profile]
}
fn apply_key(&mut self, key: KeyEvent) -> SpawnAction {
if key.kind == KeyEventKind::Release {
return SpawnAction::None;
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') | KeyCode::Char('u') => return SpawnAction::Cancel,
_ => return SpawnAction::None,
}
}
self.status = None;
match key.code {
KeyCode::Esc => SpawnAction::Cancel,
KeyCode::Enter => {
if self.worker_name.trim().is_empty() {
self.status =
Some(("worker name cannot be empty".to_owned(), StatusKind::Error));
SpawnAction::None
} else {
SpawnAction::Submit
}
}
KeyCode::Tab | KeyCode::Down => {
self.selected_profile = (self.selected_profile + 1) % self.profile_choices.len();
SpawnAction::None
}
KeyCode::BackTab | KeyCode::Up => {
self.selected_profile = if self.selected_profile == 0 {
self.profile_choices.len() - 1
} else {
self.selected_profile - 1
};
SpawnAction::None
}
KeyCode::Left => {
self.cursor = self.cursor.saturating_sub(1);
SpawnAction::None
}
KeyCode::Right => {
self.cursor = (self.cursor + 1).min(self.worker_name.chars().count());
SpawnAction::None
}
KeyCode::Home => {
self.cursor = 0;
SpawnAction::None
}
KeyCode::End => {
self.cursor = self.worker_name.chars().count();
SpawnAction::None
}
KeyCode::Backspace => {
if self.cursor > 0 {
let idx = byte_index(&self.worker_name, self.cursor - 1);
self.worker_name.remove(idx);
self.cursor -= 1;
}
SpawnAction::None
}
KeyCode::Delete => {
if self.cursor < self.worker_name.chars().count() {
let idx = byte_index(&self.worker_name, self.cursor);
self.worker_name.remove(idx);
}
SpawnAction::None
}
KeyCode::Char(ch) if is_safe_worker_char(ch) => {
let idx = byte_index(&self.worker_name, self.cursor);
self.worker_name.insert(idx, ch);
self.cursor += 1;
SpawnAction::None
}
_ => SpawnAction::None,
}
}
}
pub(crate) fn select(
workspace_root: &Path,
worker_name: Option<String>,
profile: Option<String>,
) -> Result<Option<StandaloneSpawnSelection>, StandaloneSpawnError> {
let default_worker_name = default_worker_name(workspace_root);
if let Some(profile) = profile {
return Ok(Some(StandaloneSpawnSelection {
worker_name: worker_name.unwrap_or(default_worker_name),
profile,
}));
}
let registry = ProfileDiscovery::user_settings().discover()?;
let choices = profile_choices(&registry);
if choices.is_empty() {
return Err(StandaloneSpawnError::NoProfiles);
}
let terminal = open_inline_terminal()?;
run_picker(
terminal,
SpawnForm::new(worker_name, default_worker_name, choices),
)
}
fn run_picker(
mut terminal: Terminal<CrosstermBackend<Stdout>>,
mut form: SpawnForm,
) -> Result<Option<StandaloneSpawnSelection>, StandaloneSpawnError> {
loop {
terminal.draw(|frame| draw_form(frame, &form))?;
if !event::poll(Duration::from_millis(100))? {
continue;
}
let Event::Key(key) = event::read()? else {
continue;
};
match form.apply_key(key) {
SpawnAction::None => {}
SpawnAction::Cancel => {
form.status = Some(("cancelled".to_owned(), StatusKind::Info));
terminal.draw(|frame| draw_form(frame, &form))?;
return Ok(None);
}
SpawnAction::Submit => {
let selection = StandaloneSpawnSelection {
worker_name: form.worker_name.trim().to_owned(),
profile: form.selected_profile().selector.clone(),
};
form.status = Some(("starting worker...".to_owned(), StatusKind::Progress));
terminal.draw(|frame| draw_form(frame, &form))?;
return Ok(Some(selection));
}
}
}
}
fn open_inline_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
let options = ratatui::TerminalOptions {
viewport: ratatui::Viewport::Inline(VIEWPORT_HEIGHT),
};
Terminal::with_options(CrosstermBackend::new(io::stdout()), options)
}
fn profile_choices(registry: &manifest::ProfileRegistry) -> Vec<ProfileChoice> {
registry
.entries()
.iter()
.map(|entry| {
let selector = entry.qualified_name();
let default_marker = if entry.is_default { " (default)" } else { "" };
let mut label = format!("{selector}{default_marker}");
if let Some(description) = &entry.description {
label.push_str("");
label.push_str(description);
}
ProfileChoice {
selector,
label,
is_default: entry.is_default,
}
})
.collect()
}
fn draw_form(frame: &mut ratatui::Frame<'_>, form: &SpawnForm) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.split(frame.area());
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(
"spawn worker",
Style::default().add_modifier(Modifier::BOLD),
),
])),
chunks[0],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled("name: ", Style::default().fg(Color::DarkGray)),
Span::styled(
&form.worker_name,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
])),
chunks[1],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled("profile: ", Style::default().fg(Color::DarkGray)),
Span::styled(
&form.selected_profile().label,
Style::default().fg(Color::Green),
),
Span::styled(
" (tab/down to change)",
Style::default().fg(Color::DarkGray),
),
])),
chunks[2],
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
" enter spawn · left/right edit · esc cancel",
Style::default().fg(Color::DarkGray),
))),
chunks[3],
);
let (message, color) = form
.status
.as_ref()
.map(|(message, kind)| {
let color = match kind {
StatusKind::Info => Color::DarkGray,
StatusKind::Progress => Color::Yellow,
StatusKind::Error => Color::Red,
};
(message.as_str(), color)
})
.unwrap_or(("", Color::Reset));
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(message, Style::default().fg(color)),
])),
chunks[4],
);
let prefix_width = " name: ".chars().count() as u16;
let x = chunks[1]
.x
.saturating_add(prefix_width)
.saturating_add(form.cursor as u16)
.min(chunks[1].right().saturating_sub(1));
frame.set_cursor_position((x, chunks[1].y));
}
fn default_worker_name(workspace_root: &Path) -> String {
workspace_root
.file_name()
.and_then(|name| name.to_str())
.map(sanitise_default_name)
.filter(|name| !name.is_empty())
.unwrap_or_else(|| FALLBACK_WORKER_NAME.to_owned())
}
fn sanitise_default_name(name: &str) -> String {
name.chars()
.map(|ch| if is_safe_worker_char(ch) { ch } else { '-' })
.collect()
}
fn is_safe_worker_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')
}
fn byte_index(input: &str, char_index: usize) -> usize {
input
.char_indices()
.nth(char_index)
.map_or(input.len(), |(idx, _)| idx)
}
#[cfg(test)]
mod tests {
use crossterm::event::{KeyEvent, KeyModifiers};
use super::*;
fn choices() -> Vec<ProfileChoice> {
vec![
ProfileChoice {
selector: "builtin:default".to_owned(),
label: "builtin:default (default) — Default".to_owned(),
is_default: true,
},
ProfileChoice {
selector: "builtin:coder".to_owned(),
label: "builtin:coder — Coder".to_owned(),
is_default: false,
},
]
}
#[test]
fn default_form_preserves_old_spawn_layout_defaults() {
let form = SpawnForm::new(None, "yoi".to_owned(), choices());
assert_eq!(form.worker_name, "yoi");
assert_eq!(form.selected_profile().selector, "builtin:default");
}
#[test]
fn tab_and_arrows_cycle_profiles() {
let mut form = SpawnForm::new(None, "yoi".to_owned(), choices());
assert_eq!(
form.apply_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
SpawnAction::None
);
assert_eq!(form.selected_profile().selector, "builtin:coder");
form.apply_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(form.selected_profile().selector, "builtin:default");
form.apply_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
assert_eq!(form.selected_profile().selector, "builtin:coder");
}
#[test]
fn name_input_uses_old_safe_character_policy() {
let mut form = SpawnForm::new(Some("worker".to_owned()), "yoi".to_owned(), choices());
form.apply_key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
form.apply_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE));
form.apply_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
assert_eq!(form.worker_name, "worker-1");
}
#[test]
fn enter_rejects_empty_name_and_escape_cancels() {
let mut form = SpawnForm::new(Some(String::new()), "yoi".to_owned(), choices());
assert_eq!(
form.apply_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
SpawnAction::None
);
assert_eq!(
form.status.as_ref().map(|(message, _)| message.as_str()),
Some("worker name cannot be empty")
);
assert_eq!(
form.apply_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
SpawnAction::Cancel
);
}
#[test]
fn renderer_preserves_legacy_inline_spawn_form() {
let backend = ratatui::backend::TestBackend::new(100, VIEWPORT_HEIGHT);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
let form = SpawnForm::new(None, "yoi".to_owned(), choices());
terminal.draw(|frame| draw_form(frame, &form)).unwrap();
let buffer = terminal.backend().buffer();
let rendered = buffer
.content
.chunks(buffer.area.width as usize)
.map(|row| row.iter().map(|cell| cell.symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("spawn worker"));
assert!(rendered.contains("name: yoi"));
assert!(rendered.contains("profile: builtin:default (default) — Default"));
assert!(rendered.contains("enter spawn · left/right edit · esc cancel"));
}
#[test]
fn builtin_discovery_produces_a_default_profile_choice() {
let registry = ProfileDiscovery::with_sources(None, None)
.discover()
.unwrap();
let choices = profile_choices(&registry);
let default = choices.iter().find(|choice| choice.is_default).unwrap();
assert_eq!(default.selector, "builtin:default");
assert!(default.label.contains("(default)"));
}
#[test]
fn default_worker_name_comes_from_sanitised_directory_basename() {
assert_eq!(
default_worker_name(Path::new("/home/hare/Project/yoi")),
"yoi"
);
assert_eq!(
default_worker_name(Path::new("/home/hare/Project/my project")),
"my-project"
);
assert_eq!(default_worker_name(Path::new("/")), "worker");
}
#[test]
fn explicit_profile_bypasses_discovery_and_uses_directory_name() {
let selection = select(
Path::new("/home/hare/Project/yoi"),
None,
Some("builtin:coder".to_owned()),
)
.unwrap()
.unwrap();
assert_eq!(selection.worker_name, "yoi");
assert_eq!(selection.profile, "builtin:coder");
}
}
+130 -39
View File
@@ -36,6 +36,9 @@ use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
use crate::text_selection::{HistoryViewport, SelectionRow}; use crate::text_selection::{HistoryViewport, SelectionRow};
use crate::view_mode::Mode; use crate::view_mode::Mode;
const RUN_SPINNER_FRAMES: [&str; 8] = ["", "", "", "", "", "", "", ""];
const RUN_SPINNER_FRAME_MS: u128 = 80;
pub fn draw(frame: &mut Frame, app: &mut App) { pub fn draw(frame: &mut Frame, app: &mut App) {
let area = frame.area(); let area = frame.area();
// Input content starts after the prompt (`> ` or `: `), so the width // Input content starts after the prompt (`> ` or `: `), so the width
@@ -57,14 +60,22 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
let tabs = app.worker_view_tabs(); let tabs = app.worker_view_tabs();
let show_tabs = tabs.len() > 1; let show_tabs = tabs.len() > 1;
let mini_view_h = task_mini_view_height(&app.selected_worker_view().task_store, show_tabs); let mini_view_h = task_mini_view_height(&app.selected_worker_view().task_store, show_tabs);
// One blank row separates the history tail from the mini-view so let run_status_h = u16::from(app.running);
// the latest message doesn't visually crash into the task summary. let run_status_gap = run_status_h;
// Folds away with the mini-view when there are no tasks. // One blank row separates the history tail from the run/task mini-view so
let mini_view_gap = if mini_view_h > 0 { 1 } else { 0 }; // the latest message doesn't visually crash into operational status.
// Folds away when neither run status nor tasks are visible.
let mini_view_gap = if mini_view_h > 0 || run_status_h > 0 {
1
} else {
0
};
let chunks = Layout::vertical([ let chunks = Layout::vertical([
Constraint::Min(0), // history view Constraint::Min(0), // history view
Constraint::Length(mini_view_gap), // gap above mini-view Constraint::Length(mini_view_gap), // gap above run/task mini-view
Constraint::Length(run_status_h), // active run status
Constraint::Length(run_status_gap), // gap below active run status
Constraint::Length(mini_view_h), // task mini-view (0 when empty) Constraint::Length(mini_view_h), // task mini-view (0 when empty)
Constraint::Length(1), // separator Constraint::Length(1), // separator
Constraint::Length(1), // status Constraint::Length(1), // status
@@ -82,24 +93,27 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
} else { } else {
draw_history(frame, app, chunks[0]); draw_history(frame, app, chunks[0]);
} }
if run_status_h > 0 {
draw_run_status(frame, app, chunks[2]);
}
if mini_view_h > 0 { if mini_view_h > 0 {
draw_task_mini_view( draw_task_mini_view(
frame, frame,
&app.selected_worker_view().task_store, &app.selected_worker_view().task_store,
&tabs, &tabs,
chunks[2], chunks[4],
); );
} }
draw_separator(frame, chunks[3]); draw_separator(frame, chunks[5]);
// Status/composer/control surfaces remain parent-owned. View selection changes // Status/composer/control surfaces remain parent-owned. View selection changes
// only transcript/task presentation and never implies SubWorker control. // only transcript/task presentation and never implies SubWorker control.
draw_status(frame, app, chunks[4]); draw_status(frame, app, chunks[6]);
draw_input(frame, app, &input_render, chunks[5]); draw_input(frame, app, &input_render, chunks[7]);
draw_actionbar(frame, app, chunks[6]); draw_actionbar(frame, app, chunks[8]);
if app.is_command_mode() { if app.is_command_mode() {
draw_command_popup(frame, app, chunks[5]); draw_command_popup(frame, app, chunks[7]);
} else if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) { } else if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) {
draw_completion_popup(frame, state, chunks[5]); draw_completion_popup(frame, state, chunks[7]);
} }
} }
@@ -120,6 +134,65 @@ fn task_mini_view_height(store: &TaskStore, show_tabs: bool) -> u16 {
(active_shown as u16).saturating_add(1) (active_shown as u16).saturating_add(1)
} }
fn draw_run_status(frame: &mut Frame, app: &App, area: Rect) {
frame.render_widget(Paragraph::new(run_status_line(app, Instant::now())), area);
}
fn run_status_line(app: &App, now: Instant) -> Line<'static> {
let elapsed = app
.run_started_at
.and_then(|started_at| now.checked_duration_since(started_at))
.unwrap_or_default();
let spinner_index =
((elapsed.as_millis() / RUN_SPINNER_FRAME_MS) as usize) % RUN_SPINNER_FRAMES.len();
let request_label = if app.run_requests == 1 {
"1 req".to_owned()
} else {
format!("{} reqs", app.run_requests)
};
Line::from(vec![
Span::styled(
RUN_SPINNER_FRAMES[spinner_index],
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
fmt_run_elapsed(elapsed.as_secs()),
Style::default().fg(Color::Gray),
),
Span::styled("", Style::default().fg(Color::DarkGray)),
Span::styled(request_label, Style::default().fg(Color::Gray)),
Span::styled(" | ", Style::default().fg(Color::DarkGray)),
Span::styled("", Style::default().fg(Color::Green)),
Span::styled(
fmt_tokens(app.run_upload_tokens),
Style::default().fg(Color::Green),
),
Span::styled("/", Style::default().fg(Color::DarkGray)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(
fmt_tokens(app.run_output_tokens),
Style::default().fg(Color::Yellow),
),
])
}
fn fmt_run_elapsed(secs: u64) -> String {
let hours = secs / 3600;
let minutes = (secs % 3600) / 60;
let seconds = secs % 60;
if hours > 0 {
format!("{hours}h {minutes}m {seconds:02}s")
} else if minutes > 0 {
format!("{minutes}m {seconds:02}s")
} else {
format!("{seconds}s")
}
}
fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, tabs: &[WorkerViewTab], area: Rect) { fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, tabs: &[WorkerViewTab], area: Rect) {
if area.height == 0 || area.width == 0 { if area.height == 0 || area.width == 0 {
return; return;
@@ -1223,6 +1296,18 @@ fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) {
Style::default().fg(Color::Magenta), Style::default().fg(Color::Magenta),
format!("[Clipboard #{id} | {chars} chars, {line_count} lines]"), format!("[Clipboard #{id} | {chars} chars, {line_count} lines]"),
), ),
Segment::PasteArtifact { artifact } => (
Style::default().fg(Color::Magenta),
format!(
"[Paste artifact {} | {} chars, {} lines, {}, {}, created {} ms]",
artifact.artifact_id,
artifact.char_count,
artifact.line_count,
artifact.media_type.as_str(),
artifact.availability.as_str(),
artifact.created_at_ms
),
),
Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")), Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")),
Segment::Flow { selector } => ( Segment::Flow { selector } => (
Style::default().fg(Color::Yellow), Style::default().fg(Color::Yellow),
@@ -1241,6 +1326,15 @@ fn segment_display_text(seg: &Segment) -> String {
Segment::Paste { Segment::Paste {
id, chars, lines, .. id, chars, lines, ..
} => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"), } => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"),
Segment::PasteArtifact { artifact } => format!(
"[Paste artifact {} | {} chars, {} lines, {}, {}, created {} ms]",
artifact.artifact_id,
artifact.char_count,
artifact.line_count,
artifact.media_type.as_str(),
artifact.availability.as_str(),
artifact.created_at_ms
),
Segment::FileRef { path } => format!("@{path}"), Segment::FileRef { path } => format!("@{path}"),
Segment::Flow { selector } => format!("[Flow: {selector}]"), Segment::Flow { selector } => format!("[Flow: {selector}]"),
Segment::Unknown => "[unknown segment]".to_owned(), Segment::Unknown => "[unknown segment]".to_owned(),
@@ -1726,32 +1820,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
), ),
]; ];
if app.running { if app.paused {
let status = if let Some(wait_event) = &app.latest_llm_wait_event {
format!(
"request: {} | ↑{}/↓{} | {wait_event}",
app.run_requests,
fmt_tokens(app.run_upload_tokens),
fmt_tokens(app.run_output_tokens),
)
} else if let Some(tool) = &app.current_tool {
format!(
"request: {} | ↑{}/↓{} | tool: {tool}",
app.run_requests,
fmt_tokens(app.run_upload_tokens),
fmt_tokens(app.run_output_tokens),
)
} else {
format!(
"request: {} | ↑{}/↓{}",
app.run_requests,
fmt_tokens(app.run_upload_tokens),
fmt_tokens(app.run_output_tokens),
)
};
spans.push(Span::raw(" | "));
spans.push(Span::styled(status, Style::default().fg(Color::Yellow)));
} else if app.paused {
spans.push(Span::raw(" | ")); spans.push(Span::raw(" | "));
spans.push(Span::styled( spans.push(Span::styled(
"paused", "paused",
@@ -1763,7 +1832,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
" — Enter to resume, Ctrl-X to cancel, type to start new turn", " — Enter to resume, Ctrl-X to cancel, type to start new turn",
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
} else { } else if !app.running {
spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray))); spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray)));
} }
@@ -2053,6 +2122,28 @@ mod tests {
use protocol::WorkerStatus; use protocol::WorkerStatus;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
#[test]
fn run_status_line_matches_console_metrics_and_spinner_frame() {
let now = Instant::now();
let mut app = App::new("worker".into());
app.run_started_at = now.checked_sub(Duration::from_millis(160));
app.run_requests = 1;
app.run_upload_tokens = 1_200;
app.run_output_tokens = 45;
assert_eq!(
line_text(&run_status_line(&app, now)),
"⣟ 0s ・ 1 req | ↑1.2k/↓45"
);
}
#[test]
fn run_elapsed_uses_console_style_units() {
assert_eq!(fmt_run_elapsed(9), "9s");
assert_eq!(fmt_run_elapsed(65), "1m 05s");
assert_eq!(fmt_run_elapsed(3_726), "1h 2m 06s");
}
#[test] #[test]
fn task_summary_right_aligns_worker_tabs_and_highlights_selection() { fn task_summary_right_aligns_worker_tabs_and_highlights_selection() {
let tabs = vec![ let tabs = vec![
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -18,6 +18,7 @@ sha2.workspace = true
tempfile.workspace = true tempfile.workspace = true
thiserror.workspace = true thiserror.workspace = true
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] } tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
workspace-api = { workspace = true }
[dev-dependencies] [dev-dependencies]
serde_json.workspace = true serde_json.workspace = true
+5
View File
@@ -743,6 +743,7 @@ mod tests {
command: command.into(), command: command.into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024, output_limit: 1024,
spill_dir: None,
tool_call_id: Some(tool_call_id.into()), tool_call_id: Some(tool_call_id.into()),
}) })
.await .await
@@ -770,6 +771,7 @@ mod tests {
command: "printf ready; sleep 0.2; printf done".into(), command: "printf ready; sleep 0.2; printf done".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024, output_limit: 1024,
spill_dir: None,
tool_call_id: Some("tool-delegated".into()), tool_call_id: Some("tool-delegated".into()),
}) })
.await .await
@@ -858,6 +860,7 @@ mod tests {
command: "printf denied".into(), command: "printf denied".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024, output_limit: 1024,
spill_dir: None,
tool_call_id: Some("read-only-command".into()), tool_call_id: Some("read-only-command".into()),
}) })
.await, .await,
@@ -993,6 +996,7 @@ mod tests {
command: "printf revoked".into(), command: "printf revoked".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024, output_limit: 1024,
spill_dir: None,
tool_call_id: Some("revoked-child-command".into()), tool_call_id: Some("revoked-child-command".into()),
}) })
.await, .await,
@@ -1171,6 +1175,7 @@ mod tests {
command: "printf closed".into(), command: "printf closed".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024, output_limit: 1024,
spill_dir: None,
tool_call_id: Some("closed-parent-command".into()), tool_call_id: Some("closed-parent-command".into()),
}) })
.await, .await,
+230 -19
View File
@@ -102,11 +102,63 @@ pub enum WorkdirTransportErrorCode {
Conflict, Conflict,
Unsupported, Unsupported,
InvalidRequest, InvalidRequest,
Denied,
OutOfScope,
SymlinkOutOfScope,
BrokenSymlink,
SymlinkTargetIsDirectory,
ReadOnly,
IsDirectory,
SymlinkDirectoryNotTraversed,
UnknownCommand, UnknownCommand,
Unavailable, Unavailable,
Io,
Transport,
Internal, Internal,
} }
impl WorkdirTransportErrorCode {
pub const fn as_str(self) -> &'static str {
match self {
Self::NotFound => "not_found",
Self::Conflict => "conflict",
Self::Unsupported => "unsupported",
Self::InvalidRequest => "invalid_request",
Self::Denied => "denied",
Self::OutOfScope => "out_of_scope",
Self::SymlinkOutOfScope => "symlink_out_of_scope",
Self::BrokenSymlink => "broken_symlink",
Self::SymlinkTargetIsDirectory => "symlink_target_is_directory",
Self::ReadOnly => "read_only",
Self::IsDirectory => "is_directory",
Self::SymlinkDirectoryNotTraversed => "symlink_directory_not_traversed",
Self::UnknownCommand => "unknown_command",
Self::Unavailable => "unavailable",
Self::Io => "io",
Self::Transport => "transport",
Self::Internal => "internal",
}
}
/// Shared public HTTP classification for Runtime and Workspace Workdir operation boundaries.
pub const fn http_status(self) -> u16 {
match self {
Self::NotFound | Self::UnknownCommand => 404,
Self::Conflict => 409,
Self::Denied | Self::OutOfScope | Self::SymlinkOutOfScope | Self::ReadOnly => 403,
Self::Unsupported
| Self::InvalidRequest
| Self::BrokenSymlink
| Self::SymlinkTargetIsDirectory
| Self::IsDirectory
| Self::SymlinkDirectoryNotTraversed => 400,
Self::Unavailable => 503,
Self::Io | Self::Internal => 500,
Self::Transport => 502,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkdirTransportError { pub struct WorkdirTransportError {
pub code: WorkdirTransportErrorCode, pub code: WorkdirTransportErrorCode,
@@ -126,14 +178,50 @@ impl WorkdirTransportError {
message: format!("Workdir capability {capability:?} is not available"), message: format!("Workdir capability {capability:?} is not available"),
}; };
} }
WorkdirError::UnsupportedOperation(_) => {
(Code::Unsupported, "Workdir operation is not supported")
}
WorkdirError::UnknownCommand(_) => { WorkdirError::UnknownCommand(_) => {
(Code::UnknownCommand, "Workdir command was not found") (Code::UnknownCommand, "Workdir command was not found")
} }
WorkdirError::Unavailable(_) | WorkdirError::SessionClosed => { WorkdirError::Unavailable(_) | WorkdirError::SessionClosed => {
(Code::Unavailable, "Workdir session is unavailable") (Code::Unavailable, "Workdir session is unavailable")
} }
WorkdirError::Denied(_) => (Code::InvalidRequest, "Workdir operation was denied"), WorkdirError::Denied(_) => (Code::Denied, "Workdir operation was denied"),
WorkdirError::Transport(_) => (Code::Internal, "Workdir transport failed"), WorkdirError::OutOfScope(_) => (Code::OutOfScope, "Workdir path is out of scope"),
WorkdirError::SymlinkOutOfScope { .. } => (
Code::SymlinkOutOfScope,
"Workdir symlink target is out of scope",
),
WorkdirError::BrokenSymlink { .. } => {
(Code::BrokenSymlink, "Workdir symlink target does not exist")
}
WorkdirError::SymlinkTargetIsDirectory { .. } => (
Code::SymlinkTargetIsDirectory,
"Workdir symlink target is a directory",
),
WorkdirError::ReadOnly(_) => (Code::ReadOnly, "Workdir path is read-only"),
WorkdirError::IsDirectory(_) => (Code::IsDirectory, "Workdir path is a directory"),
WorkdirError::SymlinkDirectoryNotTraversed { .. } => (
Code::SymlinkDirectoryNotTraversed,
"Workdir symlink directory was not traversed",
),
WorkdirError::Io { source, .. } => match source.kind() {
std::io::ErrorKind::NotFound => (Code::NotFound, "Workdir path was not found"),
std::io::ErrorKind::PermissionDenied => {
(Code::Denied, "Workdir operation was denied")
}
std::io::ErrorKind::AlreadyExists => {
(Code::Conflict, "Workdir resource already exists")
}
std::io::ErrorKind::InvalidInput | std::io::ErrorKind::InvalidData => {
(Code::InvalidRequest, "Workdir operation request is invalid")
}
std::io::ErrorKind::TimedOut => (Code::Unavailable, "Workdir operation timed out"),
_ => (Code::Io, "Workdir I/O operation failed"),
},
WorkdirError::OperationFailed => (Code::Internal, "Workdir operation failed"),
WorkdirError::Transport(_) => (Code::Transport, "Workdir transport failed"),
WorkdirError::InvalidPath(_) WorkdirError::InvalidPath(_)
| WorkdirError::RelativePath(_) | WorkdirError::RelativePath(_)
| WorkdirError::InvalidGlob(_) | WorkdirError::InvalidGlob(_)
@@ -141,14 +229,6 @@ impl WorkdirTransportError {
| WorkdirError::InvalidArgument(_) => { | WorkdirError::InvalidArgument(_) => {
(Code::InvalidRequest, "Workdir operation request is invalid") (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 { Self {
code, code,
@@ -161,11 +241,39 @@ impl WorkdirTransportError {
match self.code { match self.code {
Code::NotFound => WorkdirError::NotFound("<remote>".into()), Code::NotFound => WorkdirError::NotFound("<remote>".into()),
Code::Conflict => WorkdirError::Conflict(self.message), Code::Conflict => WorkdirError::Conflict(self.message),
Code::Unsupported => WorkdirError::Unavailable(self.message), Code::Unsupported => WorkdirError::UnsupportedOperation(self.message),
Code::UnknownCommand => WorkdirError::UnknownCommand("<remote>".to_string()),
Code::InvalidRequest => WorkdirError::InvalidArgument(self.message), Code::InvalidRequest => WorkdirError::InvalidArgument(self.message),
Code::Denied => WorkdirError::Denied(self.message),
Code::OutOfScope => WorkdirError::OutOfScope("<remote>".into()),
Code::SymlinkOutOfScope => WorkdirError::SymlinkOutOfScope {
path: "<remote>".into(),
target: "<remote-target>".into(),
required_permission: "requested",
},
Code::BrokenSymlink => WorkdirError::BrokenSymlink {
path: "<remote>".into(),
link: "<remote-link>".into(),
target: "<remote-target>".into(),
},
Code::SymlinkTargetIsDirectory => WorkdirError::SymlinkTargetIsDirectory {
path: "<remote>".into(),
target: "<remote-target>".into(),
},
Code::ReadOnly => WorkdirError::ReadOnly("<remote>".into()),
Code::IsDirectory => WorkdirError::IsDirectory("<remote>".into()),
Code::SymlinkDirectoryNotTraversed => WorkdirError::SymlinkDirectoryNotTraversed {
tool: "remote operation",
path: "<remote>".into(),
target: "<remote-target>".into(),
},
Code::UnknownCommand => WorkdirError::UnknownCommand("<remote>".to_string()),
Code::Unavailable => WorkdirError::Unavailable(self.message), Code::Unavailable => WorkdirError::Unavailable(self.message),
Code::Internal => WorkdirError::Transport(self.message), Code::Io => WorkdirError::Io {
path: "<remote>".into(),
source: std::io::Error::other(self.message),
},
Code::Transport => WorkdirError::Transport(self.message),
Code::Internal => WorkdirError::OperationFailed,
} }
} }
} }
@@ -517,13 +625,13 @@ mod client {
.json::<WorkdirTransportError>() .json::<WorkdirTransportError>()
.await .await
.map(WorkdirTransportError::into_workdir_error) .map(WorkdirTransportError::into_workdir_error)
.unwrap_or_else(|error| { .unwrap_or_else(|_| {
WorkdirError::Unavailable(format!("Runtime HTTP error: {error}")) WorkdirError::Transport("Runtime Workdir error response was invalid".to_string())
}) })
} }
fn http_unavailable(error: reqwest::Error) -> WorkdirError { fn http_unavailable(_error: reqwest::Error) -> WorkdirError {
WorkdirError::Unavailable(format!("Runtime Workdir HTTP request failed: {error}")) WorkdirError::Transport("Runtime Workdir HTTP request failed".to_string())
} }
pub use self::RemoteWorkdirSession as ClientSession; pub use self::RemoteWorkdirSession as ClientSession;
@@ -536,12 +644,98 @@ pub use client::{ClientSession as RemoteWorkdirSession, WorkdirHttpAuthorization
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn transport_error_round_trip_keeps_public_classification() {
for (code, expected_status, expected_error) in [
(
WorkdirTransportErrorCode::InvalidRequest,
400,
"invalid argument",
),
(WorkdirTransportErrorCode::NotFound, 404, "file not found"),
(
WorkdirTransportErrorCode::UnknownCommand,
404,
"unknown Workdir session command",
),
(
WorkdirTransportErrorCode::Conflict,
409,
"modified externally",
),
(WorkdirTransportErrorCode::Unsupported, 400, "unsupported"),
(WorkdirTransportErrorCode::Denied, 403, "denied"),
(
WorkdirTransportErrorCode::OutOfScope,
403,
"outside allowed scope",
),
(
WorkdirTransportErrorCode::SymlinkOutOfScope,
403,
"outside allowed requested scope",
),
(
WorkdirTransportErrorCode::BrokenSymlink,
400,
"broken symlink",
),
(
WorkdirTransportErrorCode::SymlinkTargetIsDirectory,
400,
"symlink to a directory",
),
(WorkdirTransportErrorCode::ReadOnly, 403, "read-only"),
(WorkdirTransportErrorCode::IsDirectory, 400, "expected file"),
(
WorkdirTransportErrorCode::SymlinkDirectoryNotTraversed,
400,
"does not follow symlink directories",
),
(WorkdirTransportErrorCode::Unavailable, 503, "unavailable"),
(WorkdirTransportErrorCode::Io, 500, "I/O error"),
(
WorkdirTransportErrorCode::Transport,
502,
"transport failed",
),
(WorkdirTransportErrorCode::Internal, 500, "operation failed"),
] {
let transport = WorkdirTransportError {
code,
message: "safe provider message".to_string(),
};
assert_eq!(code.http_status(), expected_status);
let workdir_error = transport.clone().into_workdir_error();
assert!(workdir_error.to_string().contains(expected_error));
assert_eq!(
WorkdirTransportError::from_workdir_error(&workdir_error).code,
code
);
}
}
#[test]
fn local_validation_errors_share_invalid_request_classification() {
for error in [
WorkdirError::InvalidGlob("[".to_string()),
WorkdirError::InvalidRegex("(".to_string()),
WorkdirError::InvalidArgument("limit must be positive".to_string()),
] {
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, WorkdirTransportErrorCode::InvalidRequest);
assert_eq!(transport.code.http_status(), 400);
assert_eq!(transport.message, "Workdir operation request is invalid");
}
}
#[test] #[test]
fn transport_failure_remains_distinct_from_session_unavailable() { fn transport_failure_remains_distinct_from_session_unavailable() {
let transport = WorkdirTransportError::from_workdir_error(&WorkdirError::Transport( let transport = WorkdirTransportError::from_workdir_error(&WorkdirError::Transport(
"Workspace API request timed out".to_string(), "Workspace API request timed out".to_string(),
)); ));
assert_eq!(transport.code, WorkdirTransportErrorCode::Internal); assert_eq!(transport.code, WorkdirTransportErrorCode::Transport);
assert_eq!(transport.code.http_status(), 502);
assert_eq!(transport.message, "Workdir transport failed"); assert_eq!(transport.message, "Workdir transport failed");
assert!(matches!( assert!(matches!(
transport.into_workdir_error(), transport.into_workdir_error(),
@@ -556,8 +750,25 @@ mod tests {
source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "host detail"), source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "host detail"),
}; };
let transport = WorkdirTransportError::from_workdir_error(&error); let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, WorkdirTransportErrorCode::Internal); assert_eq!(transport.code, WorkdirTransportErrorCode::Denied);
assert!(!transport.message.contains("/secret")); assert!(!transport.message.contains("/secret"));
assert!(!transport.message.contains("host detail")); assert!(!transport.message.contains("host detail"));
assert!(matches!(
transport.into_workdir_error(),
WorkdirError::Denied(_)
));
let error = WorkdirError::Io {
path: "/secret/runtime/root/file".into(),
source: std::io::Error::other("host detail"),
};
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, WorkdirTransportErrorCode::Io);
assert!(!transport.message.contains("/secret"));
assert!(!transport.message.contains("host detail"));
assert!(matches!(
transport.into_workdir_error(),
WorkdirError::Io { .. }
));
} }
} }
+6
View File
@@ -225,12 +225,18 @@ pub enum WorkdirError {
#[error("Workdir session does not support {0:?}")] #[error("Workdir session does not support {0:?}")]
Unsupported(WorkdirSessionCapability), Unsupported(WorkdirSessionCapability),
#[error("Workdir operation is unsupported: {0}")]
UnsupportedOperation(String),
#[error("invalid Workdir path: {0}")] #[error("invalid Workdir path: {0}")]
InvalidPath(String), InvalidPath(String),
#[error("Workdir session is unavailable: {0}")] #[error("Workdir session is unavailable: {0}")]
Unavailable(String), Unavailable(String),
#[error("Workdir operation failed")]
OperationFailed,
#[error("Workdir transport failed: {0}")] #[error("Workdir transport failed: {0}")]
Transport(String), Transport(String),
+234 -6
View File
@@ -10,9 +10,7 @@
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::fmt::Debug; use std::fmt::Debug;
#[cfg(test)] use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
use std::io::Write as _;
use std::io::{Read as _, Seek as _, SeekFrom};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Stdio; use std::process::Stdio;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
@@ -691,6 +689,11 @@ impl WorkdirSession for LocalWorkdirSession {
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> { async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Command)?; self.ensure_capability(WorkdirSessionCapability::Command)?;
self.ensure_open()?; self.ensure_open()?;
if let Some(spill_dir) = request.spill_dir.as_deref()
&& !self.inner.scope.snapshot().is_readable(spill_dir)
{
return Err(WorkdirError::OutOfScope(spill_dir.to_path_buf()));
}
let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed); let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
let handle = CommandHandle(format!("command-{id}")); let handle = CommandHandle(format!("command-{id}"));
let cwd = self.inner.cwd.clone(); let cwd = self.inner.cwd.clone();
@@ -776,6 +779,7 @@ impl WorkdirSession for LocalWorkdirSession {
content: String::new(), content: String::new(),
next_cursor: None, next_cursor: None,
truncated: false, truncated: false,
output_path: None,
}); });
} }
drop(commands); drop(commands);
@@ -792,6 +796,7 @@ impl WorkdirSession for LocalWorkdirSession {
content: String::new(), content: String::new(),
next_cursor: None, next_cursor: None,
truncated: false, truncated: false,
output_path: None,
}); });
} }
break commands break commands
@@ -901,6 +906,7 @@ fn command_output_page(output: &CommandOutput, cursor: usize, limit: usize) -> C
content, content,
next_cursor: (end < total_chars).then_some(end), next_cursor: (end < total_chars).then_some(end),
truncated: output.truncated || end < total_chars, truncated: output.truncated || end < total_chars,
output_path: output.output_path.clone(),
} }
} }
@@ -1059,6 +1065,22 @@ async fn run_command(
let (content, truncated) = let (content, truncated) =
read_command_output_files(&stdout_path, &stderr_path, request.output_limit.max(1))?; read_command_output_files(&stdout_path, &stderr_path, request.output_limit.max(1))?;
let output_path = match (truncated, request.spill_dir) {
(true, Some(spill_dir)) => {
let stdout_path = stdout_path.to_path_buf();
let stderr_path = stderr_path.to_path_buf();
Some(
tokio::task::spawn_blocking(move || {
persist_command_output(&stdout_path, &stderr_path, &spill_dir)
})
.await
.map_err(|error| {
WorkdirError::Unavailable(format!("Bash output spill task failed: {error}"))
})??,
)
}
_ => None,
};
Ok(CommandOutput { Ok(CommandOutput {
status, status,
exit_code, exit_code,
@@ -1066,6 +1088,7 @@ async fn run_command(
content, content,
next_cursor: None, next_cursor: None,
truncated, truncated,
output_path,
}) })
} }
@@ -1154,6 +1177,59 @@ fn stable_utf8_prefix_len(bytes: &[u8]) -> usize {
inspected inspected
} }
fn persist_command_output(
stdout_path: &Path,
stderr_path: &Path,
spill_dir: &Path,
) -> Result<PathBuf, WorkdirError> {
std::fs::create_dir_all(spill_dir).map_err(|error| WorkdirError::io(spill_dir, error))?;
let mut artifact = tempfile::Builder::new()
.prefix("bash-")
.suffix(".log")
.tempfile_in(spill_dir)
.map_err(|error| WorkdirError::io(spill_dir, error))?;
let artifact_path = artifact.path().to_path_buf();
let mut stdout =
std::fs::File::open(stdout_path).map_err(|error| WorkdirError::io(stdout_path, error))?;
let stdout_len = stdout
.metadata()
.map_err(|error| WorkdirError::io(stdout_path, error))?
.len();
std::io::copy(&mut stdout, &mut artifact)
.map_err(|error| WorkdirError::io(&artifact_path, error))?;
let mut stderr =
std::fs::File::open(stderr_path).map_err(|error| WorkdirError::io(stderr_path, error))?;
let stderr_len = stderr
.metadata()
.map_err(|error| WorkdirError::io(stderr_path, error))?
.len();
if stdout_len > 0 && stderr_len > 0 {
stdout
.seek(SeekFrom::End(-1))
.map_err(|error| WorkdirError::io(stdout_path, error))?;
let mut last = [0_u8; 1];
stdout
.read_exact(&mut last)
.map_err(|error| WorkdirError::io(stdout_path, error))?;
if last[0] != b'\n' {
artifact
.write_all(b"\n")
.map_err(|error| WorkdirError::io(&artifact_path, error))?;
}
}
std::io::copy(&mut stderr, &mut artifact)
.map_err(|error| WorkdirError::io(&artifact_path, error))?;
artifact
.flush()
.map_err(|error| WorkdirError::io(&artifact_path, error))?;
artifact
.keep()
.map(|(_, path)| path)
.map_err(|error| WorkdirError::io(&artifact_path, error.error))
}
fn read_command_output_files( fn read_command_output_files(
stdout_path: &Path, stdout_path: &Path,
stderr_path: &Path, stderr_path: &Path,
@@ -1440,6 +1516,7 @@ mod tests {
command: "sleep 30".to_owned(), command: "sleep 30".to_owned(),
timeout_secs: 60, timeout_secs: 60,
output_limit: 1024, output_limit: 1024,
spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
) )
@@ -1943,9 +2020,9 @@ mod tests {
&workdir, &workdir,
GrepRequest { GrepRequest {
pattern: "NEEDLE".into(), pattern: "NEEDLE".into(),
path: WorkdirPath::root(), path: WorkdirPath::new("src/main.rs").unwrap(),
glob: None, glob: Some("src/*.rs".into()),
file_type: None, file_type: Some("rust".into()),
case_insensitive: false, case_insensitive: false,
before_context: 0, before_context: 0,
after_context: 0, after_context: 0,
@@ -1966,6 +2043,7 @@ mod tests {
command: "pwd && printf provider-command".into(), command: "pwd && printf provider-command".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 4096, output_limit: 4096,
spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
) )
@@ -1991,6 +2069,151 @@ mod tests {
); );
} }
#[tokio::test]
async fn explicitly_scoped_absolute_artifact_can_be_read_and_grepped() {
let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let artifact = spill.path().join("bash-output.log");
std::fs::write(&artifact, "first\nFINAL-NEEDLE\nlast\n").unwrap();
let scope = Scope::from_config(&ScopeConfig {
allow: vec![
ScopeRule {
target: dir.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
},
ScopeRule {
target: spill.path().to_path_buf(),
permission: Permission::Read,
recursive: true,
},
],
deny: Vec::new(),
})
.unwrap();
let workdir = LocalWorkdirSession::new(scope, dir.path().to_path_buf());
let artifact_path = WorkdirPath::new_scoped(artifact.to_string_lossy()).unwrap();
let read = WorkdirSession::read(
&workdir,
ReadRequest {
path: artifact_path.clone(),
offset: 1,
limit: 1,
max_bytes: 1024,
},
)
.await
.unwrap();
assert_eq!(String::from_utf8(read.bytes).unwrap(), "FINAL-NEEDLE\n");
let grep = WorkdirSession::grep(
&workdir,
GrepRequest {
pattern: "FINAL-NEEDLE".into(),
path: artifact_path,
glob: None,
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: crate::GrepOutputMode::Content,
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(grep.match_count, 1);
assert!(grep.output.contains("FINAL-NEEDLE"));
}
#[tokio::test]
async fn command_rejects_spill_directory_without_read_scope() {
let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let workdir = make_fs(&dir);
let error = WorkdirSession::start_command(
&workdir,
CommandRequest {
command: "printf hidden".into(),
timeout_secs: 5,
output_limit: 1,
spill_dir: Some(spill.path().to_path_buf()),
tool_call_id: None,
},
)
.await
.unwrap_err();
assert!(matches!(error, WorkdirError::OutOfScope(path) if path == spill.path()));
}
#[tokio::test]
async fn truncated_command_output_is_retained_in_the_requested_spill_directory() {
let dir = TempDir::new().unwrap();
let spill = TempDir::new().unwrap();
let scope = Scope::from_config(&ScopeConfig {
allow: vec![
ScopeRule {
target: dir.path().to_path_buf(),
permission: Permission::Write,
recursive: true,
},
ScopeRule {
target: spill.path().to_path_buf(),
permission: Permission::Read,
recursive: true,
},
],
deny: Vec::new(),
})
.unwrap();
let workdir = LocalWorkdirSession::new(scope, dir.path().to_path_buf());
let handle = WorkdirSession::start_command(
&workdir,
CommandRequest {
command: "i=0; while [ $i -lt 200 ]; do printf 'line-%03d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'".into(),
timeout_secs: 5,
output_limit: 64,
spill_dir: Some(spill.path().to_path_buf()),
tool_call_id: None,
},
)
.await
.unwrap();
let output = WorkdirSession::command_output(
&workdir,
CommandOutputRequest {
handle,
cursor: 0,
limit: 4096,
wait: true,
},
)
.await
.unwrap();
assert!(output.truncated);
let output_path = output.output_path.expect("retained output path");
assert_eq!(output_path.parent(), Some(spill.path()));
let retained = std::fs::read_to_string(&output_path).unwrap();
assert!(retained.starts_with("line-000\n"));
assert!(retained.ends_with("FINAL-NEEDLE\n"));
assert_eq!(retained.lines().count(), 201);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
assert_eq!(
std::fs::metadata(output_path).unwrap().permissions().mode() & 0o777,
0o600
);
}
}
#[tokio::test] #[tokio::test]
async fn completed_command_output_can_be_read_in_bounded_unicode_pages() { async fn completed_command_output_can_be_read_in_bounded_unicode_pages() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -2001,6 +2224,7 @@ mod tests {
command: "printf 'aéz'".into(), command: "printf 'aéz'".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024, output_limit: 1024,
spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
) )
@@ -2120,6 +2344,7 @@ mod tests {
content: "done".into(), content: "done".into(),
next_cursor: None, next_cursor: None,
truncated: false, truncated: false,
output_path: None,
}) })
}); });
workdir.inner.commands.lock().await.insert( workdir.inner.commands.lock().await.insert(
@@ -2224,6 +2449,7 @@ mod tests {
command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(), command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(),
timeout_secs: 5, timeout_secs: 5,
output_limit: 1024, output_limit: 1024,
spill_dir: None,
tool_call_id: Some("tool-7".into()), tool_call_id: Some("tool-7".into()),
}, },
) )
@@ -2327,6 +2553,7 @@ mod tests {
command: "sleep 30".into(), command: "sleep 30".into(),
timeout_secs: 1, timeout_secs: 1,
output_limit: 1024, output_limit: 1024,
spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
) )
@@ -2396,6 +2623,7 @@ mod tests {
command: "sleep 30".into(), command: "sleep 30".into(),
timeout_secs: 60, timeout_secs: 60,
output_limit: 1024, output_limit: 1024,
spill_dir: None,
tool_call_id: None, tool_call_id: None,
}, },
) )
+7
View File
@@ -1,3 +1,5 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -9,6 +11,9 @@ pub struct CommandRequest {
pub command: String, pub command: String,
pub timeout_secs: u64, pub timeout_secs: u64,
pub output_limit: usize, pub output_limit: usize,
/// Provider-local directory where complete output is retained when the
/// inline result exceeds `output_limit`.
pub spill_dir: Option<PathBuf>,
/// Optional caller-owned correlation id. Bash supplies its tool-call id so /// Optional caller-owned correlation id. Bash supplies its tool-call id so
/// user-facing command telemetry can update the corresponding Console row /// user-facing command telemetry can update the corresponding Console row
/// without exposing provider/session handles. /// without exposing provider/session handles.
@@ -96,4 +101,6 @@ pub struct CommandOutput {
pub content: String, pub content: String,
pub next_cursor: Option<usize>, pub next_cursor: Option<usize>,
pub truncated: bool, pub truncated: bool,
/// Complete output retained by the provider when `truncated` is true.
pub output_path: Option<PathBuf>,
} }
+11 -251
View File
@@ -6,7 +6,11 @@
//! [`crate::http`]. //! [`crate::http`].
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt;
pub use workspace_api::{
WorkingDirectoryCleanupTarget, WorkingDirectoryMaterializerKind as MaterializerKind,
WorkingDirectoryOccupancy, WorkingDirectoryStatusKind, WorkingDirectorySummary,
};
/// Stable Workspace identity for a Worker hosted by a Runtime. /// Stable Workspace identity for a Worker hosted by a Runtime.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
@@ -26,83 +30,6 @@ impl RuntimeWorkerRef {
} }
} }
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MaterializerKind {
#[default]
RuntimeGitCache,
/// Legacy persisted value from the pre-cache local `git worktree` materializer.
LocalGitWorktree,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkingDirectoryStatusKind {
Active,
CleanupPending,
Corrupted,
NotFound,
Unknown,
}
impl WorkingDirectoryStatusKind {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::CleanupPending => "cleanup_pending",
Self::Corrupted => "corrupted",
Self::NotFound => "not_found",
Self::Unknown => "unknown",
}
}
}
impl fmt::Display for WorkingDirectoryStatusKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectoryCleanupTarget {
pub kind: String,
pub working_directory_id: String,
pub repository_id: String,
}
/// Durable Workspace occupancy projection for one Workdir.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct WorkingDirectoryOccupancy {
#[serde(flatten)]
pub worker: RuntimeWorkerRef,
pub display_name: String,
pub linked_at: String,
}
impl<'de> Deserialize<'de> for WorkingDirectoryOccupancy {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Wire {
runtime_id: String,
worker_id: String,
display_name: String,
linked_at: String,
}
let wire = Wire::deserialize(deserializer)?;
Ok(Self {
worker: RuntimeWorkerRef::new(wire.runtime_id, wire.worker_id),
display_name: wire.display_name,
linked_at: wire.linked_at,
})
}
}
/// Immutable materialization provenance retained by Workspace inventory. /// Immutable materialization provenance retained by Workspace inventory.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -139,100 +66,6 @@ pub struct WorkingDirectoryCurrentObservation {
pub occupied_by: Option<WorkingDirectoryOccupancy>, pub occupied_by: Option<WorkingDirectoryOccupancy>,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectorySummary {
pub working_directory_id: String,
pub repository_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_at_epoch_seconds: Option<u64>,
pub materializer_kind: MaterializerKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
pub status: WorkingDirectoryStatusKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanliness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_worker_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub occupied_by: Option<WorkingDirectoryOccupancy>,
}
impl WorkingDirectorySummary {
/// Workspace-managed inventory rows carry explicit cleanup authority.
pub fn is_workspace_managed(&self) -> bool {
self.cleanup_target.is_some()
}
pub fn provenance(&self) -> WorkingDirectoryProvenance {
WorkingDirectoryProvenance {
creation_selector: self.creation_selector.clone(),
creation_ref: self.creation_ref.clone(),
creation_tree: self.creation_tree.clone(),
materializer_kind: self.materializer_kind.clone(),
cleanup_target: self.cleanup_target.clone(),
}
}
pub fn current_observation(&self) -> WorkingDirectoryCurrentObservation {
WorkingDirectoryCurrentObservation {
current_selector: self.current_selector.clone(),
current_ref: self.current_ref.clone(),
current_tree: self.current_tree.clone(),
observed_at_epoch_seconds: self.observed_at_epoch_seconds,
status: self.status.clone(),
cleanliness: self.cleanliness.clone(),
primary_worker_id: self.primary_worker_id.clone(),
occupied_by: self.occupied_by.clone(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkingDirectoryDiagnosticSeverity {
Info,
Warning,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectoryDiagnostic {
pub code: String,
pub severity: WorkingDirectoryDiagnosticSeverity,
pub message: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectoryListResponse {
pub workspace_id: String,
pub items: Vec<WorkingDirectorySummary>,
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkingDirectoryDetailResponse {
pub workspace_id: String,
pub runtime_id: String,
pub item: WorkingDirectorySummary,
pub diagnostics: Vec<WorkingDirectoryDiagnostic>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -255,88 +88,15 @@ mod tests {
} }
#[test] #[test]
fn occupied_and_free_list_response_round_trips() { fn workspace_workdir_projection_reexports_workspace_api_authority() {
let response = WorkingDirectoryListResponse {
workspace_id: "workspace".to_string(),
items: vec![
WorkingDirectorySummary {
working_directory_id: "occupied".to_string(),
repository_id: "repo".to_string(),
creation_selector: Some("develop".to_string()),
creation_ref: Some("abc123".to_string()),
creation_tree: Some("tree123".to_string()),
current_selector: Some("work/ticket".to_string()),
current_ref: Some("def456".to_string()),
current_tree: Some("tree456".to_string()),
observed_at_epoch_seconds: Some(1_777_777_777),
materializer_kind: MaterializerKind::LocalGitWorktree,
cleanup_target: Some(WorkingDirectoryCleanupTarget {
kind: "git_worktree".to_string(),
working_directory_id: "occupied".to_string(),
repository_id: "repo".to_string(),
}),
status: WorkingDirectoryStatusKind::Active,
cleanliness: Some("clean".to_string()),
primary_worker_id: None,
occupied_by: Some(WorkingDirectoryOccupancy {
worker: RuntimeWorkerRef::new("arcadia", "worker-opaque-64"),
display_name: "Coder".to_string(),
linked_at: "2026-08-12T00:00:00Z".to_string(),
}),
},
WorkingDirectorySummary {
working_directory_id: "free".to_string(),
repository_id: "repo".to_string(),
creation_selector: None,
creation_ref: None,
creation_tree: None,
current_selector: None,
current_ref: Some("987fed".to_string()),
current_tree: None,
observed_at_epoch_seconds: None,
materializer_kind: MaterializerKind::LocalGitWorktree,
cleanup_target: None,
status: WorkingDirectoryStatusKind::Active,
cleanliness: Some("unknown".to_string()),
primary_worker_id: None,
occupied_by: None,
},
],
diagnostics: vec![WorkingDirectoryDiagnostic {
code: "observed".to_string(),
severity: WorkingDirectoryDiagnosticSeverity::Info,
message: "inventory observed".to_string(),
}],
};
let encoded = serde_json::to_value(&response).unwrap();
assert_eq!( assert_eq!(
encoded["items"][0]["occupied_by"]["worker_id"], std::any::TypeId::of::<WorkingDirectorySummary>(),
"worker-opaque-64" std::any::TypeId::of::<workspace_api::WorkingDirectorySummary>()
); );
assert!( assert_eq!(
encoded["items"][0]["occupied_by"] std::any::TypeId::of::<WorkingDirectoryOccupancy>(),
.get("runtime_worker_id") std::any::TypeId::of::<workspace_api::WorkingDirectoryOccupancy>()
.is_none()
); );
assert!(encoded["items"][1].get("occupied_by").is_none());
let mut stale = encoded.clone();
stale["items"][0]["occupied_by"]["runtime_worker_id"] = serde_json::json!(64);
assert!(serde_json::from_value::<WorkingDirectoryListResponse>(stale).is_err());
let decoded: WorkingDirectoryListResponse = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded, response);
let detail = WorkingDirectoryDetailResponse {
workspace_id: decoded.workspace_id.clone(),
runtime_id: "arcadia".to_string(),
item: decoded.items[0].clone(),
diagnostics: decoded.diagnostics.clone(),
};
let encoded = serde_json::to_value(&detail).unwrap();
let decoded: WorkingDirectoryDetailResponse = serde_json::from_value(encoded).unwrap();
assert_eq!(decoded, detail);
} }
} }
+1
View File
@@ -17,6 +17,7 @@ const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.
pub const WORKER_REMOVE_PERMISSION: &str = "workspace:worker-remove"; pub const WORKER_REMOVE_PERMISSION: &str = "workspace:worker-remove";
pub const RUNTIME_REQUEST_SOURCE_PROOF_HEADER: &str = "x-yoi-runtime-request-proof"; pub const RUNTIME_REQUEST_SOURCE_PROOF_HEADER: &str = "x-yoi-runtime-request-proof";
pub const WORKSPACE_REQUEST_PERMISSION: &str = "workspace:request"; pub const WORKSPACE_REQUEST_PERMISSION: &str = "workspace:request";
pub const WORKSPACE_WORKER_DISCOVERY_PERMISSION: &str = "workspace:worker-discovery";
pub const BACKEND_RESOURCE_FETCH_PERMISSION: &str = "workspace:resource-fetch"; pub const BACKEND_RESOURCE_FETCH_PERMISSION: &str = "workspace:resource-fetch";
const RUNTIME_REQUEST_SOURCE_PROOF_PREFIX: &str = "yoi-runtime-request-v1"; const RUNTIME_REQUEST_SOURCE_PROOF_PREFIX: &str = "yoi-runtime-request-v1";
const RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-runtime-request-v1."; const RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-runtime-request-v1.";
+39 -13
View File
@@ -703,6 +703,7 @@ async fn run_workdir_session_operation(
content: String::new(), content: String::new(),
next_cursor: Some(cursor), next_cursor: Some(cursor),
truncated: false, truncated: false,
output_path: None,
}, },
}; };
WorkdirSessionOperationResult::CommandOutput(output) WorkdirSessionOperationResult::CommandOutput(output)
@@ -1721,17 +1722,8 @@ impl RuntimeHttpWorkdirError {
impl From<workdir::WorkdirError> for RuntimeHttpWorkdirError { impl From<workdir::WorkdirError> for RuntimeHttpWorkdirError {
fn from(error: workdir::WorkdirError) -> Self { fn from(error: workdir::WorkdirError) -> Self {
let payload = WorkdirTransportError::from_workdir_error(&error); let payload = WorkdirTransportError::from_workdir_error(&error);
let status = match payload.code { let status = StatusCode::from_u16(payload.code.http_status())
WorkdirTransportErrorCode::NotFound | WorkdirTransportErrorCode::UnknownCommand => { .expect("Workdir transport error status is valid");
StatusCode::NOT_FOUND
}
WorkdirTransportErrorCode::Conflict => StatusCode::CONFLICT,
WorkdirTransportErrorCode::Unsupported | WorkdirTransportErrorCode::InvalidRequest => {
StatusCode::BAD_REQUEST
}
WorkdirTransportErrorCode::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
WorkdirTransportErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR,
};
Self { status, payload } Self { status, payload }
} }
} }
@@ -1886,8 +1878,8 @@ mod tests {
use manifest::{Scope, SharedScope}; use manifest::{Scope, SharedScope};
use tower::ServiceExt; use tower::ServiceExt;
use workdir::{ use workdir::{
LocalWorkdirSession, ReadRequest, StatRequest, Workdir, WorkdirPath, GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, StatRequest, Workdir,
WorkdirSessionCapabilities, WorkdirPath, WorkdirSessionCapabilities,
}; };
fn test_bundle(profile: ProfileSelector) -> ConfigBundle { fn test_bundle(profile: ProfileSelector) -> ConfigBundle {
@@ -2348,6 +2340,40 @@ mod tests {
.expect("owned operation"); .expect("owned operation");
assert!(matches!(result, WorkdirSessionOperationResult::Stat(_))); assert!(matches!(result, WorkdirSessionOperationResult::Stat(_)));
let grep = WorkdirSessionOperationRequest {
delegations: Vec::new(),
operation: WorkdirSessionOperation::Grep(GrepRequest {
pattern: "hello".into(),
path: WorkdirPath::new("hello.txt").unwrap(),
glob: Some("*.txt".into()),
file_type: Some("txt".into()),
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: GrepOutputMode::Content,
limit: 10,
offset: 0,
}),
};
let Json(result) = run_workdir_session_operation(
State(state.clone()),
Path("session-1".to_string()),
Some(Extension(auth.clone())),
Ok(Json(grep)),
)
.await
.expect("grep direct file through provider operation");
match result {
WorkdirSessionOperationResult::Grep(result) => {
assert_eq!(result.match_count, 1);
assert_eq!(result.matched_files, 1);
assert!(result.output.starts_with("hello.txt\n"));
assert!(result.output.contains("> 1 │ hello"));
}
other => panic!("unexpected workdir grep result: {other:?}"),
}
#[cfg(unix)] #[cfg(unix)]
{ {
let delegated_visible = WorkdirSessionOperationRequest { let delegated_visible = WorkdirSessionOperationRequest {
+3 -107
View File
@@ -1,105 +1,9 @@
use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{fmt, str::FromStr};
use uuid::{Uuid, Version};
pub use protocol::{WorkerId, WorkerIdParseError};
pub use workdir::workspace::RuntimeWorkerRef; pub use workdir::workspace::RuntimeWorkerRef;
/// Stable Workspace-owned Worker identity.
///
/// Runtime placement is deliberately not part of this value. New identities are
/// allocated by Workspace authority before a Runtime create request is sent.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct WorkerId(Uuid);
impl WorkerId {
pub fn now_v7() -> Self {
Self(Uuid::now_v7())
}
/// Converts a legacy Runtime-local numeric id into a syntactically valid
/// migration-only UUIDv7 value. New Worker allocation must use `now_v7`.
pub fn from_legacy_u64(value: u64) -> Self {
let mut bytes = [0_u8; 16];
bytes[8..].copy_from_slice(&value.to_be_bytes());
bytes[6] = 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Self(Uuid::from_bytes(bytes))
}
pub fn from_legacy_binding(workspace_id: &str, runtime_id: &str, value: u64) -> Self {
let mut hasher = Sha256::new();
hasher.update(b"yoi.workspace-worker-id.v1\0");
hasher.update(workspace_id.as_bytes());
hasher.update([0]);
hasher.update(runtime_id.as_bytes());
hasher.update([0]);
hasher.update(value.to_be_bytes());
let digest = hasher.finalize();
let mut bytes = [0_u8; 16];
bytes.copy_from_slice(&digest[..16]);
// Migrated ids sort before normally allocated UUIDv7 values while retaining
// deterministic collision-resistant payload bits.
bytes[..6].fill(0);
bytes[6] = (bytes[6] & 0x0f) | 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
Self(Uuid::from_bytes(bytes))
}
pub fn parse(value: &str) -> Option<Self> {
let value = Uuid::parse_str(value).ok()?;
(value.get_version() == Some(Version::SortRand)).then_some(Self(value))
}
pub const fn as_uuid(self) -> Uuid {
self.0
}
}
impl fmt::Display for WorkerId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(formatter)
}
}
impl FromStr for WorkerId {
type Err = WorkerIdParseError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::parse(value).ok_or(WorkerIdParseError)
}
}
impl Serialize for WorkerId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for WorkerId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(&value).ok_or_else(|| de::Error::custom("Worker id must be a UUIDv7"))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WorkerIdParseError;
impl fmt::Display for WorkerIdParseError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("Worker id must be a UUIDv7")
}
}
impl std::error::Error for WorkerIdParseError {}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LegacyWorkerIdentityMapping { pub struct LegacyWorkerIdentityMapping {
pub workspace_id: String, pub workspace_id: String,
@@ -140,7 +44,7 @@ pub fn legacy_worker_identity_mapping_digest(mappings: &[LegacyWorkerIdentityMap
} }
/// Runtime-local authority reference for Worker operations. The contained id is /// Runtime-local authority reference for Worker operations. The contained id is
/// nevertheless the Workspace-owned stable identity; the Runtime does not mint it. /// nevertheless the stable Worker identity; the Runtime does not mint it.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct WorkerRef { pub struct WorkerRef {
pub worker_id: WorkerId, pub worker_id: WorkerId,
@@ -164,14 +68,6 @@ impl TryFrom<&RuntimeWorkerRef> for WorkerRef {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn worker_id_accepts_only_uuid_v7() {
let worker_id = WorkerId::now_v7();
assert_eq!(WorkerId::parse(&worker_id.to_string()), Some(worker_id));
assert!(WorkerId::parse("30").is_none());
assert!(WorkerId::parse(&Uuid::nil().to_string()).is_none());
}
#[test] #[test]
fn runtime_worker_ref_preserves_stable_worker_identity() { fn runtime_worker_ref_preserves_stable_worker_identity() {
let worker_id = WorkerId::now_v7(); let worker_id = WorkerId::now_v7();
+18 -3
View File
@@ -1530,7 +1530,9 @@ impl Runtime {
} }
} }
Ok(protocol::Event::Snapshot { Ok(protocol::Event::Snapshot {
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
greeting: protocol::Greeting { greeting: protocol::Greeting {
worker_name: worker_ref.worker_id.to_string(), worker_name: worker_ref.worker_id.to_string(),
cwd: String::new(), cwd: String::new(),
@@ -3152,7 +3154,9 @@ mod tests {
), ),
); );
let snapshot = protocol::Event::Snapshot { let snapshot = protocol::Event::Snapshot {
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
greeting: protocol::Greeting { greeting: protocol::Greeting {
worker_name: "parent".to_string(), worker_name: "parent".to_string(),
cwd: "/tmp".to_string(), cwd: "/tmp".to_string(),
@@ -4581,7 +4585,17 @@ mod tests {
backend.set_worker_snapshot( backend.set_worker_snapshot(
&detail.worker_ref, &detail.worker_ref,
protocol::Event::Snapshot { protocol::Event::Snapshot {
entries: vec![expected_entry.clone()], session: protocol::SessionSnapshot {
entries: vec![protocol::SessionSnapshotEntry {
entry_id: "restored-log-entry".to_owned(),
timestamp: 1,
provenance: protocol::SessionEntryProvenance::LegacyUnknown,
derived_from: Vec::new(),
data: protocol::SessionSnapshotEntryData::RunError {
message: expected_entry.to_string(),
},
}],
},
greeting: protocol::Greeting { greeting: protocol::Greeting {
worker_name: "live-worker".to_string(), worker_name: "live-worker".to_string(),
cwd: "/tmp/live".to_string(), cwd: "/tmp/live".to_string(),
@@ -4606,12 +4620,13 @@ mod tests {
.unwrap(); .unwrap();
match snapshot { match snapshot {
protocol::Event::Snapshot { protocol::Event::Snapshot {
entries, session,
greeting, greeting,
status, status,
.. ..
} => { } => {
assert_eq!(entries, vec![expected_entry]); assert_eq!(session.entries.len(), 1);
assert_eq!(session.entries[0].entry_id, "restored-log-entry");
assert_eq!(greeting.worker_name, "live-worker"); assert_eq!(greeting.worker_name, "live-worker");
assert_eq!(status, protocol::WorkerStatus::Running); assert_eq!(status, protocol::WorkerStatus::Running);
} }
+151 -46
View File
@@ -37,7 +37,7 @@ use crate::working_directory::{
WorkingDirectoryBinding, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer, WorkingDirectoryBinding, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use protocol::{Event, Method, Segment, WorkerStatus}; use protocol::{ErrorCode, Event, Method, Segment, WorkerStatus};
use session_store::{CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore}; use session_store::{CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore};
#[cfg(test)] #[cfg(test)]
use session_store::{FsStore, FsWorkerStore}; use session_store::{FsStore, FsWorkerStore};
@@ -46,6 +46,8 @@ use tokio::runtime::Runtime;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use workdir::{LocalWorkdirSession, Workdir, WorkdirSessionCapabilities, WorkdirSessionHandle}; use workdir::{LocalWorkdirSession, Workdir, WorkdirSessionCapabilities, WorkdirSessionHandle};
#[cfg(test)]
use worker::WorkerController;
use worker::feature::builtin::{ use worker::feature::builtin::{
CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationProvider, CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationProvider,
WorkerObservationSubject, WorkerObservationSubjectRef, WorkerSessionCapture, WorkerObservationSubject, WorkerObservationSubjectRef, WorkerSessionCapture,
@@ -54,9 +56,11 @@ use worker::feature::builtin::{
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session}; use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session};
use worker::{ use worker::{
PromptCatalogSource, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, PreparedWorker, PromptCatalogSource, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN,
WorkerController, WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout,
WorkerHandle, WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle,
WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
bash_output_dir_for_worker_id,
}; };
const DEFAULT_BACKEND_ID: &str = "worker-crate"; const DEFAULT_BACKEND_ID: &str = "worker-crate";
@@ -67,8 +71,7 @@ const USER_INPUT_COMMIT_TIMEOUT: Duration = Duration::from_secs(9);
fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool { fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
let extensions = match entry { let extensions = match entry {
LogEntry::UserInput { extensions, .. } LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
_ => return false, _ => return false,
}; };
extensions.iter().any(|extension| { extensions.iter().any(|extension| {
@@ -422,7 +425,7 @@ impl ProfileRuntimeWorkerFactory {
fn restore_fallback_manifest( fn restore_fallback_manifest(
worker_name: &str, worker_name: &str,
) -> Result<(manifest::WorkerManifest, PromptCatalogSource), String> { ) -> Result<(manifest::WorkerManifest, PromptCatalogSource), String> {
let mut config = manifest::WorkerManifestConfig::builtin_defaults(); let mut config = manifest::WorkerManifestConfig::resolution_defaults();
config.worker.name = Some(worker_name.to_string()); config.worker.name = Some(worker_name.to_string());
let manifest = manifest::WorkerManifest::try_from(config) let manifest = manifest::WorkerManifest::try_from(config)
.map_err(|err| format!("failed to build restore fallback manifest: {err}"))?; .map_err(|err| format!("failed to build restore fallback manifest: {err}"))?;
@@ -881,15 +884,33 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
)?; )?;
let store = CombinedStore::new(session_store, worker_metadata_store); let store = CombinedStore::new(session_store, worker_metadata_store);
let mut worker = Worker::from_manifest_with_context( let run_dir = worker_aggregate_dir
.join("runs")
.join(request.run_generation.to_string());
let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id);
let mut prepared = WorkerBootstrap::new(
manifest, manifest,
store, store,
loader, loader,
workspace_context, workspace_context,
filesystem_authority, filesystem_authority,
WorkerBootstrapLayout::RuntimeManagedRun {
run_dir: run_dir.clone(),
bash_output_dir,
},
self.controller_transport,
) )
.prepare()
.await .await
.map_err(|err| format!("failed to create Worker from profile: {err}"))?; .map_err(|error| match error {
WorkerBootstrapError::Worker(source) => {
format!("failed to create Worker from profile: {source}")
}
WorkerBootstrapError::Controller { source, .. } => {
format!("failed to prepare Worker controller: {source}")
}
})?;
let worker = prepared.worker_mut();
validate_worker_memory_settings(worker.manifest(), &request.request)?; validate_worker_memory_settings(worker.manifest(), &request.request)?;
if let Some(binding) = request.working_directory.as_ref() { if let Some(binding) = request.working_directory.as_ref() {
worker.bind_workdir_session(Some(runtime_local_workdir_session( worker.bind_workdir_session(Some(runtime_local_workdir_session(
@@ -935,21 +956,16 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
} }
let workspace_client = worker.workspace_client_handle(); let workspace_client = worker.workspace_client_handle();
let run_dir = worker_aggregate_dir let started = prepared.start().await.map_err(|error| match error {
.join("runs") WorkerBootstrapError::Worker(source) => {
.join(request.run_generation.to_string()); format!("failed to prepare Worker before controller start: {source}")
let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed_run_with_transport( }
worker, WorkerBootstrapError::Controller { source, .. } => format!(
&run_dir, "failed to spawn Worker controller in {}: {source}",
self.controller_transport,
)
.await
.map_err(|err| {
format!(
"failed to spawn Worker controller in {}: {err}",
run_dir.display() run_dir.display()
) ),
})?; })?;
let (handle, shutdown_rx) = (started.handle, started.shutdown);
if flow_transition_enabled { if flow_transition_enabled {
handle.shared_state.enable_flow_transition(); handle.shared_state.enable_flow_transition();
} }
@@ -1118,18 +1134,27 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
let run_dir = worker_aggregate_dir let run_dir = worker_aggregate_dir
.join("runs") .join("runs")
.join(request.run_generation.to_string()); .join(request.run_generation.to_string());
let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed_run_with_transport( let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id);
let started = PreparedWorker::new(
worker, worker,
&run_dir, WorkerBootstrapLayout::RuntimeManagedRun {
run_dir: run_dir.clone(),
bash_output_dir,
},
self.controller_transport, self.controller_transport,
) )
.start()
.await .await
.map_err(|err| { .map_err(|error| match error {
format!( WorkerBootstrapError::Worker(source) => {
"failed to spawn restored Worker controller in {}: {err}", format!("failed to prepare restored Worker: {source}")
}
WorkerBootstrapError::Controller { source, .. } => format!(
"failed to spawn restored Worker controller in {}: {source}",
run_dir.display() run_dir.display()
) ),
})?; })?;
let (handle, shutdown_rx) = (started.handle, started.shutdown);
if flow_transition_enabled { if flow_transition_enabled {
handle.shared_state.enable_flow_transition(); handle.shared_state.enable_flow_transition();
} }
@@ -1443,7 +1468,6 @@ where
let streams = subscribe_worker_protocol_session(&handle); let streams = subscribe_worker_protocol_session(&handle);
let mut events = streams.events; let mut events = streams.events;
let mut entry_events = streams.log_entries; let mut entry_events = streams.log_entries;
let bridge_handle = handle.clone();
let bridge_busy = busy.clone(); let bridge_busy = busy.clone();
if let Err(message) = self.spawn_on_adapter_runtime(async move { if let Err(message) = self.spawn_on_adapter_runtime(async move {
loop { loop {
@@ -1451,12 +1475,28 @@ where
event = events.recv() => { event = events.recv() => {
match event { match event {
Ok(event) => { Ok(event) => {
let next_busy = match &event {
Event::InvokeStart { .. }
| Event::Status {
status: WorkerStatus::Running,
} => Some(true),
Event::RunEnd { .. }
| Event::Error {
code: ErrorCode::NotPaused,
..
}
| Event::Status {
status:
WorkerStatus::Idle
| WorkerStatus::Paused
| WorkerStatus::Stopped,
}
| Event::Shutdown => Some(false),
_ => None,
};
let _ = bridge_context.publish_protocol_event(event); let _ = bridge_context.publish_protocol_event(event);
if matches!( if let Some(next_busy) = next_busy {
bridge_handle.shared_state.get_status(), bridge_busy.store(next_busy, Ordering::SeqCst);
WorkerStatus::Idle | WorkerStatus::Paused
) {
bridge_busy.store(false, Ordering::SeqCst);
} }
} }
Err(broadcast::error::RecvError::Lagged(_)) => continue, Err(broadcast::error::RecvError::Lagged(_)) => continue,
@@ -2517,8 +2557,12 @@ mod tests {
) )
.await .await
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
let (handle, shutdown_rx) = let bash_output_dir = self.runtime_base.join("bash-output");
WorkerController::spawn_runtime_managed(worker, &self.runtime_base) let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed(
worker,
&self.runtime_base,
&bash_output_dir,
)
.await .await
.map_err(|err| err.to_string())?; .map_err(|err| err.to_string())?;
Ok(RuntimeWorkerController { Ok(RuntimeWorkerController {
@@ -2566,18 +2610,22 @@ mod tests {
) { ) {
let deadline = std::time::Instant::now() + Duration::from_secs(5); let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop { loop {
let matches = { let observed = {
let workers = backend.workers.lock().unwrap(); let workers = backend.workers.lock().unwrap();
let execution = workers.get(worker_ref).expect("live Worker execution"); let execution = workers.get(worker_ref).expect("live Worker execution");
execution.handle.shared_state.get_status() == expected_status (
&& execution.busy.load(Ordering::SeqCst) == expected_busy execution.handle.shared_state.get_status(),
execution.busy.load(Ordering::SeqCst),
)
}; };
if matches { if observed == (expected_status, expected_busy) {
return; return;
} }
assert!( assert!(
std::time::Instant::now() < deadline, std::time::Instant::now() < deadline,
"timed out waiting for adapter state {expected_status:?}, busy={expected_busy}" "timed out waiting for adapter state {expected_status:?}, busy={expected_busy}; last observed status={:?}, busy={}",
observed.0,
observed.1,
); );
std::thread::sleep(Duration::from_millis(10)); std::thread::sleep(Duration::from_millis(10));
} }
@@ -3091,9 +3139,68 @@ mod tests {
assert!(!socket_path.exists()); assert!(!socket_path.exists());
} }
#[test]
fn profile_runtime_factory_uses_shared_worker_bootstrap_seams() {
let source = include_str!("worker_backend.rs");
let production = source
.split_once("#[cfg(test)]\nmod tests")
.map(|(production, _)| production)
.expect("worker backend test module marker");
let factory = production
.split_once("impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory")
.map(|(_, factory)| factory)
.expect("profile runtime factory implementation");
let (fresh, restore) = factory
.split_once("async fn restore_controller")
.expect("fresh and restore factory paths");
let assert_in_order = |path: &str, markers: &[&str]| {
let mut offset = 0;
for marker in markers {
let relative = path[offset..]
.find(marker)
.unwrap_or_else(|| panic!("missing ordered factory marker {marker}"));
offset += relative + marker.len();
}
};
assert_in_order(
fresh,
&[
"WorkerBootstrap::new(",
".prepare()",
"worker.bind_workdir_session(",
"worker.bind_worker_observation_provider(",
"install_runtime_flow_transition_feature()",
"prepared.start()",
],
);
assert_in_order(
restore,
&[
"Worker::restore_from_worker_metadata_with_context(",
"worker.bind_workdir_session(",
"worker.bind_worker_observation_provider(",
"install_runtime_flow_transition_feature()",
"PreparedWorker::new(",
".start()",
],
);
assert!(
production.contains("WorkerBootstrap::new("),
"fresh runtime Workers must use the shared construction bootstrap"
);
assert!(
production.contains("PreparedWorker::new("),
"restored runtime Workers must use the shared pre-exposure lifecycle"
);
assert!(
!production.contains("WorkerController::spawn_runtime_managed_run_with_transport"),
"runtime factory paths must not bypass the shared controller lifecycle"
);
}
#[test] #[test]
#[serial_test::serial(worker_allocation)] #[serial_test::serial(worker_allocation)]
fn in_process_runtime_reopens_persisted_worker_without_overlong_unix_socket() { fn shared_bootstrap_preserves_in_process_transport_for_fresh_and_restored_runtime_workers() {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
let long_component = "embedded-workspace-store-segment".repeat(4); let long_component = "embedded-workspace-store-segment".repeat(4);
let runtime_store_dir = root.path().join(long_component); let runtime_store_dir = root.path().join(long_component);
@@ -3244,8 +3351,7 @@ mod tests {
assert!(entries.iter().any(|entry| { assert!(entries.iter().any(|entry| {
matches!( matches!(
entry, entry,
LogEntry::UserInput { segments, .. } LogEntry::AnnotatedUserInput { segments, .. }
| LogEntry::AnnotatedUserInput { segments, .. }
if segments == &vec![Segment::text("start the ticket")] if segments == &vec![Segment::text("start the ticket")]
) )
})); }));
@@ -3253,8 +3359,7 @@ mod tests {
.iter() .iter()
.find_map(|entry| { .find_map(|entry| {
let extensions = match entry { let extensions = match entry {
LogEntry::UserInput { extensions, .. } LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
_ => return None, _ => return None,
}; };
extensions extensions
+174 -38
View File
@@ -9,8 +9,8 @@ use worker::{
use crate::auth::{ use crate::auth::{
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial, RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial,
RuntimeRequestSourceSigner, RuntimeWorkerMutationSourceSigner, WORKER_REMOVE_PERMISSION, RuntimeRequestSourceSigner, RuntimeWorkerMutationSourceSigner, WORKER_REMOVE_PERMISSION,
WORKSPACE_REQUEST_PERMISSION, WorkerMutationActorKind, WorkerMutationOperation, WORKSPACE_REQUEST_PERMISSION, WORKSPACE_WORKER_DISCOVERY_PERMISSION, WorkerMutationActorKind,
WorkerMutationSourceClaims, new_token_id, WorkerMutationOperation, WorkerMutationSourceClaims, new_token_id,
}; };
use crate::runtime::RuntimeWorkspaceScope; use crate::runtime::RuntimeWorkspaceScope;
use crate::worker_backend::WorkspacePromptProjectionCache; use crate::worker_backend::WorkspacePromptProjectionCache;
@@ -343,6 +343,51 @@ impl RuntimeOwnedWorkspaceClient {
self.request_timeout = request_timeout; self.request_timeout = request_timeout;
self self
} }
fn execute_with_permission(
&self,
request: WorkspaceRequest,
permission: &'static str,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let base_url = self.base_url.clone();
let workspace_id = self.workspace_id.clone();
let runtime_id = self.runtime_id.clone();
let worker_id = self.worker_id.clone();
let request_source_signer = self.request_source_signer.clone();
let request_source_audience = self.request_source_audience.clone();
let request_timeout = self.request_timeout;
if tokio::runtime::Handle::try_current().is_ok() {
std::thread::spawn(move || {
execute_runtime_owned_workspace_http(
&base_url,
&workspace_id,
&runtime_id,
&worker_id,
request_source_signer.as_ref(),
request_source_audience.as_deref(),
request_timeout,
permission,
request,
)
})
.join()
.map_err(|_| {
WorkspaceClientError::Request("workspace request thread panicked".to_string())
})?
} else {
execute_runtime_owned_workspace_http(
&self.base_url,
&self.workspace_id,
&self.runtime_id,
&self.worker_id,
self.request_source_signer.as_ref(),
self.request_source_audience.as_deref(),
self.request_timeout,
permission,
request,
)
}
}
} }
impl std::fmt::Debug for RuntimeOwnedWorkspaceClient { impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
@@ -377,42 +422,40 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
&self, &self,
request: WorkspaceRequest, request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> { ) -> Result<WorkspaceResponse, WorkspaceClientError> {
let base_url = self.base_url.clone(); self.execute_with_permission(request, WORKSPACE_REQUEST_PERMISSION)
let workspace_id = self.workspace_id.clone();
let runtime_id = self.runtime_id.clone();
let worker_id = self.worker_id.clone();
let request_source_signer = self.request_source_signer.clone();
let request_source_audience = self.request_source_audience.clone();
let request_timeout = self.request_timeout;
if tokio::runtime::Handle::try_current().is_ok() {
std::thread::spawn(move || {
execute_runtime_owned_workspace_http(
&base_url,
&workspace_id,
&runtime_id,
&worker_id,
request_source_signer.as_ref(),
request_source_audience.as_deref(),
request_timeout,
request,
)
})
.join()
.map_err(|_| {
WorkspaceClientError::Request("workspace request thread panicked".to_string())
})?
} else {
execute_runtime_owned_workspace_http(
&self.base_url,
&self.workspace_id,
&self.runtime_id,
&self.worker_id,
self.request_source_signer.as_ref(),
self.request_source_audience.as_deref(),
self.request_timeout,
request,
)
} }
fn list_workspace_workers(
&self,
request: worker::WorkspaceWorkerDiscoveryRequest,
) -> Result<workspace_api::WorkspaceWorkerDiscoveryPage, WorkspaceClientError> {
let mut path = format!(
"/api/w/{}/worker-discovery/workers?limit={}",
self.workspace_id, request.limit
);
if let Some(cursor) = request.cursor.as_deref() {
path.push_str("&cursor=");
path.push_str(&percent_encode_query(cursor));
}
if let Some(query) = request.query.as_deref() {
path.push_str("&query=");
path.push_str(&percent_encode_query(query));
}
let response = self.execute_with_permission(
WorkspaceRequest::get(path),
WORKSPACE_WORKER_DISCOVERY_PERMISSION,
)?;
if !(200..300).contains(&response.status) {
return Err(WorkspaceClientError::Request(format!(
"Workspace Worker discovery failed with HTTP {}: {}",
response.status, response.body
)));
}
serde_json::from_str(&response.body).map_err(|error| {
WorkspaceClientError::Request(format!(
"invalid Workspace Worker discovery response: {error}"
))
})
} }
fn current_prompt_projection( fn current_prompt_projection(
@@ -506,6 +549,19 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
} }
} }
fn percent_encode_query(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(char::from(byte));
} else {
use std::fmt::Write as _;
let _ = write!(encoded, "%{byte:02X}");
}
}
encoded
}
fn execute_runtime_owned_workspace_http( fn execute_runtime_owned_workspace_http(
base_url: &str, base_url: &str,
workspace_id: &str, workspace_id: &str,
@@ -514,6 +570,7 @@ fn execute_runtime_owned_workspace_http(
request_source_signer: Option<&RuntimeRequestSourceSigner>, request_source_signer: Option<&RuntimeRequestSourceSigner>,
request_source_audience: Option<&str>, request_source_audience: Option<&str>,
request_timeout: Option<Duration>, request_timeout: Option<Duration>,
permission: &'static str,
request: WorkspaceRequest, request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> { ) -> Result<WorkspaceResponse, WorkspaceClientError> {
if !request.path.starts_with('/') || request.path.starts_with("//") { if !request.path.starts_with('/') || request.path.starts_with("//") {
@@ -553,7 +610,7 @@ fn execute_runtime_owned_workspace_http(
audience, audience,
workspace_id, workspace_id,
Some(worker_id), Some(worker_id),
WORKSPACE_REQUEST_PERMISSION, permission,
method.as_str(), method.as_str(),
&request.path, &request.path,
body.as_bytes(), body.as_bytes(),
@@ -903,6 +960,85 @@ mod tests {
); );
} }
#[test]
fn workspace_worker_discovery_signs_dedicated_permission_and_encoded_query() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::Mutex;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let received = Arc::new(Mutex::new(String::new()));
let received_for_server = received.clone();
let body = serde_json::json!({
"workers": [{
"subject": {
"kind": "runtime_worker",
"runtime_id": "runtime-b",
"worker_id": "worker-b"
},
"resource_key": "W-2",
"display_name": "coder two",
"profile": "builtin:coder",
"status": "idle"
}],
"next_cursor": "v1:1"
})
.to_string();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut bytes = [0_u8; 4096];
let count = stream.read(&mut bytes).unwrap();
*received_for_server.lock().unwrap() =
String::from_utf8_lossy(&bytes[..count]).into_owned();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
)
.unwrap();
});
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let client = RuntimeOwnedWorkspaceClient::new(
"workspace-a",
format!("http://{address}"),
"runtime-a",
"worker-a",
)
.with_runtime_request_source(&identity, "server-a");
let page = client
.list_workspace_workers(worker::WorkspaceWorkerDiscoveryRequest {
cursor: Some("v1:0".to_string()),
limit: 1,
query: Some("coder two".to_string()),
})
.unwrap();
assert_eq!(page.workers[0].resource_key, "W-2");
server.join().unwrap();
let request = received.lock().unwrap().clone();
assert!(request.contains(
"GET /api/w/workspace-a/worker-discovery/workers?limit=1&cursor=v1%3A0&query=coder%20two "
));
let token = request
.lines()
.find_map(|line| {
line.split_once(':').and_then(|(name, value)| {
name.eq_ignore_ascii_case(RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
.then(|| value.trim())
})
})
.unwrap();
let claims = decode_runtime_request_source_claims(token).unwrap();
assert_eq!(claims.permission, WORKSPACE_WORKER_DISCOVERY_PERMISSION);
assert_eq!(
claims.path,
"/api/w/workspace-a/worker-discovery/workers?limit=1&cursor=v1%3A0&query=coder%20two"
);
}
#[test] #[test]
fn remote_authority_stamps_and_signs_worker_remove_without_caller_claim_choices() { fn remote_authority_stamps_and_signs_worker_remove_without_caller_claim_choices() {
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap(); let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
+1
View File
@@ -27,6 +27,7 @@ toml = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tools = { workspace = true } tools = { workspace = true }
workdir = { workspace = true } workdir = { workspace = true }
workspace-api = { workspace = true }
minijinja = "2.19.0" minijinja = "2.19.0"
chrono = "0.4" chrono = "0.4"
config-source = { path = "../config-source" } config-source = { path = "../config-source" }
+3 -1
View File
@@ -47,7 +47,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let worker = worker::Worker::from_manifest_toml(&toml, store).await?; let worker = worker::Worker::from_manifest_toml(&toml, store).await?;
let runtime_tmp = tempfile::tempdir()?; let runtime_tmp = tempfile::tempdir()?;
let (handle, _shutdown_rx) = WorkerController::spawn(worker, runtime_tmp.path()).await?; let bash_output_dir = runtime_tmp.path().join("bash-output");
let (handle, _shutdown_rx) =
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir).await?;
// Check initial status via shared state // Check initial status via shared state
println!("[shared_state] {}", handle.shared_state.status_json()); println!("[shared_state] {}", handle.shared_state.status_json());
+284
View File
@@ -0,0 +1,284 @@
use std::path::PathBuf;
use agen::llm_client::client::LlmClient;
use session_store::{Store, WorkerMetadataStore};
use thiserror::Error;
use workdir::WorkdirSessionHandle;
use crate::PromptCatalogSource;
use crate::controller::{
ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle,
};
use crate::worker::{Worker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext};
use manifest::WorkerManifest;
/// Filesystem layout used by a Worker controller started through the reusable
/// bootstrap boundary.
#[derive(Debug, Clone)]
pub enum WorkerBootstrapLayout {
/// A direct Worker rooted below the supplied runtime base directory.
Direct {
runtime_base: PathBuf,
bash_output_dir: PathBuf,
},
/// A runtime-managed Worker with an exact persisted run directory.
RuntimeManagedRun {
run_dir: PathBuf,
bash_output_dir: PathBuf,
},
}
/// Return the temporary Bash spill directory owned by a stable Worker identity.
///
/// The directory deliberately lives outside session/run-generation storage so a
/// restarted controller for the same Worker keeps the same readable artifact
/// boundary.
pub fn bash_output_dir_for_worker_id(worker_id: impl std::fmt::Display) -> PathBuf {
std::env::temp_dir()
.join("yoi")
.join("workers")
.join(worker_id.to_string())
.join("bash-output")
}
/// Construction and controller inputs that are stable for one Worker launch.
pub struct WorkerBootstrap<St> {
manifest: WorkerManifest,
store: St,
prompt_catalog: PromptCatalogSource,
workspace_context: WorkerWorkspaceContext,
filesystem_authority: WorkerFilesystemAuthority,
layout: WorkerBootstrapLayout,
transport: WorkerControllerTransport,
model_client: Option<Box<dyn LlmClient>>,
workdir_session: Option<WorkdirSessionHandle>,
}
/// A constructed Worker whose host-owned live bindings can still be installed
/// before Feature installation and controller exposure.
pub struct PreparedWorker<C: LlmClient, St: Store> {
worker: Worker<C, St>,
layout: WorkerBootstrapLayout,
transport: WorkerControllerTransport,
}
/// Live controller returned only after Worker construction and feature
/// installation have completed successfully.
pub struct BootstrappedWorker {
pub handle: WorkerHandle,
pub shutdown: ShutdownReceiver,
}
#[derive(Debug, Error)]
pub enum WorkerBootstrapError {
#[error("worker construction failed")]
Worker(#[source] WorkerError),
#[error("worker controller startup failed")]
Controller {
#[source]
source: std::io::Error,
cleanup_failed: bool,
},
}
impl<St> WorkerBootstrap<St>
where
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
#[allow(clippy::too_many_arguments)]
pub fn new(
manifest: WorkerManifest,
store: St,
prompt_catalog: PromptCatalogSource,
workspace_context: WorkerWorkspaceContext,
filesystem_authority: WorkerFilesystemAuthority,
layout: WorkerBootstrapLayout,
transport: WorkerControllerTransport,
) -> Self {
Self {
manifest,
store,
prompt_catalog,
workspace_context,
filesystem_authority,
layout,
transport,
model_client: None,
workdir_session: None,
}
}
/// Inject a process-owned model client. This is primarily useful for
/// embedded hosts and deterministic tests that must not start an external
/// model server.
pub fn with_model_client<C>(mut self, model_client: C) -> Self
where
C: LlmClient + 'static,
{
self.model_client = Some(Box::new(model_client));
self
}
/// Bind an already materialized Workdir session instead of asking the
/// Worker to derive one from filesystem authority.
pub fn with_workdir_session(mut self, workdir_session: WorkdirSessionHandle) -> Self {
self.workdir_session = Some(workdir_session);
self
}
/// Construct the Worker without exposing a controller handle. Runtime hosts
/// use this seam to bind Workdir, observation, Flow, and other live services
/// before [`PreparedWorker::start`] performs Feature installation.
pub async fn prepare(
self,
) -> Result<PreparedWorker<Box<dyn LlmClient>, St>, WorkerBootstrapError> {
let mut worker = Worker::from_manifest_with_context_and_model_client(
self.manifest,
self.store,
self.prompt_catalog,
self.workspace_context,
self.filesystem_authority,
self.model_client,
)
.await
.map_err(WorkerBootstrapError::Worker)?;
if let Some(workdir_session) = self.workdir_session {
worker.bind_workdir_session(Some(workdir_session));
}
Ok(PreparedWorker::new(worker, self.layout, self.transport))
}
pub async fn prepare_restored(
self,
worker_name: &str,
) -> Result<PreparedWorker<Box<dyn LlmClient>, St>, WorkerBootstrapError> {
let mut worker =
Worker::restore_pending_from_worker_metadata_with_context_and_model_client(
worker_name,
self.manifest,
self.store,
self.prompt_catalog,
self.workspace_context,
self.filesystem_authority,
self.model_client,
)
.await
.map_err(WorkerBootstrapError::Worker)?;
if let Some(workdir_session) = self.workdir_session {
worker.bind_workdir_session(Some(workdir_session));
}
Ok(PreparedWorker::new(worker, self.layout, self.transport))
}
pub async fn start(self) -> Result<BootstrappedWorker, WorkerBootstrapError> {
self.prepare().await?.start().await
}
}
impl<C, St> PreparedWorker<C, St>
where
C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
/// Wrap a restored Worker in the same pre-exposure lifecycle used by fresh
/// bootstraps.
pub fn new(
worker: Worker<C, St>,
layout: WorkerBootstrapLayout,
transport: WorkerControllerTransport,
) -> Self {
Self {
worker,
layout,
transport,
}
}
pub fn worker(&self) -> &Worker<C, St> {
&self.worker
}
pub fn worker_mut(&mut self) -> &mut Worker<C, St> {
&mut self.worker
}
pub async fn start(self) -> Result<BootstrappedWorker, WorkerBootstrapError> {
start_worker_controller(self.worker, self.layout, self.transport).await
}
}
/// Start the shared direct/runtime-managed controller lifecycle for an already
/// constructed Worker. Restore paths use this after replaying durable state;
/// fresh hosts normally use [`WorkerBootstrap::start`].
pub async fn start_worker_controller<C, St>(
worker: Worker<C, St>,
layout: WorkerBootstrapLayout,
transport: WorkerControllerTransport,
) -> Result<BootstrappedWorker, WorkerBootstrapError>
where
C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
let cleanup_session = worker.workdir_session().cloned();
let controller = match layout {
WorkerBootstrapLayout::Direct {
runtime_base,
bash_output_dir,
} => {
WorkerController::spawn_with_transport(
worker,
&runtime_base,
&bash_output_dir,
transport,
)
.await
}
WorkerBootstrapLayout::RuntimeManagedRun {
run_dir,
bash_output_dir,
} => {
WorkerController::spawn_runtime_managed_run_with_transport(
worker,
&run_dir,
&bash_output_dir,
transport,
)
.await
}
};
match controller {
Ok((handle, shutdown)) => Ok(BootstrappedWorker { handle, shutdown }),
Err(source) => {
let cleanup_failed = match cleanup_session {
Some(session) => session.close().await.is_err(),
None => false,
};
Err(WorkerBootstrapError::Controller {
source,
cleanup_failed,
})
}
}
}
#[cfg(test)]
mod tests {
use super::bash_output_dir_for_worker_id;
#[test]
fn bash_output_directory_is_stable_per_worker_below_system_temp() {
let path = bash_output_dir_for_worker_id("019c1234-worker");
assert_eq!(
path,
std::env::temp_dir()
.join("yoi")
.join("workers")
.join("019c1234-worker")
.join("bash-output")
);
}
}
+196 -128
View File
@@ -45,12 +45,12 @@ use workdir::{
#[derive(Clone)] #[derive(Clone)]
pub struct WorkerHandle { pub struct WorkerHandle {
method_tx: mpsc::Sender<Method>, method_tx: mpsc::Sender<Method>,
event_tx: broadcast::Sender<Event>, working_event_tx: broadcast::Sender<Event>,
pub shared_state: Arc<WorkerSharedState>, pub shared_state: Arc<WorkerSharedState>,
pub runtime_dir: Arc<RuntimeDir>, pub runtime_dir: Arc<RuntimeDir>,
pub alerter: Alerter, pub alerter: Alerter,
pub in_flight: InFlightEvents, pub in_flight: InFlightEvents,
/// Segment-log mirror + broadcast handle. The IPC server snapshots /// Segment-log mirror + session-entry channel. The IPC server snapshots
/// it on every new connection (Event::Snapshot) and forwards /// it on every new connection (Event::Snapshot) and forwards
/// subsequent commits (Event::Entry) on the receiver. /// subsequent commits (Event::Entry) on the receiver.
pub sink: SegmentLogSink, pub sink: SegmentLogSink,
@@ -63,7 +63,7 @@ impl WorkerHandle {
} }
pub fn subscribe(&self) -> broadcast::Receiver<Event> { pub fn subscribe(&self) -> broadcast::Receiver<Event> {
self.event_tx.subscribe() self.working_event_tx.subscribe()
} }
pub fn committed_entries(&self) -> Vec<LogEntry> { pub fn committed_entries(&self) -> Vec<LogEntry> {
@@ -84,10 +84,7 @@ impl WorkerHandle {
(entries, entry_rx, in_flight) (entries, entry_rx, in_flight)
}; };
let event = Event::Snapshot { let event = Event::Snapshot {
entries: entries session: session_store::public_snapshot::project_current_session_snapshot(&entries),
.into_iter()
.map(|entry| serde_json::to_value(entry).expect("log entry serializes"))
.collect(),
greeting: self.shared_state.greeting.clone(), greeting: self.shared_state.greeting.clone(),
status: self.shared_state.get_status(), status: self.shared_state.get_status(),
in_flight, in_flight,
@@ -120,7 +117,7 @@ impl WorkerHandle {
/// Broadcast an event to all listeners (including socket clients). /// Broadcast an event to all listeners (including socket clients).
pub fn send_event(&self, event: Event) -> Result<usize, broadcast::error::SendError<Event>> { pub fn send_event(&self, event: Event) -> Result<usize, broadcast::error::SendError<Event>> {
self.event_tx.send(event) self.working_event_tx.send(event)
} }
/// Emit a user-facing alert. Thin wrapper over `Alerter::alert`. /// Emit a user-facing alert. Thin wrapper over `Alerter::alert`.
@@ -132,19 +129,19 @@ impl WorkerHandle {
async fn set_controller_status( async fn set_controller_status(
shared_state: &Arc<WorkerSharedState>, shared_state: &Arc<WorkerSharedState>,
runtime_dir: &RuntimeDir, runtime_dir: &RuntimeDir,
event_tx: &broadcast::Sender<Event>, working_event_tx: &broadcast::Sender<Event>,
status: WorkerStatus, status: WorkerStatus,
) { ) {
shared_state.set_status(status); shared_state.set_status(status);
let _ = runtime_dir.write_status(shared_state).await; let _ = runtime_dir.write_status(shared_state).await;
let _ = event_tx.send(Event::Status { status }); let _ = working_event_tx.send(Event::Status { status });
} }
async fn finish_controller_run<C, St>( async fn finish_controller_run<C, St>(
worker: &mut Worker<C, St>, worker: &mut Worker<C, St>,
shared_state: &Arc<WorkerSharedState>, shared_state: &Arc<WorkerSharedState>,
runtime_dir: &RuntimeDir, runtime_dir: &RuntimeDir,
event_tx: &broadcast::Sender<Event>, working_event_tx: &broadcast::Sender<Event>,
new_status: WorkerStatus, new_status: WorkerStatus,
) where ) where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
@@ -160,7 +157,7 @@ async fn finish_controller_run<C, St>(
// the terminal run boundary so reconnect snapshots cannot append stale // the terminal run boundary so reconnect snapshots cannot append stale
// partial text/tool arguments after newer entries. // partial text/tool arguments after newer entries.
worker.clear_in_flight_events(); worker.clear_in_flight_events();
set_controller_status(shared_state, runtime_dir, event_tx, new_status).await; set_controller_status(shared_state, runtime_dir, working_event_tx, new_status).await;
worker.spawn_post_run_memory_jobs(); worker.spawn_post_run_memory_jobs();
} }
@@ -225,6 +222,7 @@ impl WorkerController {
pub async fn spawn<C, St>( pub async fn spawn<C, St>(
worker: Worker<C, St>, worker: Worker<C, St>,
runtime_base: &Path, runtime_base: &Path,
bash_output_dir: &Path,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
@@ -233,6 +231,7 @@ impl WorkerController {
Self::spawn_inner( Self::spawn_inner(
worker, worker,
runtime_base, runtime_base,
bash_output_dir,
false, false,
None, None,
WorkerControllerTransport::UnixSocket, WorkerControllerTransport::UnixSocket,
@@ -240,14 +239,13 @@ impl WorkerController {
.await .await
} }
/// Spawn a Worker owned by `worker-runtime`. /// Spawn a direct Worker while letting an in-process host select the
/// /// controller transport explicitly.
/// The controller still uses an ephemeral directory for Unix sockets and pub async fn spawn_with_transport<C, St>(
/// tool spill artifacts, but does not write legacy pid/status/manifest
/// liveness projections.
pub async fn spawn_runtime_managed<C, St>(
worker: Worker<C, St>, worker: Worker<C, St>,
runtime_base: &Path, runtime_base: &Path,
bash_output_dir: &Path,
transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
@@ -256,6 +254,33 @@ impl WorkerController {
Self::spawn_inner( Self::spawn_inner(
worker, worker,
runtime_base, runtime_base,
bash_output_dir,
false,
None,
transport,
)
.await
}
/// Spawn a Worker owned by `worker-runtime`.
///
/// The controller uses an ephemeral directory for Unix sockets while tool
/// spill artifacts use the separately supplied Worker-owned temporary path.
/// Runtime-managed Workers do not write legacy pid/status/manifest liveness
/// projections.
pub async fn spawn_runtime_managed<C, St>(
worker: Worker<C, St>,
runtime_base: &Path,
bash_output_dir: &Path,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where
C: LlmClient + Clone + 'static,
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
{
Self::spawn_inner(
worker,
runtime_base,
bash_output_dir,
true, true,
None, None,
WorkerControllerTransport::UnixSocket, WorkerControllerTransport::UnixSocket,
@@ -267,6 +292,7 @@ impl WorkerController {
pub async fn spawn_runtime_managed_run<C, St>( pub async fn spawn_runtime_managed_run<C, St>(
worker: Worker<C, St>, worker: Worker<C, St>,
run_dir: &Path, run_dir: &Path,
bash_output_dir: &Path,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where where
C: LlmClient + Clone + 'static, C: LlmClient + Clone + 'static,
@@ -275,6 +301,7 @@ impl WorkerController {
Self::spawn_runtime_managed_run_with_transport( Self::spawn_runtime_managed_run_with_transport(
worker, worker,
run_dir, run_dir,
bash_output_dir,
WorkerControllerTransport::UnixSocket, WorkerControllerTransport::UnixSocket,
) )
.await .await
@@ -285,6 +312,7 @@ impl WorkerController {
pub async fn spawn_runtime_managed_run_with_transport<C, St>( pub async fn spawn_runtime_managed_run_with_transport<C, St>(
worker: Worker<C, St>, worker: Worker<C, St>,
run_dir: &Path, run_dir: &Path,
bash_output_dir: &Path,
transport: WorkerControllerTransport, transport: WorkerControllerTransport,
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
where where
@@ -294,12 +322,21 @@ impl WorkerController {
let parent = run_dir let parent = run_dir
.parent() .parent()
.ok_or_else(|| std::io::Error::other("run path has no parent"))?; .ok_or_else(|| std::io::Error::other("run path has no parent"))?;
Self::spawn_inner(worker, parent, true, Some(run_dir), transport).await Self::spawn_inner(
worker,
parent,
bash_output_dir,
true,
Some(run_dir),
transport,
)
.await
} }
async fn spawn_inner<C, St>( async fn spawn_inner<C, St>(
worker: Worker<C, St>, worker: Worker<C, St>,
runtime_base: &Path, runtime_base: &Path,
bash_output_dir: &Path,
runtime_managed: bool, runtime_managed: bool,
runtime_run: Option<&Path>, runtime_run: Option<&Path>,
transport: WorkerControllerTransport, transport: WorkerControllerTransport,
@@ -312,6 +349,7 @@ impl WorkerController {
let result = Self::spawn_initialized( let result = Self::spawn_initialized(
worker, worker,
runtime_base, runtime_base,
bash_output_dir,
runtime_managed, runtime_managed,
runtime_run, runtime_run,
transport, transport,
@@ -329,6 +367,7 @@ impl WorkerController {
async fn spawn_initialized<C, St>( async fn spawn_initialized<C, St>(
mut worker: Worker<C, St>, mut worker: Worker<C, St>,
runtime_base: &Path, runtime_base: &Path,
bash_output_dir: &Path,
runtime_managed: bool, runtime_managed: bool,
runtime_run: Option<&Path>, runtime_run: Option<&Path>,
transport: WorkerControllerTransport, transport: WorkerControllerTransport,
@@ -342,9 +381,9 @@ impl WorkerController {
// bash-output scope) === // bash-output scope) ===
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let (method_tx, method_rx) = mpsc::channel::<Method>(32); let (method_tx, method_rx) = mpsc::channel::<Method>(32);
let (event_tx, _) = broadcast::channel::<Event>(256); let (working_event_tx, _) = broadcast::channel::<Event>(256);
let alerter = Alerter::new(event_tx.clone()); let alerter = Alerter::new(working_event_tx.clone());
let in_flight = InFlightEvents::new(event_tx.clone()); let in_flight = InFlightEvents::new(working_event_tx.clone());
worker.attach_in_flight_events(in_flight.clone()); worker.attach_in_flight_events(in_flight.clone());
// Runtime directory is created before tool registration because it owns // Runtime directory is created before tool registration because it owns
@@ -384,13 +423,13 @@ impl WorkerController {
// Also hand the raw broadcast sender so Worker-internal operations // Also hand the raw broadcast sender so Worker-internal operations
// can emit typed lifecycle `Event`s (currently: compact progress). // can emit typed lifecycle `Event`s (currently: compact progress).
worker.attach_internal_worker_registry(spawned_registry.clone()); worker.attach_internal_worker_registry(spawned_registry.clone());
worker.attach_event_tx(event_tx.clone()); worker.attach_working_event_tx(working_event_tx.clone());
// Bash spills long outputs to a per-worker subdir under the runtime // Bash spill artifacts are owned by the stable Worker identity rather
// dir. Push a recursive `allow(Read)` for that path into the // than a controller session/run generation. Push a recursive
// Worker's runtime scope so the agent can `Read` saved files // `allow(Read)` for the exact tool output path into the Worker's shared
// without polluting the workspace. // runtime scope so the Workdir session and system prompt stay aligned.
let bash_output_dir = runtime_dir.path().join("bash-output"); let bash_output_dir = bash_output_dir.to_path_buf();
std::fs::create_dir_all(&bash_output_dir).map_err(|e| { std::fs::create_dir_all(&bash_output_dir).map_err(|e| {
std::io::Error::other(format!( std::io::Error::other(format!(
"create bash output dir {}: {e}", "create bash output dir {}: {e}",
@@ -419,7 +458,7 @@ impl WorkerController {
worker.wire_history_persistence(); worker.wire_history_persistence();
// === 2. Engine event bridge wiring === // === 2. Engine event bridge wiring ===
wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight); wire_event_bridges_on_engine(&mut worker, &working_event_tx, &alerter, &in_flight);
// === 3. Tool registration (builtin / memory / spawn-orchestration) === // === 3. Tool registration (builtin / memory / spawn-orchestration) ===
let fs_for_view = register_worker_tools( let fs_for_view = register_worker_tools(
@@ -466,7 +505,7 @@ impl WorkerController {
let handle = WorkerHandle { let handle = WorkerHandle {
method_tx, method_tx,
event_tx: event_tx.clone(), working_event_tx: working_event_tx.clone(),
shared_state: shared_state.clone(), shared_state: shared_state.clone(),
runtime_dir: runtime_dir.clone(), runtime_dir: runtime_dir.clone(),
alerter: alerter.clone(), alerter: alerter.clone(),
@@ -491,7 +530,7 @@ impl WorkerController {
tokio::spawn(controller_loop( tokio::spawn(controller_loop(
worker, worker,
method_rx, method_rx,
event_tx, working_event_tx,
shared_state, shared_state,
runtime_dir, runtime_dir,
cancel_tx, cancel_tx,
@@ -629,16 +668,16 @@ fn protocol_command_status(status: WorkdirCommandStatus) -> ProtocolCommandStatu
} }
/// Wire the per-event broadcast bridges on the Worker's Engine. Each callback /// Wire the per-event broadcast bridges on the Worker's Engine. Each callback
/// re-publishes a worker-level signal as a `protocol::Event` on `event_tx` /// re-publishes a worker-level signal as a `protocol::Event` on `working_event_tx`
/// so subscribers (TUI, socket clients) get a single typed stream. /// so subscribers (TUI, socket clients) get a single typed stream.
/// ///
/// `Worker::wire_history_persistence` is called separately to wire the /// `Worker::wire_history_persistence` is called separately to wire the
/// per-item history commit callback so every assistant / tool item /// per-item history commit callback so every assistant / tool item
/// landing in `worker.history` becomes a singular `LogEntry::AssistantItem` /// landing in `worker.history` becomes a singular `LogEntry::AnnotatedAssistantItem`
/// / `ToolResult` commit through the sync writer. /// / `AnnotatedToolResult` commit through the sync writer.
pub(crate) fn wire_event_bridges_on_engine<C, St>( pub(crate) fn wire_event_bridges_on_engine<C, St>(
worker: &mut Worker<C, St>, worker: &mut Worker<C, St>,
event_tx: &broadcast::Sender<Event>, working_event_tx: &broadcast::Sender<Event>,
alerter: &Alerter, alerter: &Alerter,
in_flight: &InFlightEvents, in_flight: &InFlightEvents,
) where ) where
@@ -648,12 +687,12 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
let ai_activity = worker.ai_activity_counter(); let ai_activity = worker.ai_activity_counter();
let worker = worker.engine_mut(); let worker = worker.engine_mut();
let tx = event_tx.clone(); let tx = working_event_tx.clone();
worker.on_turn_start(move |turn| { worker.on_turn_start(move |turn| {
let _ = tx.send(Event::TurnStart { turn }); let _ = tx.send(Event::TurnStart { turn });
}); });
let tx = event_tx.clone(); let tx = working_event_tx.clone();
worker.on_turn_end(move |turn| { worker.on_turn_end(move |turn| {
let _ = tx.send(Event::TurnEnd { let _ = tx.send(Event::TurnEnd {
turn, turn,
@@ -661,17 +700,17 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
}); });
}); });
let tx = event_tx.clone(); let tx = working_event_tx.clone();
worker.on_llm_call_start(move |llm_call| { worker.on_llm_call_start(move |llm_call| {
let _ = tx.send(Event::LlmCallStart { llm_call }); let _ = tx.send(Event::LlmCallStart { llm_call });
}); });
let tx = event_tx.clone(); let tx = working_event_tx.clone();
worker.on_llm_call_end(move |llm_call| { worker.on_llm_call_end(move |llm_call| {
let _ = tx.send(Event::LlmCallEnd { llm_call }); let _ = tx.send(Event::LlmCallEnd { llm_call });
}); });
let tx = event_tx.clone(); let tx = working_event_tx.clone();
worker.on_llm_retry(move |llm_call, notice| { worker.on_llm_retry(move |llm_call, notice| {
let _ = tx.send(Event::LlmRetry { let _ = tx.send(Event::LlmRetry {
llm_call, llm_call,
@@ -684,7 +723,7 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
}); });
}); });
let tx = event_tx.clone(); let tx = working_event_tx.clone();
worker.on_llm_continuation(move |llm_call, attempt, max_attempts, reason| { worker.on_llm_continuation(move |llm_call, attempt, max_attempts, reason| {
let _ = tx.send(Event::LlmContinuation { let _ = tx.send(Event::LlmContinuation {
llm_call, llm_call,
@@ -757,7 +796,7 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
}); });
}); });
let tx = event_tx.clone(); let tx = working_event_tx.clone();
let activity = ai_activity.clone(); let activity = ai_activity.clone();
worker.on_tool_result(move |result| { worker.on_tool_result(move |result| {
activity.fetch_add(1, Ordering::SeqCst); activity.fetch_add(1, Ordering::SeqCst);
@@ -782,7 +821,7 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
}); });
}); });
let tx = event_tx.clone(); let tx = working_event_tx.clone();
worker.on_usage(move |event| { worker.on_usage(move |event| {
let _ = tx.send(Event::Usage { let _ = tx.send(Event::Usage {
input_tokens: event.input_tokens, input_tokens: event.input_tokens,
@@ -791,7 +830,7 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
}); });
}); });
let tx = event_tx.clone(); let tx = working_event_tx.clone();
worker.on_error(move |event| { worker.on_error(move |event| {
let _ = tx.send(Event::Error { let _ = tx.send(Event::Error {
code: ErrorCode::ProviderError, code: ErrorCode::ProviderError,
@@ -859,6 +898,20 @@ where
crate::spawn::tool::ParentNotificationTarget::Buffer(worker.notify_buffer_handle()) crate::spawn::tool::ParentNotificationTarget::Buffer(worker.notify_buffer_handle())
}); });
let prompts = worker.prompts().clone(); let prompts = worker.prompts().clone();
let paste_store = worker.store().clone();
let paste_session_id = worker.session_id();
worker
.engine_mut()
.register_tool(crate::paste_artifact_tool::search_input_artifact_tool(
paste_store.clone(),
paste_session_id,
));
worker
.engine_mut()
.register_tool(crate::paste_artifact_tool::read_input_artifact_tool(
paste_store,
paste_session_id,
));
// Resolve the existing WorkerWorkdir binding into the domain provider. // Resolve the existing WorkerWorkdir binding into the domain provider.
// Tools only consume the provider handle; they do not own its root, cwd, // Tools only consume the provider handle; they do not own its root, cwd,
// scope, or lifecycle. No-workdir Workers expose no local tools. // scope, or lifecycle. No-workdir Workers expose no local tools.
@@ -869,7 +922,7 @@ where
.register_tools(tools::core_builtin_tools( .register_tools(tools::core_builtin_tools(
workdir.clone(), workdir.clone(),
tracker.clone(), tracker.clone(),
bash_output_dir, bash_output_dir.clone(),
)); ));
if feature_config.image.enabled && model_supports_image_attachments(&spawner_manifest.model) if feature_config.image.enabled && model_supports_image_attachments(&spawner_manifest.model)
{ {
@@ -887,8 +940,10 @@ where
.register_tools(tools::web_builtin_tools(web_config)); .register_tools(tools::web_builtin_tools(web_config));
} }
let worker_enabled = feature_config.worker.enabled;
let sub_worker_enabled = feature_config.sub_worker.enabled;
let mut feature_registry = FeatureRegistryBuilder::new(); let mut feature_registry = FeatureRegistryBuilder::new();
if feature_config.sub_worker.enabled { if sub_worker_enabled && !worker_enabled {
feature_registry.add_module( feature_registry.add_module(
crate::feature::builtin::manage_worker::sub_worker_control_feature( crate::feature::builtin::manage_worker::sub_worker_control_feature(
worker.workspace_client_handle(), worker.workspace_client_handle(),
@@ -959,6 +1014,23 @@ where
crate::feature::builtin::manage_workdir::manage_workdir_feature(workspace_client), crate::feature::builtin::manage_workdir::manage_workdir_feature(workspace_client),
); );
} }
if feature_config.workspace_worker_discovery.enabled {
let workspace_client = worker.workspace_client_handle();
let has_workspace_identity = workspace_client.workspace_id().is_some_and(|workspace_id| {
!workspace_id.is_empty() && !workspace_id.chars().any(char::is_control)
});
if !workspace_client.is_available() || !has_workspace_identity {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Workspace Worker discovery requires Backend Workspace API authority",
));
}
feature_registry.add_module(
crate::feature::builtin::workspace_worker_discovery::workspace_worker_discovery_feature(
workspace_client,
),
);
}
if feature_config.worker.enabled { if feature_config.worker.enabled {
let workspace_client = worker.workspace_client_handle(); let workspace_client = worker.workspace_client_handle();
let has_workspace_identity = workspace_client.workspace_id().is_some_and(|workspace_id| { let has_workspace_identity = workspace_client.workspace_id().is_some_and(|workspace_id| {
@@ -973,7 +1045,7 @@ where
feature_registry.add_module( feature_registry.add_module(
crate::feature::builtin::manage_worker::manage_worker_feature( crate::feature::builtin::manage_worker::manage_worker_feature(
workspace_client, workspace_client,
Some(spawned_registry.clone()), sub_worker_enabled.then(|| spawned_registry.clone()),
feature_config.worker.direct_spawn, feature_config.worker.direct_spawn,
), ),
); );
@@ -1073,6 +1145,7 @@ where
spawner_workspace_context, spawner_workspace_context,
parent_notifications, parent_notifications,
runtime_base.clone(), runtime_base.clone(),
bash_output_dir.clone(),
spawner_workspace_root, spawner_workspace_root,
source_workdir_session, source_workdir_session,
spawned_registry.clone(), spawned_registry.clone(),
@@ -1126,7 +1199,7 @@ where
async fn controller_loop<C, St>( async fn controller_loop<C, St>(
mut worker: Worker<C, St>, mut worker: Worker<C, St>,
mut method_rx: mpsc::Receiver<Method>, mut method_rx: mpsc::Receiver<Method>,
event_tx: broadcast::Sender<Event>, working_event_tx: broadcast::Sender<Event>,
shared_state: Arc<WorkerSharedState>, shared_state: Arc<WorkerSharedState>,
runtime_dir: Arc<RuntimeDir>, runtime_dir: Arc<RuntimeDir>,
cancel_tx: mpsc::Sender<()>, cancel_tx: mpsc::Sender<()>,
@@ -1183,7 +1256,7 @@ async fn controller_loop<C, St>(
set_controller_status( set_controller_status(
&shared_state, &shared_state,
&runtime_dir, &runtime_dir,
&event_tx, &working_event_tx,
WorkerStatus::Running, WorkerStatus::Running,
) )
.await; .await;
@@ -1200,7 +1273,7 @@ async fn controller_loop<C, St>(
}, },
), ),
&mut method_rx, &mut method_rx,
&event_tx, &working_event_tx,
&cancel_tx, &cancel_tx,
&pause_tx, &pause_tx,
&shared_state, &shared_state,
@@ -1225,7 +1298,7 @@ async fn controller_loop<C, St>(
}, },
), ),
&mut method_rx, &mut method_rx,
&event_tx, &working_event_tx,
&cancel_tx, &cancel_tx,
&pause_tx, &pause_tx,
&shared_state, &shared_state,
@@ -1243,7 +1316,7 @@ async fn controller_loop<C, St>(
drive_turn( drive_turn(
worker.run_for_notification(kind), worker.run_for_notification(kind),
&mut method_rx, &mut method_rx,
&event_tx, &working_event_tx,
&cancel_tx, &cancel_tx,
&pause_tx, &pause_tx,
&shared_state, &shared_state,
@@ -1261,7 +1334,7 @@ async fn controller_loop<C, St>(
drive_turn( drive_turn(
worker.resume(), worker.resume(),
&mut method_rx, &mut method_rx,
&event_tx, &working_event_tx,
&cancel_tx, &cancel_tx,
&pause_tx, &pause_tx,
&shared_state, &shared_state,
@@ -1285,16 +1358,16 @@ async fn controller_loop<C, St>(
&mut worker, &mut worker,
&shared_state, &shared_state,
&runtime_dir, &runtime_dir,
&event_tx, &working_event_tx,
new_status, new_status,
) )
.await; .await;
if shutdown { if shutdown {
let _ = event_tx.send(Event::Shutdown); let _ = working_event_tx.send(Event::Shutdown);
break; break;
} }
if take_shutdown_request_after_status(&shutdown_after_idle, new_status) { if take_shutdown_request_after_status(&shutdown_after_idle, new_status) {
let _ = event_tx.send(Event::Shutdown); let _ = working_event_tx.send(Event::Shutdown);
break; break;
} }
continue; continue;
@@ -1312,7 +1385,7 @@ async fn controller_loop<C, St>(
// already rejects `Run` while a turn is live, so // already rejects `Run` while a turn is live, so
// this branch is only reachable across a race window // this branch is only reachable across a race window
// around status flips. // around status flips.
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning, code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn".into(), message: "Worker is already executing a turn".into(),
}); });
@@ -1320,7 +1393,7 @@ async fn controller_loop<C, St>(
} }
// Stage the run without a speculative user-message echo. // Stage the run without a speculative user-message echo.
// `Worker::run` validates the input, commits // `Worker::run` validates the input, commits
// `LogEntry::UserInput`, and the session-log sink turns that // `LogEntry::AnnotatedUserInput`, and the session-log sink turns that
// committed entry into the live `Event::UserMessage`. That // committed entry into the live `Event::UserMessage`. That
// keeps every client ordered against `SegmentStart` replay and // keeps every client ordered against `SegmentStart` replay and
// makes persisted history the single source of visible user // makes persisted history the single source of visible user
@@ -1346,7 +1419,7 @@ async fn controller_loop<C, St>(
Method::Notify { message, auto_run } => { Method::Notify { message, auto_run } => {
// Client-side live echo is delivered as `Event::SystemItem` // Client-side live echo is delivered as `Event::SystemItem`
// once the interceptor commits the corresponding // once the interceptor commits the corresponding
// `LogEntry::SystemItem` entry — drained out of the // `LogEntry::AnnotatedSystemItem` entry — drained out of the
// notify buffer + broadcast through the sink. No // notify buffer + broadcast through the sink. No
// separate echo here. // separate echo here.
worker.push_notify(message, auto_run); worker.push_notify(message, auto_run);
@@ -1363,7 +1436,7 @@ async fn controller_loop<C, St>(
Method::Resume => { Method::Resume => {
if shared_state.get_status() != WorkerStatus::Paused { if shared_state.get_status() != WorkerStatus::Paused {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::NotPaused, code: ErrorCode::NotPaused,
message: "Worker is not paused".into(), message: "Worker is not paused".into(),
}); });
@@ -1379,20 +1452,20 @@ async fn controller_loop<C, St>(
set_controller_status( set_controller_status(
&shared_state, &shared_state,
&runtime_dir, &runtime_dir,
&event_tx, &working_event_tx,
WorkerStatus::Idle, WorkerStatus::Idle,
) )
.await; .await;
} }
Err(error) => { Err(error) => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: worker_error_code(&error), code: worker_error_code(&error),
message: error.to_string(), message: error.to_string(),
}); });
} }
}, },
WorkerStatus::Idle | WorkerStatus::Stopped => { WorkerStatus::Idle | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::NotRunning, code: ErrorCode::NotRunning,
message: "Worker is not running".into(), message: "Worker is not running".into(),
}); });
@@ -1409,7 +1482,7 @@ async fn controller_loop<C, St>(
// Worker is Idle (Running turns go through `drive_turn`, // Worker is Idle (Running turns go through `drive_turn`,
// not this outer match), so there is nothing to pause. // not this outer match), so there is nothing to pause.
if shared_state.get_status() != WorkerStatus::Paused { if shared_state.get_status() != WorkerStatus::Paused {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::NotRunning, code: ErrorCode::NotRunning,
message: "Worker is not running".into(), message: "Worker is not running".into(),
}); });
@@ -1419,21 +1492,21 @@ async fn controller_loop<C, St>(
Method::Compact => match shared_state.get_status() { Method::Compact => match shared_state.get_status() {
WorkerStatus::Idle => { WorkerStatus::Idle => {
if let Err(error) = worker.manual_compact().await { if let Err(error) = worker.manual_compact().await {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: worker_error_code(&error), code: worker_error_code(&error),
message: error.to_string(), message: error.to_string(),
}); });
} }
} }
WorkerStatus::Paused => { WorkerStatus::Paused => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest, code: ErrorCode::InvalidRequest,
message: "Cannot compact while the Worker is paused; resume or start a fresh turn first" message: "Cannot compact while the Worker is paused; resume or start a fresh turn first"
.into(), .into(),
}); });
} }
WorkerStatus::Running | WorkerStatus::Stopped => { WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning, code: ErrorCode::AlreadyRunning,
message: message:
"Worker is already executing a turn; compact can only run while idle" "Worker is already executing a turn; compact can only run while idle"
@@ -1444,10 +1517,10 @@ async fn controller_loop<C, St>(
Method::ListRewindTargets => match shared_state.get_status() { Method::ListRewindTargets => match shared_state.get_status() {
WorkerStatus::Idle | WorkerStatus::Paused => { WorkerStatus::Idle | WorkerStatus::Paused => {
emit_rewind_targets(&worker, &event_tx) emit_rewind_targets(&worker, &working_event_tx)
} }
WorkerStatus::Running | WorkerStatus::Stopped => { WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning, code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind can only run while idle or paused" message: "Worker is already executing a turn; rewind can only run while idle or paused"
.into(), .into(),
@@ -1460,23 +1533,28 @@ async fn controller_loop<C, St>(
expected_head_entries, expected_head_entries,
} => match shared_state.get_status() { } => match shared_state.get_status() {
WorkerStatus::Idle => { WorkerStatus::Idle => {
if apply_rewind(&mut worker, &event_tx, target, expected_head_entries) { if apply_rewind(
&mut worker,
&working_event_tx,
target,
expected_head_entries,
) {
worker.clear_in_flight_events(); worker.clear_in_flight_events();
shared_state.set_status(WorkerStatus::Idle); shared_state.set_status(WorkerStatus::Idle);
let _ = event_tx.send(Event::Status { let _ = working_event_tx.send(Event::Status {
status: WorkerStatus::Idle, status: WorkerStatus::Idle,
}); });
} }
} }
WorkerStatus::Paused => { WorkerStatus::Paused => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest, code: ErrorCode::InvalidRequest,
message: "Cannot apply rewind while the Worker is paused; resume or wait for idle first" message: "Cannot apply rewind while the Worker is paused; resume or wait for idle first"
.into(), .into(),
}); });
} }
WorkerStatus::Running | WorkerStatus::Stopped => { WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning, code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind can only run while idle or paused" message: "Worker is already executing a turn; rewind can only run while idle or paused"
.into(), .into(),
@@ -1485,24 +1563,24 @@ async fn controller_loop<C, St>(
}, },
Method::Shutdown => { Method::Shutdown => {
let _ = event_tx.send(Event::Shutdown); let _ = working_event_tx.send(Event::Shutdown);
break; break;
} }
Method::ListWorkers => match discovery.list_visible().await { Method::ListWorkers => match discovery.list_visible().await {
Ok(workers) => match serde_json::to_value(workers) { Ok(workers) => match serde_json::to_value(workers) {
Ok(workers) => { Ok(workers) => {
let _ = event_tx.send(Event::WorkersListed { workers }); let _ = working_event_tx.send(Event::WorkersListed { workers });
} }
Err(error) => { Err(error) => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::Internal, code: ErrorCode::Internal,
message: format!("serialize visible workers: {error}"), message: format!("serialize visible workers: {error}"),
}); });
} }
}, },
Err(error) => { Err(error) => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest, code: ErrorCode::InvalidRequest,
message: error.to_string(), message: error.to_string(),
}); });
@@ -1512,17 +1590,17 @@ async fn controller_loop<C, St>(
Method::RestoreWorker { name } => match discovery.restore(&name).await { Method::RestoreWorker { name } => match discovery.restore(&name).await {
Ok(result) => match serde_json::to_value(result) { Ok(result) => match serde_json::to_value(result) {
Ok(result) => { Ok(result) => {
let _ = event_tx.send(Event::WorkerRestored { result }); let _ = working_event_tx.send(Event::WorkerRestored { result });
} }
Err(error) => { Err(error) => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::Internal, code: ErrorCode::Internal,
message: format!("serialize worker restore result: {error}"), message: format!("serialize worker restore result: {error}"),
}); });
} }
}, },
Err(error) => { Err(error) => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest, code: ErrorCode::InvalidRequest,
message: error.to_string(), message: error.to_string(),
}); });
@@ -1532,17 +1610,17 @@ async fn controller_loop<C, St>(
Method::RegisterPeer { name } => match discovery.register_peer(&name) { Method::RegisterPeer { name } => match discovery.register_peer(&name) {
Ok(result) => match serde_json::to_value(result) { Ok(result) => match serde_json::to_value(result) {
Ok(result) => { Ok(result) => {
let _ = event_tx.send(Event::PeerRegistered { result }); let _ = working_event_tx.send(Event::PeerRegistered { result });
} }
Err(error) => { Err(error) => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::Internal, code: ErrorCode::Internal,
message: format!("serialize peer registration result: {error}"), message: format!("serialize peer registration result: {error}"),
}); });
} }
}, },
Err(error) => { Err(error) => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest, code: ErrorCode::InvalidRequest,
message: error.to_string(), message: error.to_string(),
}); });
@@ -1661,7 +1739,7 @@ async fn handle_inbound_worker_event(
async fn drive_turn<F>( async fn drive_turn<F>(
worker_future: F, worker_future: F,
method_rx: &mut mpsc::Receiver<Method>, method_rx: &mut mpsc::Receiver<Method>,
event_tx: &broadcast::Sender<Event>, working_event_tx: &broadcast::Sender<Event>,
cancel_tx: &mpsc::Sender<()>, cancel_tx: &mpsc::Sender<()>,
pause_tx: &mpsc::Sender<()>, pause_tx: &mpsc::Sender<()>,
shared_state: &Arc<WorkerSharedState>, shared_state: &Arc<WorkerSharedState>,
@@ -1697,7 +1775,7 @@ where
set_controller_status( set_controller_status(
shared_state, shared_state,
runtime_dir, runtime_dir,
event_tx, working_event_tx,
WorkerStatus::Running, WorkerStatus::Running,
) )
.await; .await;
@@ -1715,11 +1793,11 @@ where
WorkerRunResult::LimitReached => (WorkerStatus::Idle, RunResult::LimitReached), WorkerRunResult::LimitReached => (WorkerStatus::Idle, RunResult::LimitReached),
WorkerRunResult::RolledBack => (WorkerStatus::Idle, RunResult::RolledBack), WorkerRunResult::RolledBack => (WorkerStatus::Idle, RunResult::RolledBack),
WorkerRunResult::Interrupted { .. } if pause_requested => { WorkerRunResult::Interrupted { .. } if pause_requested => {
let _ = event_tx.send(Event::RunEnd { result: RunResult::Paused }); let _ = working_event_tx.send(Event::RunEnd { result: RunResult::Paused });
return (WorkerStatus::Paused, shutdown_requested); return (WorkerStatus::Paused, shutdown_requested);
} }
WorkerRunResult::Interrupted { code, message } => { WorkerRunResult::Interrupted { code, message } => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code, code,
message: message.clone(), message: message.clone(),
}); });
@@ -1735,7 +1813,7 @@ where
return (WorkerStatus::Idle, shutdown_requested); return (WorkerStatus::Idle, shutdown_requested);
} }
}; };
let _ = event_tx.send(Event::RunEnd { result: run_result }); let _ = working_event_tx.send(Event::RunEnd { result: run_result });
if parent_originated && matches!(run_result, RunResult::Finished) { if parent_originated && matches!(run_result, RunResult::Finished) {
crate::ipc::event::fire_and_forget( crate::ipc::event::fire_and_forget(
parent_socket.cloned(), parent_socket.cloned(),
@@ -1752,13 +1830,13 @@ where
// intentionally skip `WorkerEvent::Errored` upward: // intentionally skip `WorkerEvent::Errored` upward:
// that channel is reserved for worker runtime // that channel is reserved for worker runtime
// failures, not deliberate interruptions. // failures, not deliberate interruptions.
let _ = event_tx.send(Event::RunEnd { result: RunResult::Paused }); let _ = working_event_tx.send(Event::RunEnd { result: RunResult::Paused });
(WorkerStatus::Paused, shutdown_requested) (WorkerStatus::Paused, shutdown_requested)
} }
Err(e) => { Err(e) => {
let code = worker_error_code(&e); let code = worker_error_code(&e);
let message = e.to_string(); let message = e.to_string();
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code, code,
message: message.clone(), message: message.clone(),
}); });
@@ -1789,13 +1867,13 @@ where
let _ = cancel_tx.try_send(()); let _ = cancel_tx.try_send(());
} }
Some(Method::Run { .. } | Method::RunTracked { .. } | Method::Resume) => { Some(Method::Run { .. } | Method::RunTracked { .. } | Method::Resume) => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning, code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn".into(), message: "Worker is already executing a turn".into(),
}); });
} }
Some(Method::Compact | Method::ListRewindTargets | Method::RewindTo { .. }) => { Some(Method::Compact | Method::ListRewindTargets | Method::RewindTo { .. }) => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning, code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind/compact can only run while idle or paused" message: "Worker is already executing a turn; rewind/compact can only run while idle or paused"
.into(), .into(),
@@ -1809,7 +1887,7 @@ where
} }
Some(Method::ListCompletions { .. }) => {} Some(Method::ListCompletions { .. }) => {}
Some(Method::ListWorkers | Method::RestoreWorker { .. } | Method::RegisterPeer { .. }) => { Some(Method::ListWorkers | Method::RestoreWorker { .. } | Method::RegisterPeer { .. }) => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning, code: ErrorCode::AlreadyRunning,
message: "Worker discovery/control requests are only handled while the Worker is idle or paused" message: "Worker discovery/control requests are only handled while the Worker is idle or paused"
.into(), .into(),
@@ -1842,20 +1920,20 @@ where
} }
} }
fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, event_tx: &broadcast::Sender<Event>) fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, working_event_tx: &broadcast::Sender<Event>)
where where
C: LlmClient + 'static, C: LlmClient + 'static,
St: Store, St: Store,
{ {
match worker.list_rewind_targets() { match worker.list_rewind_targets() {
Ok((head_entries, targets)) => { Ok((head_entries, targets)) => {
let _ = event_tx.send(Event::RewindTargets { let _ = working_event_tx.send(Event::RewindTargets {
head_entries, head_entries,
targets, targets,
}); });
} }
Err(err) => { Err(err) => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::Internal, code: ErrorCode::Internal,
message: err.to_string(), message: err.to_string(),
}); });
@@ -1865,7 +1943,7 @@ where
fn apply_rewind<C, St>( fn apply_rewind<C, St>(
worker: &mut Worker<C, St>, worker: &mut Worker<C, St>,
event_tx: &broadcast::Sender<Event>, working_event_tx: &broadcast::Sender<Event>,
target: RewindTargetId, target: RewindTargetId,
expected_head_entries: usize, expected_head_entries: usize,
) -> bool ) -> bool
@@ -1874,30 +1952,18 @@ where
St: Store, St: Store,
{ {
match worker.rewind_to(target, expected_head_entries) { match worker.rewind_to(target, expected_head_entries) {
Ok(applied) => match applied Ok(applied) => {
.entries let session =
.into_iter() session_store::public_snapshot::project_current_session_snapshot(&applied.entries);
.map(serde_json::to_value) let _ = working_event_tx.send(Event::RewindApplied {
.collect::<Result<Vec<_>, _>>() session,
{
Ok(entries) => {
let _ = event_tx.send(Event::RewindApplied {
entries,
input: applied.input, input: applied.input,
summary: applied.summary, summary: applied.summary,
}); });
true true
} }
Err(error) => {
let _ = event_tx.send(Event::Error {
code: ErrorCode::Internal,
message: format!("failed to encode rewind snapshot: {error}"),
});
false
}
},
Err(err) => { Err(err) => {
let _ = event_tx.send(Event::Error { let _ = working_event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest, code: ErrorCode::InvalidRequest,
message: err.to_string(), message: err.to_string(),
}); });
@@ -2031,7 +2097,7 @@ mod tests {
// would observe channel-closed and confuse the select! arm. // would observe channel-closed and confuse the select! arm.
_method_tx: mpsc::Sender<Method>, _method_tx: mpsc::Sender<Method>,
method_rx: mpsc::Receiver<Method>, method_rx: mpsc::Receiver<Method>,
event_tx: broadcast::Sender<Event>, working_event_tx: broadcast::Sender<Event>,
cancel_tx: mpsc::Sender<()>, cancel_tx: mpsc::Sender<()>,
_cancel_rx: mpsc::Receiver<()>, _cancel_rx: mpsc::Receiver<()>,
pause_tx: mpsc::Sender<()>, pause_tx: mpsc::Sender<()>,
@@ -2052,7 +2118,7 @@ mod tests {
.expect("runtime dir create"), .expect("runtime dir create"),
); );
let (method_tx, method_rx) = mpsc::channel::<Method>(16); let (method_tx, method_rx) = mpsc::channel::<Method>(16);
let (event_tx, _) = broadcast::channel::<Event>(16); let (working_event_tx, _) = broadcast::channel::<Event>(16);
let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1); let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1);
let (pause_tx, pause_rx) = mpsc::channel::<()>(1); let (pause_tx, pause_rx) = mpsc::channel::<()>(1);
let shared_state = Arc::new(WorkerSharedState::new( let shared_state = Arc::new(WorkerSharedState::new(
@@ -2077,7 +2143,7 @@ mod tests {
DriveTurnEnv { DriveTurnEnv {
_method_tx: method_tx, _method_tx: method_tx,
method_rx, method_rx,
event_tx, working_event_tx,
cancel_tx, cancel_tx,
_cancel_rx: cancel_rx, _cancel_rx: cancel_rx,
pause_tx, pause_tx,
@@ -2101,7 +2167,9 @@ mod tests {
let mut writer = JsonLineWriter::new(w); let mut writer = JsonLineWriter::new(w);
writer writer
.write(&Event::Snapshot { .write(&Event::Snapshot {
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
greeting: protocol::Greeting { greeting: protocol::Greeting {
worker_name: "parent".into(), worker_name: "parent".into(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
@@ -2137,7 +2205,7 @@ mod tests {
let (status, shutdown) = drive_turn( let (status, shutdown) = drive_turn(
worker_future, worker_future,
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.working_event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx, &env.pause_tx,
&env.shared_state, &env.shared_state,
@@ -2180,7 +2248,7 @@ mod tests {
let (status, shutdown) = drive_turn( let (status, shutdown) = drive_turn(
worker_future, worker_future,
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.working_event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx, &env.pause_tx,
&env.shared_state, &env.shared_state,
@@ -2210,7 +2278,7 @@ mod tests {
let (status, _) = drive_turn( let (status, _) = drive_turn(
worker_future, worker_future,
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.working_event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx, &env.pause_tx,
&env.shared_state, &env.shared_state,
@@ -2248,7 +2316,7 @@ mod tests {
let (status, _) = drive_turn( let (status, _) = drive_turn(
worker_future, worker_future,
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.working_event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx, &env.pause_tx,
&env.shared_state, &env.shared_state,
@@ -2292,7 +2360,7 @@ mod tests {
let (status, _) = drive_turn( let (status, _) = drive_turn(
worker_future, worker_future,
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.working_event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx, &env.pause_tx,
&env.shared_state, &env.shared_state,
@@ -2334,7 +2402,7 @@ mod tests {
let (status, shutdown) = drive_turn( let (status, shutdown) = drive_turn(
worker_future, worker_future,
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.working_event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx, &env.pause_tx,
&env.shared_state, &env.shared_state,
@@ -2373,7 +2441,7 @@ mod tests {
let (status, shutdown) = drive_turn( let (status, shutdown) = drive_turn(
worker_future, worker_future,
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.working_event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx, &env.pause_tx,
&env.shared_state, &env.shared_state,
@@ -2410,7 +2478,7 @@ mod tests {
let (status, shutdown) = drive_turn( let (status, shutdown) = drive_turn(
worker_future, worker_future,
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.working_event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx, &env.pause_tx,
&env.shared_state, &env.shared_state,
@@ -2433,7 +2501,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn compact_method_is_rejected_while_running() { async fn compact_method_is_rejected_while_running() {
let mut env = make_env().await; let mut env = make_env().await;
let mut events = env.event_tx.subscribe(); let mut events = env.working_event_tx.subscribe();
env._method_tx env._method_tx
.send(Method::Compact) .send(Method::Compact)
.await .await
@@ -2446,7 +2514,7 @@ mod tests {
let (status, shutdown) = drive_turn( let (status, shutdown) = drive_turn(
worker_future, worker_future,
&mut env.method_rx, &mut env.method_rx,
&env.event_tx, &env.working_event_tx,
&env.cancel_tx, &env.cancel_tx,
&env.pause_tx, &env.pause_tx,
&env.shared_state, &env.shared_state,
+13 -1
View File
@@ -14,9 +14,9 @@ use std::process::Stdio;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use crate::runtime_command::WorkerRuntimeCommand;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait; use async_trait::async_trait;
use client::WorkerRuntimeCommand;
use manifest::{Permission, ScopeRule}; use manifest::{Permission, ScopeRule};
use protocol::stream::JsonLineReader; use protocol::stream::JsonLineReader;
use protocol::{Event, Method, WorkerStatus}; use protocol::{Event, Method, WorkerStatus};
@@ -1481,7 +1481,9 @@ mod tests {
let mut writer = JsonLineWriter::new(stream); let mut writer = JsonLineWriter::new(stream);
writer writer
.write(&Event::Snapshot { .write(&Event::Snapshot {
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
greeting: protocol::Greeting { greeting: protocol::Greeting {
worker_name: "target".into(), worker_name: "target".into(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
@@ -1514,7 +1516,9 @@ mod tests {
.unwrap(); .unwrap();
writer writer
.write(&Event::Snapshot { .write(&Event::Snapshot {
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
greeting: protocol::Greeting { greeting: protocol::Greeting {
worker_name: "target".into(), worker_name: "target".into(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
@@ -1603,7 +1607,9 @@ mod tests {
let mut writer = JsonLineWriter::new(stream); let mut writer = JsonLineWriter::new(stream);
writer writer
.write(&Event::Snapshot { .write(&Event::Snapshot {
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
greeting: protocol::Greeting { greeting: protocol::Greeting {
worker_name: "target".into(), worker_name: "target".into(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
@@ -1627,7 +1633,9 @@ mod tests {
let mut writer = JsonLineWriter::new(writer_half); let mut writer = JsonLineWriter::new(writer_half);
writer writer
.write(&Event::Snapshot { .write(&Event::Snapshot {
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
greeting: protocol::Greeting { greeting: protocol::Greeting {
worker_name: "target".into(), worker_name: "target".into(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
@@ -1729,7 +1737,9 @@ mod tests {
.unwrap(); .unwrap();
writer writer
.write(&Event::Snapshot { .write(&Event::Snapshot {
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
greeting: protocol::Greeting { greeting: protocol::Greeting {
worker_name: "alerted".into(), worker_name: "alerted".into(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
@@ -1779,7 +1789,9 @@ mod tests {
let mut writer = JsonLineWriter::new(stream); let mut writer = JsonLineWriter::new(stream);
let _ = writer let _ = writer
.write(&Event::Snapshot { .write(&Event::Snapshot {
session: protocol::SessionSnapshot {
entries: Vec::new(), entries: Vec::new(),
},
greeting: protocol::Greeting { greeting: protocol::Greeting {
worker_name: "child-live".into(), worker_name: "child-live".into(),
cwd: "/tmp".into(), cwd: "/tmp".into(),
+19 -7
View File
@@ -3,8 +3,8 @@ use std::path::{Path, PathBuf};
use std::process::ExitCode; use std::process::ExitCode;
use crate::{ use crate::{
PromptCatalogSource, Worker, WorkerController, WorkerFilesystemAuthority, PromptCatalogSource, Worker, WorkerBootstrapLayout, WorkerControllerTransport,
WorkerWorkspaceContext, WorkerFilesystemAuthority, WorkerWorkspaceContext, start_worker_controller,
}; };
use clap::{CommandFactory, FromArgMatches, Parser}; use clap::{CommandFactory, FromArgMatches, Parser};
use manifest::{Permission, ScopeConfig, ScopeRule, WorkerManifest, WorkerManifestConfig, paths}; use manifest::{Permission, ScopeConfig, ScopeRule, WorkerManifest, WorkerManifestConfig, paths};
@@ -184,7 +184,8 @@ fn load_spawn_config_json(
) -> Result<(WorkerManifest, PromptCatalogSource), String> { ) -> Result<(WorkerManifest, PromptCatalogSource), String> {
let config = serde_json::from_str::<WorkerManifestConfig>(config_json) let config = serde_json::from_str::<WorkerManifestConfig>(config_json)
.map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?; .map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?;
let manifest = WorkerManifest::try_from(WorkerManifestConfig::builtin_defaults().merge(config)) let manifest =
WorkerManifest::try_from(WorkerManifestConfig::resolution_defaults().merge(config))
.map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?; .map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?;
Ok((manifest, PromptCatalogSource::builtins_only())) Ok((manifest, PromptCatalogSource::builtins_only()))
} }
@@ -192,7 +193,7 @@ fn load_spawn_config_json(
fn load_builtin_default_manifest( fn load_builtin_default_manifest(
worker_name: &str, worker_name: &str,
) -> Result<(WorkerManifest, PromptCatalogSource), String> { ) -> Result<(WorkerManifest, PromptCatalogSource), String> {
let mut config = WorkerManifestConfig::builtin_defaults(); let mut config = WorkerManifestConfig::resolution_defaults();
config.worker.name = Some(worker_name.to_string()); config.worker.name = Some(worker_name.to_string());
let manifest = WorkerManifest::try_from(config) let manifest = WorkerManifest::try_from(config)
.map_err(|e| format!("failed to resolve builtin worker defaults: {e}"))?; .map_err(|e| format!("failed to resolve builtin worker defaults: {e}"))?;
@@ -259,7 +260,7 @@ fn load_single_manifest(
absolute_path.display() absolute_path.display()
) )
})?; })?;
let mut config = WorkerManifestConfig::builtin_defaults().merge( let mut config = WorkerManifestConfig::resolution_defaults().merge(
WorkerManifestConfig::from_toml(&toml) WorkerManifestConfig::from_toml(&toml)
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))? .map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?
.resolve_paths(base_dir), .resolve_paths(base_dir),
@@ -633,13 +634,24 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
return ExitCode::FAILURE; return ExitCode::FAILURE;
} }
}; };
let (handle, shutdown_rx) = match WorkerController::spawn(worker, &runtime_base).await { let bash_output_dir = crate::bash_output_dir_for_worker_id(&worker_name);
Ok(pair) => pair, let started = match start_worker_controller(
worker,
WorkerBootstrapLayout::Direct {
runtime_base: runtime_base.clone(),
bash_output_dir,
},
WorkerControllerTransport::UnixSocket,
)
.await
{
Ok(started) => started,
Err(e) => { Err(e) => {
eprintln!("error: failed to start worker controller: {e}"); eprintln!("error: failed to start worker controller: {e}");
return ExitCode::FAILURE; return ExitCode::FAILURE;
} }
}; };
let (handle, shutdown_rx) = (started.handle, started.shutdown);
let socket_path = handle.runtime_dir.socket_path(); let socket_path = handle.runtime_dir.socket_path();
// Machine-readable ready line for parents that spawned this Worker // Machine-readable ready line for parents that spawned this Worker
+1
View File
@@ -17,6 +17,7 @@ pub mod session_explore;
pub mod task; pub mod task;
pub mod ticket; pub mod ticket;
pub mod worker_observation; pub mod worker_observation;
pub mod workspace_worker_discovery;
pub(crate) use memory_extract::{MemoryExtractFeature, MemoryExtractState, render_extract_input}; pub(crate) use memory_extract::{MemoryExtractFeature, MemoryExtractState, render_extract_input};
pub(crate) use session_explore::{SessionExploreFeature, SessionExploreState}; pub(crate) use session_explore::{SessionExploreFeature, SessionExploreState};
@@ -963,9 +963,12 @@ permission = "read"
.append( .append(
session_id, session_id,
segment_id, segment_id,
&LogEntry::UserInput { &LogEntry::AnnotatedUserInput {
ts: 1, ts: 1,
extensions: vec![], extensions: vec![],
history: vec![crate::session_history::test_logged_history_entry(
agen::Item::user_message("verify current Flow conditions"),
)],
segments: vec![Segment::Text { segments: vec![Segment::Text {
content: "verify current Flow conditions".into(), content: "verify current Flow conditions".into(),
}], }],
@@ -12,11 +12,7 @@ use async_trait::async_trait;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult}; use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult};
use workdir::workspace::{ use workdir::workspace::{WorkspaceWorkdirSessionFence, WorkspaceWorkdirSessionOperationRequest};
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
WorkingDirectoryListResponse as WorkdirListResponse, WorkspaceWorkdirSessionFence,
WorkspaceWorkdirSessionOperationRequest,
};
use workdir::{ use workdir::{
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
@@ -24,6 +20,13 @@ use workdir::{
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult, WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
}; };
use workspace_api::{
WorkingDirectoryCreateRequest as WorkdirCreateRequest,
WorkingDirectoryCreateResponse as WorkdirCreateResponse,
WorkingDirectoryDetailResponse as WorkdirDetailResponse,
WorkingDirectoryListResponse as WorkdirListResponse,
};
use crate::feature::{ use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution, FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration, ToolDeclaration,
@@ -195,11 +198,16 @@ impl WorkspaceAttachedWorkdirSession {
.execute(request) .execute(request)
.map_err(workspace_workdir_error)?; .map_err(workspace_workdir_error)?;
if !response.is_success() { if !response.is_success() {
return Err(WorkdirError::Transport(format!( return Err(
"Workspace Workdir API returned HTTP {}: {}", serde_json::from_str::<workdir::http::WorkdirTransportError>(&response.body)
response.status, .map(workdir::http::WorkdirTransportError::into_workdir_error)
bounded_error_body(&response.body) .unwrap_or_else(|_| {
))); WorkdirError::Transport(format!(
"Workspace Workdir operation failed with HTTP {}",
response.status
))
}),
);
} }
serde_json::from_str(&response.body).map_err(|error| { serde_json::from_str(&response.body).map_err(|error| {
WorkdirError::Transport(format!( WorkdirError::Transport(format!(
@@ -334,7 +342,13 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
} }
} }
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> { async fn start_command(
&self,
mut request: CommandRequest,
) -> Result<CommandHandle, WorkdirError> {
// `spill_dir` belongs to the Worker host running BashTool. This remote
// WorkdirSession provider cannot safely resolve or write that host path.
request.spill_dir = None;
match self.operate(WorkdirSessionOperation::CommandStart(request))? { match self.operate(WorkdirSessionOperation::CommandStart(request))? {
WorkdirSessionOperationResult::CommandStart(result) => Ok(result), WorkdirSessionOperationResult::CommandStart(result) => Ok(result),
_ => Err(Self::mismatch("command_start")), _ => Err(Self::mismatch("command_start")),
@@ -415,9 +429,9 @@ impl WorkspaceHttpWorkdirBackend {
runtime_id: runtime_id.map(str::to_string), runtime_id: runtime_id.map(str::to_string),
repository_id: repository_id.to_string(), repository_id: repository_id.to_string(),
selector, selector,
operation_id, operation_id: Some(operation_id),
}; };
let response = self.execute_json::<WorkdirDetailResponse>(WorkspaceRequest::json( let response = self.execute_json::<WorkdirCreateResponse>(WorkspaceRequest::json(
WorkspaceRequestMethod::Post, WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/working-directories"), format!("/api/w/{workspace_id}/working-directories"),
serde_json::to_string(&request).map_err(decode_error)?, serde_json::to_string(&request).map_err(decode_error)?,
@@ -696,16 +710,6 @@ struct WorkdirCreateInput {
selector: Option<String>, selector: Option<String>,
} }
#[derive(Debug, Serialize)]
struct WorkdirCreateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
runtime_id: Option<String>,
repository_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
selector: Option<String>,
operation_id: String,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct WorkdirAttachInput { struct WorkdirAttachInput {
@@ -795,6 +799,21 @@ mod tests {
} }
} }
fn error_response(
status: u16,
code: workdir::http::WorkdirTransportErrorCode,
message: &str,
) -> WorkspaceResponse {
WorkspaceResponse {
status,
body: serde_json::to_string(&workdir::http::WorkdirTransportError {
code,
message: message.to_string(),
})
.unwrap(),
}
}
fn workdir_json(id: &str) -> serde_json::Value { fn workdir_json(id: &str) -> serde_json::Value {
json!({ json!({
"working_directory_id": id, "working_directory_id": id,
@@ -1098,6 +1117,42 @@ mod tests {
assert!(body.get("session_id").is_none()); assert!(body.get("session_id").is_none());
} }
#[tokio::test]
async fn attached_session_does_not_send_worker_local_bash_spill_path() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
"operation": "command_start",
"result": "command-1"
}))]));
let session = WorkspaceAttachedWorkdirSession::handle(client.clone());
let handle = session
.start_command(CommandRequest {
command: "true".to_string(),
timeout_secs: 120,
output_limit: 1024,
spill_dir: Some("/worker-local/bash-output".into()),
tool_call_id: Some("call-1".to_string()),
})
.await
.unwrap();
assert_eq!(handle, CommandHandle("command-1".to_string()));
let requests = client.requests();
let body: serde_json::Value =
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(body["operation"]["operation"], "command_start");
assert_eq!(body["operation"]["request"]["command"], "true");
assert!(body["operation"]["request"]["spill_dir"].is_null());
assert_eq!(body["operation"]["request"]["tool_call_id"], "call-1");
assert!(
!requests[0]
.body
.as_deref()
.unwrap()
.contains("/worker-local/bash-output")
);
}
#[tokio::test] #[tokio::test]
async fn delegated_attached_session_carries_captured_fence_on_operations() { async fn delegated_attached_session_carries_captured_fence_on_operations() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![ let client = Arc::new(RecordingWorkspaceClient::new(vec![
@@ -1175,6 +1230,48 @@ mod tests {
assert_eq!(validation["delegations"].as_array().unwrap().len(), 1); assert_eq!(validation["delegations"].as_array().unwrap().len(), 1);
} }
#[tokio::test]
async fn attached_session_preserves_typed_provider_validation_error() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![error_response(
400,
workdir::http::WorkdirTransportErrorCode::InvalidRequest,
"Workdir operation request is invalid",
)]));
let session = WorkspaceAttachedWorkdirSession::handle(client);
let error = session
.glob(workdir::GlobRequest {
pattern: "[".to_string(),
path: workdir::WorkdirPath::root(),
limit: 10,
})
.await
.unwrap_err();
assert!(matches!(error, WorkdirError::InvalidArgument(_)));
}
#[tokio::test]
async fn attached_session_does_not_expose_untyped_workspace_error_body() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![WorkspaceResponse {
status: 502,
body: "secret token and /host/private/path".to_string(),
}]));
let session = WorkspaceAttachedWorkdirSession::handle(client);
let error = session
.stat(StatRequest {
path: workdir::WorkdirPath::root(),
})
.await
.unwrap_err();
let message = error.to_string();
assert!(matches!(error, WorkdirError::Transport(_)));
assert!(!message.contains("secret token"));
assert!(!message.contains("/host/private/path"));
assert!(message.contains("HTTP 502"));
}
#[tokio::test] #[tokio::test]
async fn nested_attached_session_preserves_full_delegation_chain() { async fn nested_attached_session_preserves_full_delegation_chain() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![ let client = Arc::new(RecordingWorkspaceClient::new(vec![
File diff suppressed because it is too large Load Diff
@@ -185,12 +185,9 @@ impl Tool for StageMemoryCandidateTool {
})?); })?);
} }
if matches!(params.kind, CandidateKind::Preference) if matches!(params.kind, CandidateKind::Preference)
&& entries.iter().any(|entry| { && entries
!matches!( .iter()
entry.origin, .any(|entry| !matches!(entry.origin, protocol::SessionEntryProvenance::HumanInput))
crate::WorkerHistoryProvenance::HumanInput { .. }
)
})
{ {
return Err(ToolError::InvalidArgument( return Err(ToolError::InvalidArgument(
"preference candidates require exclusively HumanInput evidence; model, Worker, Flow, backend, derived, and legacy-unknown origins are not preference authority" "preference candidates require exclusively HumanInput evidence; model, Worker, Flow, backend, derived, and legacy-unknown origins are not preference authority"
@@ -324,10 +321,23 @@ fn evidence_kind(entry: &SessionEntryEvidence) -> EvidenceKind {
} }
} }
fn evidence_origin(origin: &crate::WorkerHistoryProvenance) -> EvidenceOrigin { fn evidence_origin(origin: &protocol::SessionEntryProvenance) -> EvidenceOrigin {
use crate::WorkerHistoryProvenance as Origin; use protocol::SessionEntryProvenance as Origin;
let mut evidence = EvidenceOrigin { let kind = match origin {
kind: EvidenceOriginKind::LegacyUnknown, Origin::HumanInput => EvidenceOriginKind::HumanInput,
Origin::WorkerInput => EvidenceOriginKind::WorkerInput,
Origin::FlowInstruction => EvidenceOriginKind::FlowInstruction,
Origin::BackendInstruction => EvidenceOriginKind::BackendInstruction,
Origin::ModelOutput => EvidenceOriginKind::ModelOutput,
Origin::ToolOutput => EvidenceOriginKind::ToolOutput,
Origin::DerivedSummary => EvidenceOriginKind::DerivedSummary,
Origin::LegacyUnknown => EvidenceOriginKind::LegacyUnknown,
};
EvidenceOrigin {
kind,
// The public SessionSnapshot intentionally excludes account, Worker,
// Runtime, and Flow internals. Preserve the authenticated origin class
// without inventing missing control-plane identity fields.
account_id: None, account_id: None,
workspace_id: None, workspace_id: None,
runtime_id: None, runtime_id: None,
@@ -335,46 +345,7 @@ fn evidence_origin(origin: &crate::WorkerHistoryProvenance) -> EvidenceOrigin {
flow_selector: None, flow_selector: None,
flow_definition_id: None, flow_definition_id: None,
flow_definition_revision: None, flow_definition_revision: None,
};
match origin {
Origin::HumanInput { account_id } => {
evidence.kind = EvidenceOriginKind::HumanInput;
evidence.account_id = Some(account_id.clone());
} }
Origin::WorkerInput { actor } => {
evidence.kind = EvidenceOriginKind::WorkerInput;
evidence.workspace_id = actor.workspace_id.clone();
evidence.runtime_id = actor.runtime_id.clone();
evidence.worker_id = Some(actor.worker_id.clone());
}
Origin::FlowInstruction {
selector,
definition_id,
definition_revision,
..
} => {
evidence.kind = EvidenceOriginKind::FlowInstruction;
evidence.flow_selector = Some(selector.clone());
evidence.flow_definition_id = Some(definition_id.clone());
evidence.flow_definition_revision = Some(*definition_revision);
}
Origin::BackendInstruction { .. } => evidence.kind = EvidenceOriginKind::BackendInstruction,
Origin::ModelOutput { worker } => {
evidence.kind = EvidenceOriginKind::ModelOutput;
evidence.workspace_id = worker.workspace_id.clone();
evidence.runtime_id = worker.runtime_id.clone();
evidence.worker_id = Some(worker.worker_id.clone());
}
Origin::ToolOutput { worker } => {
evidence.kind = EvidenceOriginKind::ToolOutput;
evidence.workspace_id = worker.workspace_id.clone();
evidence.runtime_id = worker.runtime_id.clone();
evidence.worker_id = Some(worker.worker_id.clone());
}
Origin::DerivedSummary => evidence.kind = EvidenceOriginKind::DerivedSummary,
Origin::LegacyUnknown => evidence.kind = EvidenceOriginKind::LegacyUnknown,
}
evidence
} }
fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence { fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
@@ -502,12 +473,10 @@ mod tests {
} }
#[test] #[test]
fn human_origin_projects_account_authority_into_evidence() { fn public_human_origin_preserves_class_without_inventing_account_authority() {
let origin = evidence_origin(&crate::WorkerHistoryProvenance::HumanInput { let origin = evidence_origin(&protocol::SessionEntryProvenance::HumanInput);
account_id: "account-1".into(),
});
assert_eq!(origin.kind, EvidenceOriginKind::HumanInput); assert_eq!(origin.kind, EvidenceOriginKind::HumanInput);
assert_eq!(origin.account_id.as_deref(), Some("account-1")); assert_eq!(origin.account_id, None);
} }
#[test] #[test]

Some files were not shown because too many files have changed in this diff Show More