Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcada300e3 | ||
|
|
9756174676 | ||
|
|
6dd8461a46 | ||
|
|
12cc2eb0e9 | ||
|
|
38dd717aa6 | ||
|
|
6640c902de | ||
|
|
583c343d08 | ||
|
|
dfc48f7a05 | ||
|
|
cd9d854595 | ||
|
|
766cbd17e5 | ||
|
|
6df95bf981 | ||
|
|
6398ca0893 | ||
|
|
62372a48cc | ||
|
|
733632509a | ||
|
|
e1c13ec314 | ||
|
|
928ff0eabe | ||
|
|
406559b13d | ||
|
|
20c16aa6fd | ||
|
|
44ba5fd6d4 | ||
|
|
745c6adbf2 | ||
|
|
a9aa09636f | ||
|
|
6945986dd1 | ||
|
|
80c1f48f0e | ||
|
|
31e18205f0 | ||
|
|
e84a9d3f9b | ||
|
|
133feb8c76 | ||
|
|
c5fd9c01e5 | ||
|
|
a7bf5ceac3 | ||
|
|
74139aeb7e | ||
|
|
0cae4fd05c | ||
|
|
4b3b4fda61 | ||
|
|
adb684a6bf | ||
|
|
8493472983 | ||
|
|
862eeb7add | ||
|
|
4d9b211d69 | ||
|
|
ebb272324c | ||
|
|
c0290512b3 | ||
|
|
4ec56fe41e | ||
|
|
f8a7c46cf9 | ||
|
|
2bab8a9bb6 | ||
|
|
89e6a6215a | ||
|
|
88be87e03e | ||
|
|
22867faa9c | ||
|
|
16c0fc704d | ||
|
|
32fdd076bf | ||
|
|
402ae0d466 |
Generated
+25
-3
@@ -637,8 +637,8 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
name = "client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
"manifest",
|
||||
"protocol",
|
||||
"reqwest",
|
||||
"serde",
|
||||
@@ -2629,6 +2629,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"agen",
|
||||
"arc-swap",
|
||||
"decodal",
|
||||
"protocol",
|
||||
"secrets",
|
||||
"serde",
|
||||
@@ -4615,6 +4616,26 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "standalone"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"agen",
|
||||
"async-trait",
|
||||
"fs4",
|
||||
"futures",
|
||||
"manifest",
|
||||
"protocol",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"session-store",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"worker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
@@ -5304,7 +5325,6 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"client",
|
||||
"crossterm 0.28.1",
|
||||
"fs4",
|
||||
"manifest",
|
||||
"protocol",
|
||||
"pulldown-cmark",
|
||||
@@ -5313,13 +5333,14 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"session-store",
|
||||
"standalone",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"ticket",
|
||||
"tokio",
|
||||
"toml",
|
||||
"unicode-width",
|
||||
"uuid",
|
||||
"worker",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6617,6 +6638,7 @@ dependencies = [
|
||||
"wasmtime",
|
||||
"wat",
|
||||
"workdir",
|
||||
"workspace-api",
|
||||
"yoi-plugin-pdk",
|
||||
]
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ members = [
|
||||
"crates/agen",
|
||||
"crates/agen-macros",
|
||||
"crates/session-store",
|
||||
"crates/standalone",
|
||||
"crates/secrets",
|
||||
"crates/manifest",
|
||||
"crates/mcp",
|
||||
@@ -36,6 +37,7 @@ default-members = [
|
||||
"crates/agen",
|
||||
"crates/agen-macros",
|
||||
"crates/session-store",
|
||||
"crates/standalone",
|
||||
"crates/secrets",
|
||||
"crates/manifest",
|
||||
"crates/mcp",
|
||||
@@ -87,6 +89,7 @@ protocol = { path = "crates/protocol" }
|
||||
session-metrics = { path = "crates/session-metrics" }
|
||||
session-analytics = { path = "crates/session-analytics" }
|
||||
session-store = { path = "crates/session-store" }
|
||||
standalone = { path = "crates/standalone" }
|
||||
secrets = { path = "crates/secrets" }
|
||||
tools = { path = "crates/tools" }
|
||||
config-source = { path = "crates/config-source" }
|
||||
|
||||
@@ -5,15 +5,15 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
protocol = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
ticket = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { 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 }
|
||||
uuid = { workspace = true }
|
||||
workspace-api.workspace = true
|
||||
|
||||
@@ -0,0 +1,768 @@
|
||||
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 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(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,
|
||||
},
|
||||
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::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_origin_rejects_unsafe_authority_changes() {
|
||||
for invalid in [
|
||||
"ftp://example.com",
|
||||
"https://user@example.com",
|
||||
"https://example.com/api",
|
||||
"https://example.com/?query=1",
|
||||
"https://example.com/#fragment",
|
||||
] {
|
||||
assert!(BackendOrigin::parse(invalid).is_err(), "accepted {invalid}");
|
||||
}
|
||||
assert_ne!(
|
||||
BackendOrigin::parse("http://localhost:8787").unwrap(),
|
||||
BackendOrigin::parse("http://127.0.0.1:8787").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_lookup_distinguishes_missing_malformed_mismatch_and_expired() {
|
||||
let missing = temp_path("missing");
|
||||
assert!(matches!(
|
||||
BackendApiClient::from_token_file("http://localhost:8787", &missing),
|
||||
Err(BackendApiClientError::TokenFileMissing { .. })
|
||||
));
|
||||
|
||||
let malformed = temp_path("malformed");
|
||||
fs::write(&malformed, b"not json").unwrap();
|
||||
assert!(matches!(
|
||||
BackendApiClient::from_token_file("http://localhost:8787", &malformed),
|
||||
Err(BackendApiClientError::TokenFileMalformed { .. })
|
||||
));
|
||||
|
||||
let mismatch = temp_path("mismatch");
|
||||
write_fixture(
|
||||
&mismatch,
|
||||
serde_json::json!({"tokens": {"http://localhost:8787": {
|
||||
"token_type": "Bearer", "access_token": "secret"
|
||||
}}}),
|
||||
);
|
||||
assert!(matches!(
|
||||
BackendApiClient::from_token_file("http://127.0.0.1:8787", &mismatch),
|
||||
Err(BackendApiClientError::TokenEntryMissing { .. })
|
||||
));
|
||||
|
||||
let expired = temp_path("expired");
|
||||
write_fixture(
|
||||
&expired,
|
||||
serde_json::json!({"tokens": {"http://localhost:8787": {
|
||||
"token_type": "Bearer",
|
||||
"access_token": "secret",
|
||||
"expires_at": "2000-01-01T00:00:00Z"
|
||||
}}}),
|
||||
);
|
||||
assert!(matches!(
|
||||
BackendApiClient::from_token_file("http://localhost:8787", &expired),
|
||||
Err(BackendApiClientError::TokenExpired { .. })
|
||||
));
|
||||
|
||||
for path in [malformed, mismatch, expired] {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_write_and_lookup_share_origin_normalization() {
|
||||
let path = temp_path("normalized-write");
|
||||
save_backend_token_to_file(
|
||||
"HTTP://Example.COM:80////",
|
||||
"Bearer",
|
||||
"normalized-secret",
|
||||
None,
|
||||
&path,
|
||||
)
|
||||
.unwrap();
|
||||
let contents = fs::read_to_string(&path).unwrap();
|
||||
assert!(contents.contains("\"http://example.com\""));
|
||||
let client = BackendApiClient::from_token_file("http://example.com/", &path).unwrap();
|
||||
assert_eq!(
|
||||
client.authorization_header_value(),
|
||||
"Bearer normalized-secret"
|
||||
);
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_debug_and_errors_never_include_token_value() {
|
||||
let client = BackendApiClient::from_access_token_for_test(
|
||||
"http://localhost:8787",
|
||||
"never-print-this-token",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!format!("{client:?}").contains("never-print-this-token"));
|
||||
assert!(
|
||||
!BackendApiClientError::Unauthorized {
|
||||
origin: client.origin().clone()
|
||||
}
|
||||
.to_string()
|
||||
.contains("never-print-this-token")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_requests_follow_only_same_origin_redirects() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let origin = format!("http://{}", listener.local_addr().unwrap());
|
||||
let handle = thread::spawn(move || {
|
||||
for response in [
|
||||
"HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
] {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0; 4096];
|
||||
let read = stream.read(&mut request).unwrap();
|
||||
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
||||
assert!(request.contains("authorization: bearer redirect-secret\r\n"));
|
||||
stream.write_all(response.as_bytes()).unwrap();
|
||||
}
|
||||
});
|
||||
let client =
|
||||
BackendApiClient::from_access_token_for_test(&origin, "redirect-secret").unwrap();
|
||||
let response = client
|
||||
.blocking_request(Method::GET, "/start")
|
||||
.unwrap()
|
||||
.send()
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_requests_reject_cross_origin_redirects_without_leaking_token() {
|
||||
let source = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let target = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
target.set_nonblocking(true).unwrap();
|
||||
let source_origin = format!("http://{}", source.local_addr().unwrap());
|
||||
let target_origin = format!("http://{}", target.local_addr().unwrap());
|
||||
let location = format!("{target_origin}/capture");
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut stream, _) = source.accept().unwrap();
|
||||
let mut request = vec![0; 4096];
|
||||
let read = stream.read(&mut request).unwrap();
|
||||
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
||||
assert!(request.contains("authorization: bearer redirect-secret\r\n"));
|
||||
let response = format!(
|
||||
"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
stream.write_all(response.as_bytes()).unwrap();
|
||||
});
|
||||
let client =
|
||||
BackendApiClient::from_access_token_for_test(&source_origin, "redirect-secret")
|
||||
.unwrap();
|
||||
let error = client
|
||||
.blocking_request(Method::GET, "/start")
|
||||
.unwrap()
|
||||
.send()
|
||||
.unwrap_err();
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("redirect"));
|
||||
assert!(!message.contains("redirect-secret"));
|
||||
handle.join().unwrap();
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
assert!(matches!(
|
||||
target.accept(),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_diagnostics_distinguish_unauthorized_and_forbidden() {
|
||||
let client =
|
||||
BackendApiClient::from_access_token_for_test("http://localhost:8787", "secret")
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
client.check_status(StatusCode::UNAUTHORIZED),
|
||||
Err(BackendApiClientError::Unauthorized { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
client.check_status(StatusCode::FORBIDDEN),
|
||||
Err(BackendApiClientError::Forbidden { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::BackendOrigin;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::time::Duration;
|
||||
@@ -9,9 +10,11 @@ pub struct BackendAuthTarget {
|
||||
|
||||
impl BackendAuthTarget {
|
||||
pub fn new(base_url: impl Into<String>) -> Self {
|
||||
Self {
|
||||
base_url: base_url.into(),
|
||||
}
|
||||
let 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 {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
use crate::{BackendApiClient, BackendApiClientError};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use protocol::stream::{decode_event, encode_method};
|
||||
use protocol::{ErrorCode, Event, Method};
|
||||
use reqwest::Method as HttpMethod;
|
||||
use std::collections::VecDeque;
|
||||
use std::fmt;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_tungstenite::connect_async;
|
||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
|
||||
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
|
||||
pub use workspace_api::{
|
||||
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
|
||||
@@ -113,6 +118,7 @@ pub struct BackendRuntimeClient {
|
||||
#[derive(Debug)]
|
||||
pub enum BackendRuntimeClientError {
|
||||
InvalidTarget(String),
|
||||
Api(BackendApiClientError),
|
||||
Http(reqwest::Error),
|
||||
}
|
||||
|
||||
@@ -120,6 +126,7 @@ impl fmt::Display for BackendRuntimeClientError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidTarget(message) => f.write_str(message),
|
||||
Self::Api(error) => write!(f, "{error}"),
|
||||
Self::Http(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
@@ -127,6 +134,12 @@ impl fmt::Display 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 {
|
||||
fn from(error: reqwest::Error) -> Self {
|
||||
Self::Http(error)
|
||||
@@ -137,7 +150,7 @@ pub async fn list_backend_workers(
|
||||
target: &BackendRuntimeListTarget,
|
||||
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
|
||||
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() {
|
||||
let path = backend_runtime_workers_path(
|
||||
target
|
||||
@@ -146,12 +159,9 @@ pub async fn list_backend_workers(
|
||||
.expect("validated Backend Workspace scope"),
|
||||
runtime_id,
|
||||
);
|
||||
let url = join_base_and_path(&target.base_url, &path);
|
||||
return Ok(http
|
||||
.get(url)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
let response = api.request(HttpMethod::GET, &path)?.send().await?;
|
||||
api.check_status(response.status())?;
|
||||
return Ok(response
|
||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||
.await?);
|
||||
}
|
||||
@@ -162,12 +172,9 @@ pub async fn list_backend_workers(
|
||||
.as_deref()
|
||||
.expect("validated Backend Workspace scope"),
|
||||
);
|
||||
let runtime_url = join_base_and_path(&target.base_url, &runtime_path);
|
||||
let runtimes = http
|
||||
.get(runtime_url)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
let response = api.request(HttpMethod::GET, &runtime_path)?.send().await?;
|
||||
api.check_status(response.status())?;
|
||||
let runtimes = response
|
||||
.json::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
|
||||
.await?;
|
||||
|
||||
@@ -181,29 +188,43 @@ pub async fn list_backend_workers(
|
||||
.expect("validated Backend Workspace scope"),
|
||||
&runtime.runtime_id,
|
||||
);
|
||||
let url = join_base_and_path(&target.base_url, &path);
|
||||
match http
|
||||
.get(url)
|
||||
.send()
|
||||
.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);
|
||||
let response = match api.request(HttpMethod::GET, &path)?.send().await {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
diagnostics.push(BackendDiagnostic {
|
||||
code: "runtime_worker_list_failed".to_string(),
|
||||
severity: BackendDiagnosticSeverity::Error,
|
||||
message: format!(
|
||||
"failed to list workers for runtime {}: {error}",
|
||||
runtime.runtime_id
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
Err(error) => diagnostics.push(BackendDiagnostic {
|
||||
};
|
||||
if matches!(
|
||||
response.status(),
|
||||
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
|
||||
) {
|
||||
api.check_status(response.status())?;
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
diagnostics.push(BackendDiagnostic {
|
||||
code: "runtime_worker_list_failed".to_string(),
|
||||
severity: BackendDiagnosticSeverity::Error,
|
||||
message: format!(
|
||||
"failed to list workers for runtime {}: {error}",
|
||||
runtime.runtime_id
|
||||
"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 {
|
||||
@@ -224,7 +245,7 @@ pub async fn list_backend_stopped_workers(
|
||||
"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(
|
||||
target
|
||||
.workspace_id
|
||||
@@ -232,12 +253,12 @@ pub async fn list_backend_stopped_workers(
|
||||
.expect("validated Backend Workspace scope"),
|
||||
runtime_id,
|
||||
);
|
||||
let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped"));
|
||||
Ok(http
|
||||
.get(url)
|
||||
let response = api
|
||||
.request(HttpMethod::GET, &format!("{path}?status=stopped"))?
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.await?;
|
||||
api.check_status(response.status())?;
|
||||
Ok(response
|
||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||
.await?)
|
||||
}
|
||||
@@ -246,33 +267,33 @@ pub async fn restore_backend_worker(
|
||||
target: &BackendRuntimeTarget,
|
||||
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
||||
validate_target(target)?;
|
||||
let http = reqwest::Client::new();
|
||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
let path = backend_runtime_worker_restore_path(
|
||||
&target.workspace_id,
|
||||
&target.runtime_id,
|
||||
&target.worker_id,
|
||||
);
|
||||
let url = join_base_and_path(&target.base_url, &path);
|
||||
Ok(http
|
||||
.post(url)
|
||||
let response = api
|
||||
.request(HttpMethod::POST, &path)?
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<BackendWorkerRestoreResponse>()
|
||||
.await?)
|
||||
.await?;
|
||||
api.check_status(response.status())?;
|
||||
Ok(response.json::<BackendWorkerRestoreResponse>().await?)
|
||||
}
|
||||
|
||||
impl BackendRuntimeClient {
|
||||
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
|
||||
validate_target(&target)?;
|
||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
let (event_tx, rx) = mpsc::unbounded_channel();
|
||||
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
||||
|
||||
let protocol_target = target.clone();
|
||||
let protocol_event_tx = event_tx.clone();
|
||||
let protocol_task = tokio::spawn(async move {
|
||||
run_worker_protocol_transport(protocol_target, command_rx, protocol_event_tx).await;
|
||||
run_worker_protocol_transport(protocol_target, api, command_rx, protocol_event_tx)
|
||||
.await;
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
@@ -317,11 +338,21 @@ impl Drop for BackendRuntimeClient {
|
||||
|
||||
async fn run_worker_protocol_transport(
|
||||
target: BackendRuntimeTarget,
|
||||
api: BackendApiClient,
|
||||
mut commands: mpsc::UnboundedReceiver<Method>,
|
||||
tx: mpsc::UnboundedSender<Event>,
|
||||
) {
|
||||
let url = protocol_ws_url(&target);
|
||||
match connect_async(&url).await {
|
||||
let request = match protocol_ws_request(&target, &api) {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
let _ = tx.send(diagnostic_event(format!(
|
||||
"Backend protocol request could not be constructed for {}: {error}",
|
||||
target.display_label()
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
match connect_async(request).await {
|
||||
Ok((ws, _)) => {
|
||||
let (mut sink, mut stream) = ws.split();
|
||||
loop {
|
||||
@@ -387,10 +418,8 @@ async fn run_worker_protocol_transport(
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = tx.send(diagnostic_event(format!(
|
||||
"Backend protocol WebSocket connect failed for {}: {error}",
|
||||
target.display_label()
|
||||
)));
|
||||
let message = protocol_connect_error_message(&target, &api, &error);
|
||||
let _ = tx.send(diagnostic_event(message));
|
||||
while commands.recv().await.is_some() {
|
||||
let _ = tx.send(diagnostic_event(format!(
|
||||
"Backend protocol command was not sent because command stream is unavailable for {}",
|
||||
@@ -401,6 +430,29 @@ async fn run_worker_protocol_transport(
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_connect_error_message(
|
||||
target: &BackendRuntimeTarget,
|
||||
api: &BackendApiClient,
|
||||
error: &tokio_tungstenite::tungstenite::Error,
|
||||
) -> String {
|
||||
if let tokio_tungstenite::tungstenite::Error::Http(response) = error {
|
||||
if let Ok(status) = reqwest::StatusCode::from_u16(response.status().as_u16()) {
|
||||
if matches!(
|
||||
status,
|
||||
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
|
||||
) {
|
||||
if let Err(error) = api.check_status(status) {
|
||||
return error.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
format!(
|
||||
"Backend protocol WebSocket connect failed for {}: {error}",
|
||||
target.display_label()
|
||||
)
|
||||
}
|
||||
|
||||
fn diagnostic_event(message: impl Into<String>) -> Event {
|
||||
Event::Error {
|
||||
code: ErrorCode::Internal,
|
||||
@@ -496,6 +548,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 {
|
||||
let path = format!(
|
||||
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
|
||||
@@ -557,6 +622,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]
|
||||
fn backend_worker_summary_decodes_current_occupied_workdir_contract() {
|
||||
let payload = serde_json::json!({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::{BackendApiClient, BackendApiClientError};
|
||||
use reqwest::Method;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
||||
@@ -70,7 +72,7 @@ impl BackendWorkspaceCatalogTarget {
|
||||
#[derive(Debug)]
|
||||
pub enum BackendWorkspaceClientError {
|
||||
InvalidTarget(String),
|
||||
RequestFailed { status: u16, message: String },
|
||||
Api(BackendApiClientError),
|
||||
Http(reqwest::Error),
|
||||
}
|
||||
|
||||
@@ -78,9 +80,7 @@ impl fmt::Display for BackendWorkspaceClientError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidTarget(message) => f.write_str(message),
|
||||
Self::RequestFailed { status, message } => {
|
||||
write!(f, "Backend request failed with HTTP {status}: {message}")
|
||||
}
|
||||
Self::Api(error) => write!(f, "{error}"),
|
||||
Self::Http(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,12 @@ impl fmt::Display 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 {
|
||||
fn from(error: reqwest::Error) -> Self {
|
||||
Self::Http(error)
|
||||
@@ -97,13 +103,21 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
|
||||
pub async fn list_backend_workspaces(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||
validate_target(target)?;
|
||||
let url = format!(
|
||||
"{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}",
|
||||
target.base_url.trim_end_matches('/')
|
||||
);
|
||||
let response = reqwest::Client::new().get(url).send().await?;
|
||||
let response = require_success(response).await?;
|
||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
list_backend_workspaces_with_client(&client).await
|
||||
}
|
||||
|
||||
async fn list_backend_workspaces_with_client(
|
||||
client: &BackendApiClient,
|
||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||
let response = client
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
|
||||
)?
|
||||
.send()
|
||||
.await?;
|
||||
client.check_status(response.status())?;
|
||||
Ok(response.json::<Vec<BackendWorkspace>>().await?)
|
||||
}
|
||||
|
||||
@@ -111,42 +125,50 @@ pub async fn create_backend_workspace(
|
||||
target: &BackendWorkspaceCatalogTarget,
|
||||
request: &CreateBackendWorkspaceRequest,
|
||||
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
|
||||
validate_target(target)?;
|
||||
let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/'));
|
||||
let response = reqwest::Client::new()
|
||||
.post(url)
|
||||
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||
let response = client
|
||||
.request(Method::POST, "/api/workspaces")?
|
||||
.json(request)
|
||||
.send()
|
||||
.await?;
|
||||
let response = require_success(response).await?;
|
||||
client.check_status(response.status())?;
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_catalog_request_uses_shared_bearer_client() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0; 4096];
|
||||
let read = stream.read(&mut request).unwrap();
|
||||
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
||||
assert!(request.starts_with("get /api/workspaces?limit=200 "));
|
||||
assert!(request.contains("authorization: bearer catalog-secret\r\n"));
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let client =
|
||||
BackendApiClient::from_access_token_for_test(&base_url, "catalog-secret").unwrap();
|
||||
assert!(
|
||||
list_backend_workspaces_with_client(&client)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_request_keeps_operation_key_for_exact_retry() {
|
||||
|
||||
+12
-27
@@ -1,23 +1,20 @@
|
||||
//! Worker プロトコルを喋るクライアント。
|
||||
//! Backend Workspace/Runtime と既存 Worker protocol へ接続するクライアント。
|
||||
//!
|
||||
//! - [`WorkerClient`]: 既存 worker の Unix ソケットへ接続して `Method` を送り、
|
||||
//! `Event` を受け取る低レベル接続。
|
||||
//! - [`spawn`]: worker バイナリをサブプロセスとして起動し、`YOI-READY`
|
||||
//! ハンドシェイクが終わるまで待つフロー。subprocess を立ち上げる必要が
|
||||
//! ない呼び出し側 (=既存 worker に attach する場合) は使わなくてよい。
|
||||
//!
|
||||
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
||||
//! Standalone execution is owned by the `standalone` crate and does not spawn
|
||||
//! a Worker subprocess through this crate.
|
||||
|
||||
pub mod backend_auth;
|
||||
pub mod backend_api;
|
||||
mod backend_auth;
|
||||
pub mod backend_runtime;
|
||||
pub mod backend_workspace;
|
||||
pub mod runtime_command;
|
||||
pub mod spawn;
|
||||
pub mod target;
|
||||
pub mod ticket_role;
|
||||
mod worker_client;
|
||||
mod workspace_product;
|
||||
|
||||
pub use backend_api::{
|
||||
BackendApiClient, BackendApiClientError, BackendOrigin, backend_token_file_path,
|
||||
save_backend_token,
|
||||
};
|
||||
pub use backend_auth::{
|
||||
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
||||
poll_device_login, start_device_login, wait_for_device_login,
|
||||
@@ -35,22 +32,10 @@ pub use backend_workspace::{
|
||||
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
||||
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
|
||||
};
|
||||
pub use runtime_command::WorkerRuntimeCommand;
|
||||
pub use target::{
|
||||
BackendTarget, Dashboard, LocalTarget, ResolvedTarget, Target, TargetError, TargetKind,
|
||||
WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest,
|
||||
WorkerResume, WorkerSpawn,
|
||||
};
|
||||
|
||||
pub use spawn::{
|
||||
SpawnConfig, SpawnError, SpawnReady, WorkerProcessLaunchConfig, WorkerProcessLaunchOptions,
|
||||
spawn_worker, spawn_worker_with_options,
|
||||
};
|
||||
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,
|
||||
BackendTarget, Dashboard, ResolvedTarget, StandaloneSessionListIntent,
|
||||
StandaloneSessionResumeIntent, StandaloneTarget, Target, TargetError, TargetKind,
|
||||
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
|
||||
};
|
||||
pub use worker_client::WorkerClient;
|
||||
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary};
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
+161
-194
@@ -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)]
|
||||
pub enum TargetKind {
|
||||
Local,
|
||||
/// One-process Standalone authority with no Runtime or Workspace backend.
|
||||
Standalone,
|
||||
Backend,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ResolvedTarget {
|
||||
Local,
|
||||
Standalone,
|
||||
Backend {
|
||||
base_url: String,
|
||||
workspace_id: String,
|
||||
@@ -20,7 +24,7 @@ pub enum ResolvedTarget {
|
||||
impl ResolvedTarget {
|
||||
pub fn kind(&self) -> TargetKind {
|
||||
match self {
|
||||
Self::Local => TargetKind::Local,
|
||||
Self::Standalone => TargetKind::Standalone,
|
||||
Self::Backend { .. } => TargetKind::Backend,
|
||||
}
|
||||
}
|
||||
@@ -29,31 +33,12 @@ impl ResolvedTarget {
|
||||
impl fmt::Display for TargetKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Local => f.write_str("local"),
|
||||
Self::Standalone => f.write_str("Standalone"),
|
||||
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)]
|
||||
pub struct BackendTarget {
|
||||
pub base_url: String,
|
||||
@@ -62,11 +47,19 @@ pub struct BackendTarget {
|
||||
|
||||
impl BackendTarget {
|
||||
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 {
|
||||
base_url: base_url.into(),
|
||||
base_url,
|
||||
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)]
|
||||
@@ -108,34 +101,31 @@ impl WorkerConnectionSelector {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkerSpawn {
|
||||
pub runtime_command: WorkerRuntimeCommand,
|
||||
pub state_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkerByName {
|
||||
pub runtime_command: WorkerRuntimeCommand,
|
||||
pub struct StandaloneSessionListIntent {
|
||||
pub state_dir: PathBuf,
|
||||
pub cwd: PathBuf,
|
||||
pub include_all: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkerResume {
|
||||
pub runtime_command: WorkerRuntimeCommand,
|
||||
pub struct StandaloneSessionResumeIntent {
|
||||
pub state_dir: PathBuf,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Dashboard {
|
||||
Local {
|
||||
runtime_command: WorkerRuntimeCommand,
|
||||
},
|
||||
Backend {
|
||||
base_url: String,
|
||||
workspace_id: String,
|
||||
},
|
||||
pub struct Dashboard {
|
||||
pub base_url: String,
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkerList {
|
||||
pub local_runtime_command: Option<WorkerRuntimeCommand>,
|
||||
pub backend_target: Option<BackendRuntimeListTarget>,
|
||||
pub backend_target: BackendRuntimeListTarget,
|
||||
pub include_stopped: bool,
|
||||
}
|
||||
|
||||
@@ -161,12 +151,6 @@ impl TargetError {
|
||||
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 {
|
||||
@@ -183,71 +167,40 @@ pub trait Target: fmt::Debug + Send + Sync {
|
||||
/// Resolve the target once for Workspace product-state operations.
|
||||
///
|
||||
/// Backend targets must carry an explicit Workspace identity. Callers use
|
||||
/// this value instead of rediscovering Backend/local authority from cwd or
|
||||
/// process configuration after command dispatch.
|
||||
/// this value instead of rediscovering authority from cwd or process
|
||||
/// configuration after command dispatch.
|
||||
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> {
|
||||
Ok(WorkerSpawn {
|
||||
runtime_command: self.runtime_command()?,
|
||||
})
|
||||
Err(TargetError::unsupported("Worker spawn", self.kind()))
|
||||
}
|
||||
|
||||
fn worker_by_name(&self) -> Result<WorkerByName, TargetError> {
|
||||
Ok(WorkerByName {
|
||||
runtime_command: self.runtime_command()?,
|
||||
})
|
||||
fn standalone_session_list(
|
||||
&self,
|
||||
_include_all: bool,
|
||||
) -> Result<StandaloneSessionListIntent, TargetError> {
|
||||
Err(TargetError::unsupported(
|
||||
"standalone session listing",
|
||||
self.kind(),
|
||||
))
|
||||
}
|
||||
|
||||
fn resume_worker(&self) -> Result<WorkerResume, TargetError> {
|
||||
Ok(WorkerResume {
|
||||
runtime_command: self.runtime_command()?,
|
||||
})
|
||||
fn standalone_session_resume(
|
||||
&self,
|
||||
_session_id: String,
|
||||
) -> Result<StandaloneSessionResumeIntent, TargetError> {
|
||||
Err(TargetError::unsupported(
|
||||
"standalone session restore",
|
||||
self.kind(),
|
||||
))
|
||||
}
|
||||
|
||||
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
||||
Ok(Dashboard::Local {
|
||||
runtime_command: self.runtime_command()?,
|
||||
})
|
||||
Err(TargetError::unsupported("Worker dashboard", self.kind()))
|
||||
}
|
||||
|
||||
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
||||
if request.runtime_id.is_some() {
|
||||
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 list_workers(&self, _request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
||||
Err(TargetError::unsupported("Worker listing", self.kind()))
|
||||
}
|
||||
|
||||
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_session_list(
|
||||
&self,
|
||||
include_all: bool,
|
||||
) -> Result<StandaloneSessionListIntent, TargetError> {
|
||||
let cwd = std::env::current_dir()
|
||||
.map_err(|error| TargetError::invalid(self.kind(), error.to_string()))?;
|
||||
Ok(StandaloneSessionListIntent {
|
||||
state_dir: self.state_dir.clone(),
|
||||
cwd,
|
||||
include_all,
|
||||
})
|
||||
}
|
||||
|
||||
fn standalone_session_resume(
|
||||
&self,
|
||||
session_id: String,
|
||||
) -> Result<StandaloneSessionResumeIntent, TargetError> {
|
||||
Ok(StandaloneSessionResumeIntent {
|
||||
state_dir: self.state_dir.clone(),
|
||||
session_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Target for BackendTarget {
|
||||
fn kind(&self) -> TargetKind {
|
||||
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> {
|
||||
match self.resolve()? {
|
||||
ResolvedTarget::Backend {
|
||||
base_url,
|
||||
workspace_id,
|
||||
} => Ok(Dashboard::Backend {
|
||||
base_url,
|
||||
workspace_id,
|
||||
}),
|
||||
ResolvedTarget::Local => unreachable!("BackendTarget cannot resolve as Local"),
|
||||
}
|
||||
let ResolvedTarget::Backend {
|
||||
base_url,
|
||||
workspace_id,
|
||||
} = self.resolve()?
|
||||
else {
|
||||
unreachable!("BackendTarget resolves only Backend authority")
|
||||
};
|
||||
Ok(Dashboard {
|
||||
base_url,
|
||||
workspace_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError> {
|
||||
Ok(WorkerList {
|
||||
local_runtime_command: None,
|
||||
backend_target: Some(BackendRuntimeListTarget::new(
|
||||
backend_target: BackendRuntimeListTarget::new(
|
||||
self.base_url.clone(),
|
||||
self.workspace_id.clone(),
|
||||
request.runtime_id,
|
||||
)),
|
||||
),
|
||||
include_stopped: request.include_stopped,
|
||||
})
|
||||
}
|
||||
@@ -371,8 +362,34 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_target_resolves_local_product_state_authority() {
|
||||
assert_eq!(LocalTarget::new().resolve().unwrap(), ResolvedTarget::Local);
|
||||
fn standalone_target_carries_in_process_state_without_runtime_command() {
|
||||
let target = StandaloneTarget::new("/tmp/yoi-standalone-state");
|
||||
|
||||
assert_eq!(target.kind(), TargetKind::Standalone);
|
||||
assert_eq!(target.resolve().unwrap(), ResolvedTarget::Standalone);
|
||||
assert_eq!(
|
||||
target.spawn_worker().unwrap(),
|
||||
WorkerSpawn {
|
||||
state_dir: PathBuf::from("/tmp/yoi-standalone-state"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_target_never_exposes_workspace_worker_operations() {
|
||||
let target = StandaloneTarget::new("/tmp/yoi-standalone-state");
|
||||
|
||||
assert_eq!(
|
||||
target
|
||||
.list_workers(WorkerListRequest::new(None))
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
"Worker listing is not supported by Standalone target"
|
||||
);
|
||||
assert_eq!(
|
||||
target.dashboard().unwrap_err().to_string(),
|
||||
"Worker dashboard is not supported by Standalone target"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -381,26 +398,13 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
target.dashboard().unwrap(),
|
||||
Dashboard::Backend {
|
||||
Dashboard {
|
||||
base_url: "http://127.0.0.1:8787".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]
|
||||
fn backend_target_builds_worker_list() {
|
||||
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())))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(workers.backend_target.base_url, "http://127.0.0.1:8787");
|
||||
assert_eq!(
|
||||
workers.backend_target.as_ref().unwrap().base_url,
|
||||
"http://127.0.0.1:8787"
|
||||
);
|
||||
assert_eq!(
|
||||
workers
|
||||
.backend_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.workspace_id
|
||||
.as_deref(),
|
||||
workers.backend_target.workspace_id.as_deref(),
|
||||
Some("workspace-a")
|
||||
);
|
||||
assert_eq!(
|
||||
workers
|
||||
.backend_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.runtime_id
|
||||
.as_deref(),
|
||||
workers.backend_target.runtime_id.as_deref(),
|
||||
Some("runtime-a")
|
||||
);
|
||||
}
|
||||
@@ -446,41 +437,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_target_rejects_worker_connection_before_workspace_selection() {
|
||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
||||
let error =
|
||||
match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-b")) {
|
||||
Ok(_) => panic!("unscoped connection must fail"),
|
||||
Err(error) => error,
|
||||
};
|
||||
fn standalone_target_builds_explicit_session_intents() {
|
||||
let target = StandaloneTarget::new("/tmp/yoi-client-sessions");
|
||||
let list = target.standalone_session_list(true).unwrap();
|
||||
assert_eq!(list.state_dir, PathBuf::from("/tmp/yoi-client-sessions"));
|
||||
assert!(list.include_all);
|
||||
assert!(list.cwd.is_absolute());
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("workspace selection is required")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_target_rejects_local_worker_operations() {
|
||||
let target = BackendTarget::new("http://127.0.0.1:8787", None::<String>);
|
||||
let err = target.spawn_worker().unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"Worker spawn is not supported by Backend target"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_target_builds_local_worker_list() {
|
||||
let target = LocalTarget::new();
|
||||
let workers = target
|
||||
.list_workers(WorkerListRequest::with_stopped(None))
|
||||
let resume = target
|
||||
.standalone_session_resume("019d1234-0000-7000-8000-000000000000".to_string())
|
||||
.unwrap();
|
||||
|
||||
assert!(workers.local_runtime_command.is_some());
|
||||
assert!(workers.backend_target.is_none());
|
||||
assert!(workers.include_stopped);
|
||||
assert_eq!(resume.state_dir, list.state_dir);
|
||||
assert_eq!(resume.session_id, "019d1234-0000-7000-8000-000000000000");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ use workspace_api::{
|
||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||
};
|
||||
|
||||
use crate::BackendWorkspaceClientError;
|
||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||
|
||||
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
||||
|
||||
@@ -47,9 +47,9 @@ struct BackendWorkspaceOrchestratorResponse {
|
||||
/// Construction requires both the selected Backend URL and Workspace identity.
|
||||
/// Callers should derive these once from `Target::resolve()` and must not retry
|
||||
/// failed requests against repository-local state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackendWorkspaceProductClient {
|
||||
base_url: String,
|
||||
api: BackendApiClient,
|
||||
workspace_id: String,
|
||||
}
|
||||
|
||||
@@ -58,22 +58,32 @@ impl BackendWorkspaceProductClient {
|
||||
base_url: impl Into<String>,
|
||||
workspace_id: impl Into<String>,
|
||||
) -> Result<Self, BackendWorkspaceClientError> {
|
||||
let base_url = base_url.into().trim_end_matches('/').to_string();
|
||||
if base_url.is_empty() {
|
||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend base URL must not be empty".into(),
|
||||
));
|
||||
}
|
||||
let base_url = base_url.into();
|
||||
let api = BackendApiClient::from_stored_token(&base_url)?;
|
||||
let workspace_id = workspace_id.into();
|
||||
if workspace_id.trim().is_empty() {
|
||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend Workspace identity must not be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
base_url,
|
||||
workspace_id,
|
||||
})
|
||||
Ok(Self { api, workspace_id })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_with_access_token(
|
||||
base_url: impl Into<String>,
|
||||
workspace_id: impl Into<String>,
|
||||
access_token: &str,
|
||||
) -> Result<Self, BackendWorkspaceClientError> {
|
||||
let base_url = base_url.into();
|
||||
let api = BackendApiClient::from_access_token_for_test(&base_url, access_token)?;
|
||||
let workspace_id = workspace_id.into();
|
||||
if workspace_id.trim().is_empty() {
|
||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
"Backend Workspace identity must not be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self { api, workspace_id })
|
||||
}
|
||||
|
||||
pub fn workspace_id(&self) -> &str {
|
||||
@@ -316,7 +326,7 @@ impl BackendWorkspaceProductClient {
|
||||
body: Option<&B>,
|
||||
) -> Result<R, BackendWorkspaceClientError> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -326,7 +336,8 @@ impl BackendWorkspaceProductClient {
|
||||
path: &str,
|
||||
body: Option<&B>,
|
||||
) -> 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(())
|
||||
}
|
||||
|
||||
@@ -336,14 +347,12 @@ impl BackendWorkspaceProductClient {
|
||||
path: &str,
|
||||
body: Option<&B>,
|
||||
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
|
||||
let client = reqwest::blocking::Client::builder().build()?;
|
||||
let url = format!(
|
||||
"{}/api/w/{}/{}",
|
||||
self.base_url,
|
||||
let path = format!(
|
||||
"/api/w/{}/{}",
|
||||
encode_path_segment(&self.workspace_id),
|
||||
path.trim_start_matches('/')
|
||||
);
|
||||
let request = client.request(method, url);
|
||||
let request = self.api.blocking_request(method, &path)?;
|
||||
Ok(match body {
|
||||
Some(body) => request.json(body),
|
||||
None => request,
|
||||
@@ -588,19 +597,6 @@ fn ticket_client_error(error: BackendWorkspaceClientError) -> TicketError {
|
||||
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 {
|
||||
match id {
|
||||
TicketIdOrSlug::Id(id) => id.to_string(),
|
||||
@@ -698,24 +694,32 @@ mod tests {
|
||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
||||
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
||||
let client = BackendWorkspaceProductClient::new(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();
|
||||
|
||||
assert!(response.items.is_empty());
|
||||
assert!(
|
||||
request
|
||||
.recv()
|
||||
.unwrap()
|
||||
.starts_with("GET /api/w/workspace-a/objectives?limit=1000 ")
|
||||
);
|
||||
let request = request.recv().unwrap();
|
||||
assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 "));
|
||||
assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_mutation_failure_is_returned_without_local_fallback() {
|
||||
let (base_url, request, handle) = one_response_server("403 Forbidden", "denied");
|
||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
||||
let (base_url, request, handle) =
|
||||
one_response_server("403 Forbidden", "test-backend-token");
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-backend-token",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = client
|
||||
.create_objective(&ObjectiveCreateRequest {
|
||||
@@ -727,6 +731,7 @@ mod tests {
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("403"));
|
||||
assert!(!error.to_string().contains("test-backend-token"));
|
||||
assert!(
|
||||
request
|
||||
.recv()
|
||||
@@ -739,7 +744,12 @@ mod tests {
|
||||
#[test]
|
||||
fn ticket_relation_query_uses_workspace_scoped_backend_route() {
|
||||
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
|
||||
.query_ticket_relations(
|
||||
@@ -758,7 +768,12 @@ mod tests {
|
||||
#[test]
|
||||
fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
|
||||
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();
|
||||
|
||||
@@ -784,7 +799,12 @@ mod tests {
|
||||
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();
|
||||
|
||||
@@ -806,7 +826,12 @@ mod tests {
|
||||
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
|
||||
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 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();
|
||||
|
||||
@@ -822,7 +847,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,22 @@ 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]
|
||||
fn logical_paths_reject_absolute_parent_and_backslash_forms() {
|
||||
assert!(FsPath::new("src/lib.rs").is_ok());
|
||||
@@ -280,6 +296,261 @@ mod tests {
|
||||
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]
|
||||
fn grep_content_groups_lines_by_file_and_marks_matches() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -7,8 +7,8 @@ use grep_regex::RegexMatcherBuilder;
|
||||
use grep_searcher::sinks::UTF8 as UTF8Sink;
|
||||
use grep_searcher::{BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkMatch};
|
||||
use ignore::WalkBuilder;
|
||||
use ignore::overrides::OverrideBuilder;
|
||||
use ignore::types::TypesBuilder;
|
||||
use ignore::overrides::{Override, OverrideBuilder};
|
||||
use ignore::types::{Types, TypesBuilder};
|
||||
|
||||
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;
|
||||
|
||||
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 {
|
||||
pattern: String,
|
||||
path: Option<PathBuf>,
|
||||
@@ -221,13 +253,15 @@ pub fn run_grep(
|
||||
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
|
||||
_ => FsError::io(&base, e),
|
||||
})?;
|
||||
if !base_meta.is_dir() {
|
||||
if !base_meta.is_file() && !base_meta.is_dir() {
|
||||
return Err(FsError::InvalidArgument(format!(
|
||||
"grep search path is not a directory: {}",
|
||||
"grep search path must be a regular file or directory: {}",
|
||||
base.display()
|
||||
)));
|
||||
}
|
||||
if let Some(info) = symlink.as_ref() {
|
||||
if base_meta.is_dir()
|
||||
&& let Some(info) = symlink.as_ref()
|
||||
{
|
||||
return Err(FsError::SymlinkDirectoryNotTraversed {
|
||||
tool: "Grep",
|
||||
path: base.clone(),
|
||||
@@ -235,32 +269,9 @@ pub fn run_grep(
|
||||
});
|
||||
}
|
||||
|
||||
let mut wb = WalkBuilder::new(&base);
|
||||
wb.hidden(true)
|
||||
.git_ignore(true)
|
||||
.git_global(true)
|
||||
.git_exclude(true)
|
||||
.ignore(true)
|
||||
.parents(true)
|
||||
.follow_links(false);
|
||||
|
||||
if let Some(t) = p.file_type.as_deref() {
|
||||
let mut tb = TypesBuilder::new();
|
||||
tb.add_defaults();
|
||||
tb.select(t);
|
||||
let types = tb
|
||||
.build()
|
||||
.map_err(|e| FsError::InvalidArgument(format!("invalid type {t}: {e}")))?;
|
||||
wb.types(types);
|
||||
}
|
||||
if let Some(g) = p.glob.as_deref() {
|
||||
let mut ob = OverrideBuilder::new(&base);
|
||||
ob.add(g).map_err(|e| FsError::InvalidGlob(e.to_string()))?;
|
||||
let ov = ob
|
||||
.build()
|
||||
.map_err(|e| FsError::InvalidGlob(e.to_string()))?;
|
||||
wb.overrides(ov);
|
||||
}
|
||||
let filter_base = if base_meta.is_file() { root } else { &base };
|
||||
let types = build_types(p.file_type.as_deref())?;
|
||||
let overrides = build_overrides(filter_base, p.glob.as_deref())?;
|
||||
|
||||
let mode = p.output_mode.unwrap_or_default();
|
||||
let head_limit = p.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
||||
@@ -275,74 +286,133 @@ pub fn run_grep(
|
||||
lines: Vec::new(),
|
||||
truncated: false,
|
||||
};
|
||||
let mut matching_files_seen = 0;
|
||||
let mut matches_seen = 0;
|
||||
|
||||
// Per-mode walker state.
|
||||
let mut matching_files_seen: usize = 0;
|
||||
let mut matches_seen: usize = 0;
|
||||
if base_meta.is_file() {
|
||||
if direct_file_selected(&base, overrides.as_ref(), types.as_ref()) {
|
||||
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() {
|
||||
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
|
||||
let mut walker = WalkBuilder::new(&base);
|
||||
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;
|
||||
}
|
||||
let path = entry.path();
|
||||
if !access.is_readable(path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match mode {
|
||||
GrepOutputMode::FilesWithMatches => {
|
||||
let hit = scan_any_match(&mut searcher, &matcher, path)?;
|
||||
if !hit {
|
||||
continue;
|
||||
}
|
||||
if matching_files_seen >= offset {
|
||||
report.files.push(path.to_path_buf());
|
||||
if report.files.len() >= head_limit {
|
||||
report.truncated = true;
|
||||
break 'walker;
|
||||
}
|
||||
}
|
||||
matching_files_seen += 1;
|
||||
}
|
||||
GrepOutputMode::Count => {
|
||||
let count = scan_count(&mut searcher, &matcher, path)?;
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
if matching_files_seen >= offset {
|
||||
report.counts.push((path.to_path_buf(), count));
|
||||
if report.counts.len() >= head_limit {
|
||||
report.truncated = true;
|
||||
break 'walker;
|
||||
}
|
||||
}
|
||||
matching_files_seen += 1;
|
||||
}
|
||||
GrepOutputMode::Content => {
|
||||
let before_count = matches_seen;
|
||||
let mut sink = ContentSink {
|
||||
path: path.to_path_buf(),
|
||||
lines: &mut report.lines,
|
||||
matches_seen: &mut matches_seen,
|
||||
offset,
|
||||
head_limit,
|
||||
};
|
||||
searcher
|
||||
.search_path(&matcher, path, &mut sink)
|
||||
.map_err(|e| FsError::io(path, e))?;
|
||||
// If we hit head_limit during this file, stop walking.
|
||||
if matches_seen >= offset.saturating_add(head_limit) && matches_seen > before_count
|
||||
{
|
||||
report.truncated = true;
|
||||
break 'walker;
|
||||
}
|
||||
}
|
||||
if scan_path(
|
||||
&mut searcher,
|
||||
&matcher,
|
||||
path,
|
||||
mode,
|
||||
&mut report,
|
||||
&mut matching_files_seen,
|
||||
&mut matches_seen,
|
||||
offset,
|
||||
head_limit,
|
||||
)? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
searcher: &mut Searcher,
|
||||
matcher: &grep_regex::RegexMatcher,
|
||||
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
[dependencies]
|
||||
arc-swap = "1"
|
||||
agen = { workspace = true }
|
||||
decodal.workspace = true
|
||||
protocol = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,8 @@ pub struct FeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub worker: Option<WorkerFeatureConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub workspace_worker_discovery: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub objective: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub manage_workdir: Option<FeatureFlagConfigPartial>,
|
||||
@@ -119,6 +121,11 @@ impl FeatureConfigPartial {
|
||||
),
|
||||
flow: merge_option(self.flow, other.flow, FeatureFlagConfigPartial::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(
|
||||
self.objective,
|
||||
other.objective,
|
||||
@@ -265,6 +272,10 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
||||
.worker
|
||||
.map(WorkerFeatureConfig::from)
|
||||
.unwrap_or_default(),
|
||||
workspace_worker_discovery: value
|
||||
.workspace_worker_discovery
|
||||
.map(FeatureFlagConfig::from)
|
||||
.unwrap_or_default(),
|
||||
objective: value
|
||||
.objective
|
||||
.map(FeatureFlagConfig::from)
|
||||
@@ -394,6 +405,7 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
||||
sub_worker: Some(value.sub_worker.into()),
|
||||
flow: Some(value.flow.into()),
|
||||
worker: Some(value.worker.into()),
|
||||
workspace_worker_discovery: Some(value.workspace_worker_discovery.into()),
|
||||
objective: Some(value.objective.into()),
|
||||
manage_workdir: Some(value.manage_workdir.into()),
|
||||
ticket: Some(value.ticket.into()),
|
||||
@@ -566,15 +578,16 @@ impl WorkerManifestConfig {
|
||||
})
|
||||
}
|
||||
|
||||
/// Base config populated with the in-code defaults listed in
|
||||
/// [`crate::defaults`]. Profile and one-file Manifest resolvers start
|
||||
/// from this layer so every per-field default lives at exactly one
|
||||
/// call site (the `defaults` module).
|
||||
/// Base config populated with the in-code per-field defaults listed in
|
||||
/// [`crate::defaults`]. This is not a selectable Profile and does not
|
||||
/// enable a launch capability surface. Profile and one-file Manifest
|
||||
/// 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
|
||||
/// belt-and-suspenders fallback, so a manually-constructed config
|
||||
/// that skips this layer still resolves to the same values.
|
||||
pub fn builtin_defaults() -> Self {
|
||||
pub fn resolution_defaults() -> Self {
|
||||
Self {
|
||||
engine: EngineManifestConfig {
|
||||
tool_output: ToolOutputLimitsPartial {
|
||||
@@ -1973,7 +1986,7 @@ enabled = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
|
||||
let manifest: WorkerManifest = WorkerManifestConfig::resolution_defaults()
|
||||
.merge(cfg)
|
||||
.merge(WorkerManifestConfig {
|
||||
worker: WorkerMetaConfig {
|
||||
@@ -2074,7 +2087,7 @@ enabled = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
|
||||
let manifest: WorkerManifest = WorkerManifestConfig::resolution_defaults()
|
||||
.merge(base)
|
||||
.merge(upper)
|
||||
.merge(WorkerManifestConfig {
|
||||
@@ -2137,7 +2150,7 @@ permission = "write"
|
||||
|
||||
#[test]
|
||||
fn builtin_defaults_populates_worker_limit_defaults() {
|
||||
let cfg = WorkerManifestConfig::builtin_defaults();
|
||||
let cfg = WorkerManifestConfig::resolution_defaults();
|
||||
assert_eq!(
|
||||
cfg.engine.tool_output.default_max_bytes,
|
||||
Some(defaults::TOOL_OUTPUT_MAX_BYTES)
|
||||
@@ -2172,7 +2185,7 @@ permission = "write"
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let merged = WorkerManifestConfig::builtin_defaults().merge(overlay);
|
||||
let merged = WorkerManifestConfig::resolution_defaults().merge(overlay);
|
||||
let manifest: WorkerManifest = merged.try_into().unwrap();
|
||||
assert_eq!(
|
||||
manifest.engine.tool_output.default_max_bytes,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod builtin_profile;
|
||||
mod config;
|
||||
pub mod defaults;
|
||||
mod model;
|
||||
@@ -7,6 +8,11 @@ pub mod plugin;
|
||||
mod profile;
|
||||
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::{
|
||||
CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial,
|
||||
PermissionConfigPartial, ResolveError, SessionConfigPartial, ToolOutputLimitsPartial,
|
||||
@@ -17,10 +23,11 @@ pub use model::{
|
||||
};
|
||||
pub use paths::user_profiles_path;
|
||||
pub use profile::{
|
||||
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
|
||||
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
|
||||
ProfileSelector, ProfileSource, ResolvedProfile, resolve_profile_artifact,
|
||||
resolve_profile_artifact_value,
|
||||
ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
|
||||
ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
|
||||
ProfileResolveOptions, ProfileResolver, ProfileSelector, ProfileSource, ResolvedProfile,
|
||||
WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value,
|
||||
validate_profile_execution_target,
|
||||
};
|
||||
pub use protocol::{Permission, ScopeRule};
|
||||
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
||||
@@ -118,6 +125,10 @@ pub struct FeatureConfig {
|
||||
pub flow: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
pub objective: FeatureFlagConfig,
|
||||
#[serde(default)]
|
||||
@@ -142,6 +153,7 @@ impl Default for FeatureConfig {
|
||||
sub_worker: FeatureFlagConfig::disabled(),
|
||||
flow: FeatureFlagConfig::disabled(),
|
||||
worker: WorkerFeatureConfig::disabled(),
|
||||
workspace_worker_discovery: FeatureFlagConfig::disabled(),
|
||||
objective: FeatureFlagConfig::disabled(),
|
||||
manage_workdir: FeatureFlagConfig::disabled(),
|
||||
ticket: TicketFeatureConfig::default(),
|
||||
|
||||
+258
-258
@@ -6,9 +6,14 @@
|
||||
//! from launch context.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
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::{
|
||||
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
|
||||
};
|
||||
@@ -23,45 +28,6 @@ use crate::{
|
||||
const PROFILE_FORMAT_V1: &str = "yoi.profile.v1";
|
||||
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)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
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)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum ProfileSource {
|
||||
@@ -217,13 +285,14 @@ impl ProfileRegistryEntry {
|
||||
source: ProfileRegistrySource,
|
||||
name: &'static str,
|
||||
label: &'static str,
|
||||
provenance: String,
|
||||
description: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
source,
|
||||
name: name.to_string(),
|
||||
path: None,
|
||||
provenance: label.to_string(),
|
||||
provenance,
|
||||
description,
|
||||
is_default: false,
|
||||
artifact: ProfileRegistryArtifact::Builtin { label },
|
||||
@@ -321,12 +390,16 @@ pub struct ProfileDiscovery {
|
||||
}
|
||||
|
||||
impl ProfileDiscovery {
|
||||
pub fn for_cwd(_cwd: &Path) -> Self {
|
||||
pub fn user_settings() -> Self {
|
||||
Self {
|
||||
user_config: paths::user_profiles_path(),
|
||||
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 {
|
||||
Self {
|
||||
user_config,
|
||||
@@ -412,15 +485,22 @@ impl ProfileResolver {
|
||||
options,
|
||||
),
|
||||
ProfileSelector::Named { .. } | ProfileSelector::Default => {
|
||||
let cwd = std::env::current_dir().map_err(|source| ProfileError::CommandIo {
|
||||
path: PathBuf::from("."),
|
||||
source,
|
||||
})?;
|
||||
let registry = ProfileDiscovery::for_cwd(&cwd).discover()?;
|
||||
let registry = ProfileDiscovery::user_settings().discover()?;
|
||||
self.resolve_from_registry(selector, ®istry, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_for_target(
|
||||
&self,
|
||||
selector: &ProfileSelector,
|
||||
options: ProfileResolveOptions,
|
||||
target: ProfileExecutionTarget,
|
||||
) -> Result<ResolvedProfile, ProfileError> {
|
||||
let resolved = self.resolve(selector, options)?;
|
||||
validate_profile_execution_target(&resolved.manifest, target)?;
|
||||
Ok(resolved)
|
||||
}
|
||||
/// Resolve a registry/default selector against an already-discovered
|
||||
/// registry. Callers such as SubWorkerSpawn use this to bind discovery to the
|
||||
/// Worker's cwd instead of the process current directory.
|
||||
@@ -503,7 +583,7 @@ impl ProfileResolver {
|
||||
.as_deref()
|
||||
.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}`"))
|
||||
})?;
|
||||
resolve_profile_value(
|
||||
@@ -565,7 +645,8 @@ fn resolve_profile_value(
|
||||
memory: profile.memory.map(Into::into),
|
||||
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)?;
|
||||
manifest.profile = Some(ProfileManifestSnapshot {
|
||||
source: source.clone(),
|
||||
@@ -759,14 +840,30 @@ fn load_profile_registry_file(
|
||||
}
|
||||
|
||||
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(
|
||||
ProfileRegistrySource::Builtin,
|
||||
profile.name,
|
||||
profile.label,
|
||||
name,
|
||||
label,
|
||||
format!("{}#{digest}", profile.path),
|
||||
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) {
|
||||
@@ -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> {
|
||||
let Some(map) = value.as_object() else {
|
||||
return Err(ProfileError::InvalidProfile(
|
||||
@@ -1288,6 +1190,13 @@ pub enum ProfileError {
|
||||
#[source]
|
||||
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")]
|
||||
NoDefaultProfile,
|
||||
#[error("profile resolution requires an explicit runtime Worker name")]
|
||||
@@ -1341,18 +1250,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn builtin_profiles_do_not_define_an_implicit_default() {
|
||||
fn builtin_default_is_explicit_registry_authority() {
|
||||
let registry = ProfileDiscovery::with_sources(None, None)
|
||||
.discover()
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
registry.default_entry(),
|
||||
Err(ProfileError::NoDefaultProfile)
|
||||
));
|
||||
assert!(matches!(
|
||||
registry.select(&ProfileSelector::Default),
|
||||
Err(ProfileError::NoDefaultProfile)
|
||||
));
|
||||
let default = registry.default_entry().unwrap();
|
||||
assert_eq!(default.source, ProfileRegistrySource::Builtin);
|
||||
assert_eq!(default.name, "default");
|
||||
assert_eq!(default.qualified_name(), BUILTIN_DEFAULT_PROFILE);
|
||||
assert!(default.is_default);
|
||||
assert!(
|
||||
default
|
||||
.provenance
|
||||
.starts_with("profiles/default.dcdl#sha256:")
|
||||
);
|
||||
assert_eq!(registry.select(&ProfileSelector::Default).unwrap(), default);
|
||||
}
|
||||
#[test]
|
||||
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]
|
||||
fn builtin_coder_uses_sub_worker_control_without_worker_control() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -1423,7 +1421,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 resolved = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
@@ -1435,7 +1433,9 @@ mod tests {
|
||||
|
||||
assert!(resolved.manifest.feature.manage_workdir.enabled);
|
||||
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||
assert!(!resolved.manifest.feature.worker.enabled);
|
||||
assert!(resolved.manifest.feature.worker.enabled);
|
||||
assert!(!resolved.manifest.feature.worker.direct_spawn);
|
||||
assert!(resolved.manifest.feature.workspace_worker_discovery.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+142
-29
@@ -340,8 +340,7 @@ pub struct InternalWorkerRef {
|
||||
pub struct InternalWorkerSnapshot {
|
||||
pub worker: InternalWorkerRef,
|
||||
pub revision: u64,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
|
||||
pub entries: Vec<serde_json::Value>,
|
||||
pub session: SessionSnapshot,
|
||||
#[serde(default)]
|
||||
pub status: WorkerStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -364,12 +363,114 @@ pub enum ToolResultDisposition {
|
||||
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)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
||||
pub enum Event {
|
||||
/// 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
|
||||
/// the same user line that reconnect snapshots would replay from
|
||||
/// history; clients must not synthesize a separate pending/fake
|
||||
@@ -390,7 +491,7 @@ pub enum Event {
|
||||
/// of parsing free-text prefixes like `[Notification] …` or
|
||||
/// `[File: …]`.
|
||||
///
|
||||
/// One event per `LogEntry::SystemItem` commit. Disk-side and
|
||||
/// One event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and
|
||||
/// wire-side are 1:1.
|
||||
SystemItem {
|
||||
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||
@@ -555,8 +656,7 @@ pub enum Event {
|
||||
/// role-specific entry events (`SegmentRotated` / `SystemItem`) —
|
||||
/// there is no generic "every committed entry" broadcast.
|
||||
Snapshot {
|
||||
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
|
||||
entries: Vec<serde_json::Value>,
|
||||
session: SessionSnapshot,
|
||||
greeting: Greeting,
|
||||
#[serde(default)]
|
||||
status: WorkerStatus,
|
||||
@@ -589,14 +689,10 @@ pub enum Event {
|
||||
/// Server-side segment log rotated to a fresh `SegmentStart`.
|
||||
///
|
||||
/// Fires on compaction and on auto-fork when the store head drifts
|
||||
/// from the live writer's cached head. Clients drop their derived
|
||||
/// view and reseed from `entry.history` exactly the way they would
|
||||
/// from a connect-time `Snapshot`.
|
||||
///
|
||||
/// Payload is the JSON form of `session_store::LogEntry::SegmentStart`.
|
||||
/// A compaction/fork has replaced the authoritative segment. Clients drop
|
||||
/// their derived view and reseed from the canonical committed snapshot.
|
||||
SegmentRotated {
|
||||
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||
entry: serde_json::Value,
|
||||
session: SessionSnapshot,
|
||||
},
|
||||
/// Current Worker controller status. Broadcast on every controller-level
|
||||
/// transition and included in `History` snapshots for late attach.
|
||||
@@ -623,11 +719,10 @@ pub enum Event {
|
||||
head_entries: usize,
|
||||
targets: Vec<RewindTarget>,
|
||||
},
|
||||
/// A rewind has truncated the authoritative session. `entries` is the
|
||||
/// retained session-log prefix clients should use to reseed display state.
|
||||
/// A rewind has truncated the authoritative session. `session` is the
|
||||
/// retained canonical snapshot clients should use to reseed display state.
|
||||
RewindApplied {
|
||||
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
|
||||
entries: Vec<serde_json::Value>,
|
||||
session: SessionSnapshot,
|
||||
input: Vec<Segment>,
|
||||
summary: RewindSummary,
|
||||
},
|
||||
@@ -1440,7 +1535,17 @@ mod tests {
|
||||
#[test]
|
||||
fn event_snapshot_format() {
|
||||
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 {
|
||||
worker_name: "test".into(),
|
||||
cwd: "/tmp".into(),
|
||||
@@ -1458,8 +1563,12 @@ mod tests {
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed["event"], "snapshot");
|
||||
assert!(parsed["data"]["entries"].is_array());
|
||||
assert_eq!(parsed["data"]["entries"][0]["kind"], "user_input");
|
||||
assert!(parsed["data"]["session"]["entries"].is_array());
|
||||
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"]["tools"][0], "Read");
|
||||
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
|
||||
@@ -1469,7 +1578,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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();
|
||||
match decoded {
|
||||
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
|
||||
@@ -1477,7 +1586,9 @@ mod tests {
|
||||
}
|
||||
|
||||
let event = Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
session: SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: Greeting {
|
||||
worker_name: "test".into(),
|
||||
cwd: "/tmp".into(),
|
||||
@@ -1543,15 +1654,17 @@ mod tests {
|
||||
#[test]
|
||||
fn event_segment_rotated_roundtrip() {
|
||||
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 parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
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();
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -1627,8 +1740,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_snapshot_legacy_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":[]}}}"#;
|
||||
fn event_snapshot_without_status_defaults_to_idle() {
|
||||
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();
|
||||
match decoded {
|
||||
Event::Snapshot {
|
||||
@@ -2039,11 +2152,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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!({
|
||||
"event": "snapshot",
|
||||
"data": {
|
||||
"entries": [],
|
||||
"session": { "entries": [] },
|
||||
"greeting": {
|
||||
"worker_name": "parent",
|
||||
"cwd": ".",
|
||||
|
||||
@@ -8,7 +8,9 @@ use crate::{
|
||||
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
||||
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||
InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId,
|
||||
RunResult, ScopeRule, Segment, ToolResultDisposition, TurnResult, WorkerEvent, WorkerStatus,
|
||||
RunResult, ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
|
||||
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||
ToolResultDisposition, TurnResult, WorkerEvent, WorkerStatus,
|
||||
subscription::{
|
||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||
@@ -63,6 +65,13 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<RewindSummary>(&cfg, &mut output);
|
||||
push_decl::<InFlightBlock>(&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::<InternalWorkerRef>(&cfg, &mut output);
|
||||
push_decl::<InternalWorkerSnapshot>(&cfg, &mut output);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{LoggedItem, SessionId};
|
||||
use crate::LoggedItem;
|
||||
|
||||
/// Stable logical identity of one model-visible history entry.
|
||||
///
|
||||
@@ -142,12 +142,15 @@ mod tests {
|
||||
#[test]
|
||||
fn annotated_segment_start_is_restore_visible_without_projecting_metadata() {
|
||||
let session_id = uuid::Uuid::now_v7();
|
||||
let history_entry = legacy_logged_history(LoggedItem::Message {
|
||||
role: LoggedRole::Assistant,
|
||||
content: vec![crate::LoggedContentPart::Text {
|
||||
text: "answer".into(),
|
||||
}],
|
||||
});
|
||||
let history_entry = LoggedHistoryEntry {
|
||||
item: LoggedItem::Message {
|
||||
role: LoggedRole::Assistant,
|
||||
content: vec![crate::LoggedContentPart::Text {
|
||||
text: "answer".into(),
|
||||
}],
|
||||
},
|
||||
metadata: LoggedSessionHistoryMetadata::legacy_unknown(),
|
||||
};
|
||||
let state = crate::collect_state(&[crate::LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id,
|
||||
@@ -160,21 +163,3 @@ mod tests {
|
||||
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),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -26,14 +26,16 @@
|
||||
//! let (session_id, segment_id) = create_segment(&store, SegmentStartState {
|
||||
//! system_prompt: None,
|
||||
//! config: &config,
|
||||
//! history: &[],
|
||||
//! history: Vec::new(),
|
||||
//! })?;
|
||||
//! ```
|
||||
|
||||
pub mod event_trace;
|
||||
pub mod fs_store;
|
||||
pub mod history;
|
||||
mod legacy_session_log;
|
||||
pub mod logged_item;
|
||||
pub mod public_snapshot;
|
||||
pub mod segment;
|
||||
pub mod segment_log;
|
||||
pub mod store;
|
||||
@@ -48,11 +50,11 @@ pub use fs_store::FsStore;
|
||||
pub use history::{
|
||||
LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
|
||||
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry,
|
||||
LoggedWorkerSubject, legacy_logged_history, legacy_segment_history,
|
||||
LoggedWorkerSubject,
|
||||
};
|
||||
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
||||
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,
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,9 @@
|
||||
//! The caller (typically Worker) holds the Engine directly and calls these
|
||||
//! functions after state-mutating operations.
|
||||
|
||||
use crate::logged_item::{LoggedItem, to_logged};
|
||||
use crate::segment_log::{self, LogEntry, SegmentOrigin};
|
||||
use crate::store::{Store, StoreError};
|
||||
use crate::system_item::SystemItem;
|
||||
use crate::{SegmentId, SessionId};
|
||||
use crate::{LoggedHistoryEntry, LoggedSystemHistoryEntry, SegmentId, SessionId};
|
||||
use agen::EngineResult;
|
||||
use agen::llm_client::RequestConfig;
|
||||
use agen::llm_client::types::Item;
|
||||
@@ -18,7 +16,7 @@ use protocol::Segment;
|
||||
pub struct SegmentStartState<'a> {
|
||||
pub system_prompt: Option<&'a str>,
|
||||
pub config: &'a RequestConfig,
|
||||
pub history: &'a [Item],
|
||||
pub history: Vec<LoggedHistoryEntry>,
|
||||
}
|
||||
|
||||
/// Create a new session + initial segment, writing the initial
|
||||
@@ -44,12 +42,12 @@ pub fn create_segment_with_ids(
|
||||
segment_id: SegmentId,
|
||||
state: SegmentStartState<'_>,
|
||||
) -> Result<(), StoreError> {
|
||||
let entry = LogEntry::SegmentStart {
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id,
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: to_logged(state.history),
|
||||
history: state.history.to_vec(),
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
@@ -70,12 +68,12 @@ pub fn create_compacted_segment(
|
||||
source_turn_count: usize,
|
||||
) -> Result<SegmentId, StoreError> {
|
||||
let segment_id = crate::new_segment_id();
|
||||
let entry = LogEntry::SegmentStart {
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id: source_session_id,
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: to_logged(state.history),
|
||||
history: state.history.to_vec(),
|
||||
forked_from: None,
|
||||
compacted_from: Some(SegmentOrigin {
|
||||
segment_id: source_segment_id,
|
||||
@@ -154,12 +152,12 @@ pub fn ensure_head_or_fork(
|
||||
}
|
||||
let source_segment_id = *segment_id;
|
||||
let fork_id = crate::new_segment_id();
|
||||
let entry = LogEntry::SegmentStart {
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id,
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: to_logged(state.history),
|
||||
history: state.history.to_vec(),
|
||||
forked_from: Some(SegmentOrigin {
|
||||
segment_id: source_segment_id,
|
||||
at_turn_index,
|
||||
@@ -183,8 +181,9 @@ pub fn save_user_input(
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
segments: Vec<Segment>,
|
||||
history: Vec<LoggedHistoryEntry>,
|
||||
) -> 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
|
||||
@@ -194,15 +193,17 @@ pub fn save_user_input_with_extensions(
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
segments: Vec<Segment>,
|
||||
history: Vec<LoggedHistoryEntry>,
|
||||
extensions: Vec<segment_log::SessionExtension>,
|
||||
) -> Result<(), StoreError> {
|
||||
append_entry(
|
||||
store,
|
||||
session_id,
|
||||
segment_id,
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: segment_log::now_millis(),
|
||||
segments,
|
||||
history,
|
||||
extensions,
|
||||
},
|
||||
)
|
||||
@@ -220,64 +221,57 @@ pub fn save_delta(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
new_items: &[Item],
|
||||
new_items: &[LoggedHistoryEntry],
|
||||
) -> Result<(), StoreError> {
|
||||
if new_items.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
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() {
|
||||
// Already persisted by save_user_input at submit time.
|
||||
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)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Map one history item to its singular `LogEntry` form. Used by the
|
||||
/// fallback `save_delta` path and the controller's worker-callback
|
||||
/// classifier so write classification lives in one place.
|
||||
pub fn classify_history_item(item: &Item, ts: u64) -> LogEntry {
|
||||
/// Map one annotated history entry to its singular `LogEntry` form. Used by
|
||||
/// the fallback `save_delta` path and the controller's worker-callback
|
||||
/// classifier so write classification lives in one place without discarding
|
||||
/// 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() {
|
||||
LogEntry::ToolResult {
|
||||
ts,
|
||||
item: LoggedItem::from(item),
|
||||
}
|
||||
} else if item.is_assistant_message() || item.is_tool_call() || item.is_reasoning() {
|
||||
LogEntry::AssistantItem {
|
||||
ts,
|
||||
item: LoggedItem::from(item),
|
||||
}
|
||||
LogEntry::AnnotatedToolResult { ts, entry }
|
||||
} else {
|
||||
// Defensive: anything else (future Item kinds) routes through
|
||||
// AssistantItem rather than getting silently dropped.
|
||||
LogEntry::AssistantItem {
|
||||
ts,
|
||||
item: LoggedItem::from(item),
|
||||
}
|
||||
// Assistant messages, tool calls, reasoning, and future non-user
|
||||
// items all use the assistant-side canonical record.
|
||||
LogEntry::AnnotatedAssistantItem { ts, entry }
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a single typed system item as `LogEntry::SystemItem`. Helper
|
||||
/// for the Worker-side interceptor commit path; mirrors the per-item
|
||||
/// commit shape used for assistant / tool result entries.
|
||||
/// Append one typed system item and its history metadata as a canonical
|
||||
/// `LogEntry::AnnotatedSystemItem`.
|
||||
pub fn append_system_item(
|
||||
store: &impl Store,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
item: SystemItem,
|
||||
entry: LoggedSystemHistoryEntry,
|
||||
) -> Result<(), StoreError> {
|
||||
append_entry(
|
||||
store,
|
||||
session_id,
|
||||
segment_id,
|
||||
LogEntry::SystemItem {
|
||||
LogEntry::AnnotatedSystemItem {
|
||||
ts: segment_log::now_millis(),
|
||||
item,
|
||||
entry,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -430,12 +424,12 @@ pub fn fork(
|
||||
) -> Result<(SessionId, SegmentId), StoreError> {
|
||||
let session_id = crate::new_session_id();
|
||||
let fork_id = crate::new_segment_id();
|
||||
let entry = LogEntry::SegmentStart {
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id,
|
||||
system_prompt: state.system_prompt.map(String::from),
|
||||
config: state.config.clone(),
|
||||
history: to_logged(state.history),
|
||||
history: state.history.to_vec(),
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
@@ -470,7 +464,7 @@ pub fn fork_at(
|
||||
// segment), before any turn completes.
|
||||
entries
|
||||
.iter()
|
||||
.position(|e| !matches!(e, LogEntry::SegmentStart { .. }))
|
||||
.position(|e| !matches!(e, LogEntry::AnnotatedSegmentStart { .. }))
|
||||
.unwrap_or(entries.len())
|
||||
} else {
|
||||
entries
|
||||
@@ -482,12 +476,12 @@ pub fn fork_at(
|
||||
let state = segment_log::collect_state(&entries[..cut]);
|
||||
|
||||
let fork_id = crate::new_segment_id();
|
||||
let entry = LogEntry::SegmentStart {
|
||||
let entry = LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id: source_session_id,
|
||||
system_prompt: state.system_prompt,
|
||||
config: state.config,
|
||||
history: to_logged(&state.history),
|
||||
history: state.annotated_history,
|
||||
forked_from: Some(SegmentOrigin {
|
||||
segment_id: source_id,
|
||||
at_turn_index,
|
||||
|
||||
@@ -16,7 +16,6 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::history::{LoggedHistoryEntry, LoggedSystemHistoryEntry};
|
||||
use crate::logged_item::LoggedItem;
|
||||
use crate::system_item::SystemItem;
|
||||
|
||||
/// A single segment log entry, serialized as one JSONL line.
|
||||
///
|
||||
@@ -50,28 +49,7 @@ impl SessionExtension {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum LogEntry {
|
||||
/// Segment start. Always the first entry in a segment log.
|
||||
/// 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
|
||||
/// Canonical segment seed. Retained entries keep their stable logical
|
||||
/// identity and origin across fork/compaction/restore.
|
||||
AnnotatedSegmentStart {
|
||||
ts: u64,
|
||||
@@ -105,22 +83,7 @@ pub enum LogEntry {
|
||||
/// restore conservatively instead of re-running a dangling tool call.
|
||||
Invoke { ts: u64, trigger: InvokeKind },
|
||||
|
||||
/// User input accepted at submit time. Carries the original 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
|
||||
/// Canonical user submission with its exact model-visible entries. Typed
|
||||
/// Flow instructions and caller-attributed input remain separate entries.
|
||||
AnnotatedUserInput {
|
||||
ts: u64,
|
||||
@@ -130,35 +93,19 @@ pub enum LogEntry {
|
||||
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 },
|
||||
|
||||
/// One assistant-side item appended to history — assistant message,
|
||||
/// reasoning, or tool call. Singular: one entry per history item so
|
||||
/// the wire-side `Event::*` lane and on-disk LogEntry stay 1:1.
|
||||
AssistantItem { ts: u64, item: LoggedItem },
|
||||
|
||||
/// Schema-v2 tool output and metadata committed as one journal record.
|
||||
/// Canonical tool output and metadata committed as one journal record.
|
||||
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
|
||||
|
||||
/// One tool-execution result appended to history.
|
||||
ToolResult { ts: u64, item: LoggedItem },
|
||||
|
||||
/// Schema-v2 typed system event and model-visible metadata committed
|
||||
/// Canonical typed system event and model-visible metadata committed
|
||||
/// together.
|
||||
AnnotatedSystemItem {
|
||||
ts: u64,
|
||||
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.
|
||||
TurnEnd { ts: u64, turn_count: usize },
|
||||
|
||||
@@ -260,6 +207,10 @@ pub struct RestoredState {
|
||||
pub system_prompt: Option<String>,
|
||||
pub config: RequestConfig,
|
||||
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,
|
||||
/// AgentTurns consumed by the active paused/yielded logical run.
|
||||
pub active_run_turn_count: Option<usize>,
|
||||
@@ -276,7 +227,7 @@ pub struct RestoredState {
|
||||
/// session-store は domain を不透明扱いし、各ドメインが自前で fold する。
|
||||
pub extensions: Vec<(String, serde_json::Value)>,
|
||||
/// 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
|
||||
/// pre-compaction history seeded via `SegmentStart.history`, whose
|
||||
/// 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,
|
||||
config: RequestConfig::default(),
|
||||
history: Vec::new(),
|
||||
annotated_history: Vec::new(),
|
||||
turn_count: 0,
|
||||
active_run_turn_count: None,
|
||||
last_run_interrupted: false,
|
||||
@@ -304,18 +256,6 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
state.entries_count += 1;
|
||||
|
||||
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 {
|
||||
session_id,
|
||||
system_prompt,
|
||||
@@ -326,6 +266,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
state.session_id = Some(*session_id);
|
||||
state.system_prompt = system_prompt.clone();
|
||||
state.config = config.clone();
|
||||
state.annotated_history = history.clone();
|
||||
state.history = history
|
||||
.iter()
|
||||
.cloned()
|
||||
@@ -338,26 +279,13 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
||||
state.last_run_interrupted = true;
|
||||
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 {
|
||||
segments,
|
||||
extensions,
|
||||
history,
|
||||
..
|
||||
} => {
|
||||
state.annotated_history.extend(history.iter().cloned());
|
||||
state
|
||||
.history
|
||||
.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::AnnotatedToolResult { entry, .. } => {
|
||||
state.annotated_history.push(entry.clone());
|
||||
state.history.push(Item::from(entry.item.clone()));
|
||||
}
|
||||
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());
|
||||
}
|
||||
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, .. } => {
|
||||
if let Some(active_turn_count) = &mut state.active_run_turn_count {
|
||||
*active_turn_count += turn_count.saturating_sub(state.turn_count);
|
||||
@@ -465,6 +389,20 @@ pub fn now_millis() -> u64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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]
|
||||
fn replay_empty() {
|
||||
@@ -476,12 +414,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn replay_segment_start_sets_initial_state() {
|
||||
let state = collect_state(&[LogEntry::SegmentStart {
|
||||
let state = collect_state(&[LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: Some("You are helpful.".into()),
|
||||
config: RequestConfig::default().with_max_tokens(1024),
|
||||
history: vec![Item::user_message("seed").into()],
|
||||
history: vec![annotated(Item::user_message("seed"))],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
}]);
|
||||
@@ -494,7 +432,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_full_turn() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -503,14 +441,15 @@ mod tests {
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2000,
|
||||
extensions: vec![],
|
||||
segments: vec![Segment::text("Hello")],
|
||||
history: vec![annotated(Item::user_message("Hello"))],
|
||||
},
|
||||
LogEntry::AssistantItem {
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 3000,
|
||||
item: Item::assistant_message("Hi!").into(),
|
||||
entry: annotated(Item::assistant_message("Hi!")),
|
||||
},
|
||||
LogEntry::TurnEnd {
|
||||
ts: 3100,
|
||||
@@ -531,7 +470,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_incomplete_invoke_is_interrupted() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -544,14 +483,15 @@ mod tests {
|
||||
ts: 2000,
|
||||
trigger: InvokeKind::UserSend,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2001,
|
||||
extensions: vec![],
|
||||
segments: vec![Segment::text("run a tool")],
|
||||
history: vec![annotated(Item::user_message("run a tool"))],
|
||||
},
|
||||
LogEntry::AssistantItem {
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
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]
|
||||
fn replay_with_tool_calls() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -570,22 +510,27 @@ mod tests {
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2000,
|
||||
extensions: vec![],
|
||||
segments: vec![Segment::text("Check weather")],
|
||||
history: vec![annotated(Item::user_message("Check weather"))],
|
||||
},
|
||||
LogEntry::AssistantItem {
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
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,
|
||||
item: Item::tool_result("call_1", "Sunny, 25C").into(),
|
||||
entry: annotated(Item::tool_result("call_1", "Sunny, 25C")),
|
||||
},
|
||||
LogEntry::AssistantItem {
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 4000,
|
||||
item: Item::assistant_message("It's sunny in Tokyo!").into(),
|
||||
entry: annotated(Item::assistant_message("It's sunny in Tokyo!")),
|
||||
},
|
||||
LogEntry::TurnEnd {
|
||||
ts: 4100,
|
||||
@@ -599,9 +544,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn replay_restores_durable_tool_image_detail() {
|
||||
let entry = LogEntry::ToolResult {
|
||||
let entry = LogEntry::AnnotatedToolResult {
|
||||
ts: 3500,
|
||||
item: Item::tool_result_item_with_attachments(
|
||||
entry: annotated(Item::tool_result_item_with_attachments(
|
||||
"call_image",
|
||||
"attached",
|
||||
None,
|
||||
@@ -609,8 +554,7 @@ mod tests {
|
||||
vec![agen::tool::Attachment::Image(
|
||||
agen::tool::ImageAttachment::new("image/png", b"durable-image".to_vec()),
|
||||
)],
|
||||
)
|
||||
.into(),
|
||||
)),
|
||||
};
|
||||
let persisted = serde_json::to_string(&entry).unwrap();
|
||||
let restored_entry: LogEntry = serde_json::from_str(&persisted).unwrap();
|
||||
@@ -630,7 +574,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_config_changed() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -650,7 +594,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_llm_usage_appends_to_usage_history() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -659,10 +603,11 @@ mod tests {
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2000,
|
||||
extensions: vec![],
|
||||
segments: vec![Segment::text("hi")],
|
||||
history: vec![annotated(Item::user_message("hi"))],
|
||||
},
|
||||
LogEntry::LlmUsage {
|
||||
ts: 2100,
|
||||
@@ -672,9 +617,9 @@ mod tests {
|
||||
cache_write_tokens: 0,
|
||||
output_tokens: 10,
|
||||
},
|
||||
LogEntry::AssistantItem {
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 2200,
|
||||
item: Item::assistant_message("yo").into(),
|
||||
entry: annotated(Item::assistant_message("yo")),
|
||||
},
|
||||
LogEntry::LlmUsage {
|
||||
ts: 3100,
|
||||
@@ -698,7 +643,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_without_llm_usage_keeps_usage_history_empty() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -707,10 +652,11 @@ mod tests {
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2000,
|
||||
extensions: vec![],
|
||||
segments: vec![Segment::text("hi")],
|
||||
history: vec![annotated(Item::user_message("hi"))],
|
||||
},
|
||||
]);
|
||||
assert!(state.usage_history.is_empty());
|
||||
@@ -771,7 +717,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_invoke_marker_only_mutates_interrupted_state() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 0,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -784,10 +730,11 @@ mod tests {
|
||||
ts: 100,
|
||||
trigger: InvokeKind::UserSend,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 101,
|
||||
extensions: vec![],
|
||||
segments: vec![Segment::text("hi")],
|
||||
history: vec![annotated(Item::user_message("hi"))],
|
||||
},
|
||||
LogEntry::TurnEnd {
|
||||
ts: 200,
|
||||
@@ -806,7 +753,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_paused_turn_abandoned_clears_interrupted_marker() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 0,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -830,7 +777,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_restores_active_run_budget_across_compaction_checkpoint() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 0,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -861,7 +808,7 @@ mod tests {
|
||||
}))
|
||||
.expect("legacy run-completed entry");
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 0,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -924,7 +871,7 @@ mod tests {
|
||||
#[test]
|
||||
fn replay_extension_collects_domain_payload_pairs() {
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -983,9 +930,12 @@ mod tests {
|
||||
#[test]
|
||||
fn user_input_extensions_restore_with_the_same_committed_input() {
|
||||
let segments = vec![Segment::text("Flow instructions"), Segment::text("Ticket")];
|
||||
let entry = LogEntry::UserInput {
|
||||
let entry = LogEntry::AnnotatedUserInput {
|
||||
ts: 9999,
|
||||
segments: segments.clone(),
|
||||
history: vec![annotated(Item::user_message(Segment::flatten_to_text(
|
||||
&segments,
|
||||
)))],
|
||||
extensions: vec![SessionExtension::new(
|
||||
"flow.runtime.v1",
|
||||
serde_json::json!({ "state": "implement", "revision": 0 }),
|
||||
@@ -1000,7 +950,7 @@ mod tests {
|
||||
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
|
||||
/// text while preserving the original segments separately. This covers
|
||||
/// the segments → flatten → Item replay path from the ticket.
|
||||
@@ -1020,16 +970,19 @@ mod tests {
|
||||
path: "src/main.rs".into(),
|
||||
},
|
||||
];
|
||||
let entry = LogEntry::UserInput {
|
||||
let entry = LogEntry::AnnotatedUserInput {
|
||||
ts: 4242,
|
||||
extensions: vec![],
|
||||
segments: segments.clone(),
|
||||
history: vec![annotated(Item::user_message(Segment::flatten_to_text(
|
||||
&segments,
|
||||
)))],
|
||||
};
|
||||
// JSON round-trip preserves the variant byte-for-byte.
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
let parsed: LogEntry = serde_json::from_str(&json).unwrap();
|
||||
let state = collect_state(&[
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//! `kind` instead of parsing text prefixes like `[Notification] …` or
|
||||
//! `[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
|
||||
//! `Event::SystemItem` on the wire.
|
||||
//!
|
||||
|
||||
@@ -20,7 +20,8 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
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 SESSION_FILE: &str = "session.json";
|
||||
const SEGMENTS_DIR: &str = "segments";
|
||||
@@ -47,9 +48,15 @@ impl WorkerSessionStore {
|
||||
Ok(bytes) => {
|
||||
let mut manifest: SessionManifest = serde_json::from_slice(&bytes)?;
|
||||
match manifest.schema_version {
|
||||
SESSION_SCHEMA_VERSION => {}
|
||||
LEGACY_SESSION_SCHEMA_VERSION => {
|
||||
validate_legacy_segment_logs(&root)?;
|
||||
SESSION_SCHEMA_VERSION => {
|
||||
validate_canonical_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;
|
||||
atomic_write_json(&root.join(SESSION_FILE), &manifest)?;
|
||||
}
|
||||
@@ -144,6 +151,41 @@ impl WorkerSessionStore {
|
||||
.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> {
|
||||
let _guard = self
|
||||
.append_lock
|
||||
@@ -183,7 +225,7 @@ impl Store for WorkerSessionStore {
|
||||
entry: &LogEntry,
|
||||
) -> Result<(), StoreError> {
|
||||
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(
|
||||
@@ -286,37 +328,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);
|
||||
if !segments.exists() {
|
||||
return Ok(());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut paths = Vec::new();
|
||||
for entry in fs::read_dir(&segments)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let metadata = fs::symlink_metadata(&path)?;
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
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;
|
||||
}
|
||||
let contents = fs::read_to_string(&path)?;
|
||||
for (line_index, line) in contents.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
serde_json::from_str::<LogEntry>(line).map_err(|error| StoreError::Corrupt {
|
||||
line: line_index + 1,
|
||||
if !name.ends_with(".jsonl") {
|
||||
continue;
|
||||
}
|
||||
if !metadata.file_type().is_file() {
|
||||
return Err(StoreError::Corrupt {
|
||||
line: 0,
|
||||
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()
|
||||
),
|
||||
})?;
|
||||
let mut output = Vec::new();
|
||||
for entry in canonical {
|
||||
serde_json::to_writer(&mut output, &entry)?;
|
||||
output.push(b'\n');
|
||||
}
|
||||
plans.push(MigrationPlan {
|
||||
path,
|
||||
source,
|
||||
output,
|
||||
});
|
||||
}
|
||||
|
||||
// Fence the complete preflight snapshot before starting phase 2. Session
|
||||
// open is the exclusive restore boundary; this additionally fails closed
|
||||
// if an unexpected writer raced the preflight.
|
||||
for plan in &plans {
|
||||
if fs::read(&plan.path)? != plan.source {
|
||||
return Err(StoreError::Corrupt {
|
||||
line: 0,
|
||||
message: format!(
|
||||
"Worker Session segment changed during migration: {}",
|
||||
plan.path.display()
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for plan in plans {
|
||||
atomic_write_bytes(&plan.path, &plan.output)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_canonical_segment_logs(root: &Path) -> Result<(), StoreError> {
|
||||
for (_, path) in segment_log_paths(root)? {
|
||||
let _: Vec<LogEntry> = parse_jsonl(&fs::read(&path)?)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_legacy_jsonl(
|
||||
schema_version: u32,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
bytes: &[u8],
|
||||
) -> Result<Vec<LogEntry>, serde_json::Error> {
|
||||
let text = std::str::from_utf8(bytes).map_err(|error| {
|
||||
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
|
||||
})?;
|
||||
text.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| !line.trim().is_empty())
|
||||
.map(|(line_index, line)| {
|
||||
crate::legacy_session_log::decode_entry(
|
||||
schema_version,
|
||||
line,
|
||||
session_id,
|
||||
segment_id,
|
||||
line_index,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
|
||||
let mut bytes = serde_json::to_vec_pretty(value)?;
|
||||
bytes.push(b'\n');
|
||||
@@ -418,7 +561,21 @@ fn truncate_uncommitted_tail(file: &mut File) -> std::io::Result<u64> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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]
|
||||
fn canonical_layout_and_single_session_invariant() {
|
||||
@@ -445,7 +602,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v1_logs_are_validated_and_promoted_to_v2() {
|
||||
fn schema_v1_logs_are_rewritten_and_promoted_to_v3() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let session_id = new_session_id();
|
||||
let segment_id = new_segment_id();
|
||||
@@ -467,7 +624,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[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 session_id = new_session_id();
|
||||
let manifest = SessionManifest {
|
||||
@@ -492,6 +649,280 @@ mod tests {
|
||||
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]
|
||||
fn reopen_preserves_session_and_segment_ids() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
use agen::EngineResult;
|
||||
use agen::llm_client::types::{Item, RequestConfig};
|
||||
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;
|
||||
|
||||
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 {
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
@@ -25,7 +38,7 @@ fn round_trip_write_and_read() {
|
||||
let segid = new_segment_id();
|
||||
|
||||
let entries = vec![
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: sid,
|
||||
system_prompt: Some("You are helpful.".into()),
|
||||
@@ -34,14 +47,15 @@ fn round_trip_write_and_read() {
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2000,
|
||||
extensions: vec![],
|
||||
segments: vec![protocol::Segment::text("Hello")],
|
||||
history: vec![annotated(Item::user_message("Hello"))],
|
||||
},
|
||||
LogEntry::AssistantItem {
|
||||
LogEntry::AnnotatedAssistantItem {
|
||||
ts: 3000,
|
||||
item: Item::assistant_message("Hi there!").into(),
|
||||
entry: annotated(Item::assistant_message("Hi there!")),
|
||||
},
|
||||
LogEntry::TurnEnd {
|
||||
ts: 3100,
|
||||
@@ -79,14 +93,14 @@ fn create_segment_writes_all_entries() {
|
||||
let sid = new_session_id();
|
||||
let segid = new_segment_id();
|
||||
|
||||
let entries = [LogEntry::SegmentStart {
|
||||
let entries = [LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: sid,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![
|
||||
Item::user_message("seed").into(),
|
||||
Item::assistant_message("ok").into(),
|
||||
annotated(Item::user_message("seed")),
|
||||
annotated(Item::assistant_message("ok")),
|
||||
],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
@@ -205,7 +219,7 @@ fn read_entry_count_matches_append_tally() {
|
||||
let segid = new_segment_id();
|
||||
|
||||
let entries = [
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1000,
|
||||
session_id: sid,
|
||||
system_prompt: None,
|
||||
@@ -214,10 +228,11 @@ fn read_entry_count_matches_append_tally() {
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: 2000,
|
||||
extensions: vec![],
|
||||
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_entry_count(sid, segid).unwrap(), 1);
|
||||
|
||||
let next = LogEntry::UserInput {
|
||||
let next = LogEntry::AnnotatedUserInput {
|
||||
ts: 2,
|
||||
extensions: vec![],
|
||||
segments: vec![protocol::Segment::text("recovered")],
|
||||
history: vec![annotated(Item::user_message("recovered"))],
|
||||
};
|
||||
store.append(sid, segid, &next).unwrap();
|
||||
|
||||
|
||||
@@ -16,6 +16,21 @@ use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state};
|
||||
// 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> {
|
||||
vec![
|
||||
Event::text_block_start(0),
|
||||
@@ -144,6 +159,7 @@ async fn run_and_persist(
|
||||
session_id,
|
||||
segment_id,
|
||||
vec![protocol::Segment::text(input)],
|
||||
annotated(&[Item::user_message(input)]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -154,8 +170,8 @@ async fn run_and_persist(
|
||||
worker.engine = locked.unlock();
|
||||
|
||||
let projected = worker.history();
|
||||
let new_items = &projected[history_before..];
|
||||
session_store::save_delta(store, session_id, segment_id, new_items).unwrap();
|
||||
let new_items = annotated(&projected[history_before..]);
|
||||
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();
|
||||
|
||||
match &result {
|
||||
@@ -219,7 +235,7 @@ async fn session_run_logs_entries() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: &worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -237,7 +253,10 @@ async fn session_run_logs_entries() {
|
||||
);
|
||||
|
||||
// First entry is SegmentStart
|
||||
assert!(matches!(&entries[0], LogEntry::SegmentStart { .. }));
|
||||
assert!(matches!(
|
||||
&entries[0],
|
||||
LogEntry::AnnotatedSegmentStart { .. }
|
||||
));
|
||||
|
||||
// Has a RunCompleted with Finished
|
||||
let has_finished = entries.iter().any(|e| {
|
||||
@@ -264,7 +283,7 @@ async fn session_restore_round_trip() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: &worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -303,7 +322,7 @@ async fn session_run_with_tool_call() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: &worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -314,12 +333,12 @@ async fn session_run_with_tool_call() {
|
||||
|
||||
let has_tool_results = entries
|
||||
.iter()
|
||||
.any(|e| matches!(e, LogEntry::ToolResult { .. }));
|
||||
.any(|e| matches!(e, LogEntry::AnnotatedToolResult { .. }));
|
||||
assert!(has_tool_results, "should have ToolResult entry");
|
||||
|
||||
let has_assistant = entries
|
||||
.iter()
|
||||
.any(|e| matches!(e, LogEntry::AssistantItem { .. }));
|
||||
.any(|e| matches!(e, LogEntry::AnnotatedAssistantItem { .. }));
|
||||
assert!(has_assistant, "should have AssistantItem entry");
|
||||
}
|
||||
|
||||
@@ -338,7 +357,7 @@ async fn session_resume_after_pause() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: &worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -377,7 +396,7 @@ async fn session_fork_creates_new_session() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: &worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -390,7 +409,7 @@ async fn session_fork_creates_new_session() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: &worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -399,7 +418,10 @@ async fn session_fork_creates_new_session() {
|
||||
// Fork should have a SegmentStart with the current history
|
||||
let fork_entries = store.read_all(fork_sid, fork_segid).unwrap();
|
||||
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);
|
||||
assert_eq!(fork_state.session_id, Some(fork_sid));
|
||||
@@ -418,7 +440,7 @@ async fn session_fork_at_truncates_within_session() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: &worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -445,6 +467,23 @@ async fn session_fork_at_truncates_within_session() {
|
||||
.expect("source segment has the matching TurnEnd");
|
||||
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.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.
|
||||
let segs = store.list_segments(sid).unwrap();
|
||||
@@ -463,7 +502,7 @@ async fn session_config_changed_logged() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: &worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -496,7 +535,7 @@ async fn session_auto_forks_on_conflict() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker_a.get_system_prompt(),
|
||||
config: worker_a.request_config(),
|
||||
history: &worker_a.history(),
|
||||
history: annotated(&worker_a.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -505,12 +544,14 @@ async fn session_auto_forks_on_conflict() {
|
||||
let mut entries_written: usize = 1;
|
||||
|
||||
// Simulate another Worker writing to the same segment behind our back.
|
||||
let extra_entry = LogEntry::UserInput {
|
||||
ts: 9999,
|
||||
extensions: vec![],
|
||||
segments: vec![protocol::Segment::text("Interloper")],
|
||||
};
|
||||
store.append(sid, original_segid, &extra_entry).unwrap();
|
||||
session_store::save_user_input(
|
||||
&store,
|
||||
sid,
|
||||
original_segid,
|
||||
vec![protocol::Segment::text("Interloper")],
|
||||
annotated(&[Item::user_message("Interloper")]),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Now the on-disk count exceeds our tally — ensure_head_or_fork should auto-fork.
|
||||
session_store::ensure_head_or_fork(
|
||||
@@ -522,7 +563,7 @@ async fn session_auto_forks_on_conflict() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker_a.get_system_prompt(),
|
||||
config: worker_a.request_config(),
|
||||
history: &worker_a.history(),
|
||||
history: annotated(&worker_a.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -543,7 +584,7 @@ async fn session_auto_forks_on_conflict() {
|
||||
// The new segment records its lineage forward via forked_from; the
|
||||
// source segment is left immutable (no terminal marker written back).
|
||||
match &fork_entries[0] {
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
forked_from: Some(origin),
|
||||
..
|
||||
} => {
|
||||
@@ -563,7 +604,7 @@ async fn session_auto_forks_on_conflict() {
|
||||
);
|
||||
let has_interloper = original_entries
|
||||
.iter()
|
||||
.any(|e| matches!(e, LogEntry::UserInput { .. }));
|
||||
.any(|e| matches!(e, LogEntry::AnnotatedUserInput { .. }));
|
||||
assert!(has_interloper);
|
||||
}
|
||||
|
||||
@@ -581,7 +622,7 @@ async fn nested_past_fork_leaves_ancestors_immutable() {
|
||||
SegmentStartState {
|
||||
system_prompt: worker.get_system_prompt(),
|
||||
config: worker.request_config(),
|
||||
history: &worker.history(),
|
||||
history: annotated(&worker.history()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -618,7 +659,7 @@ async fn nested_past_fork_leaves_ancestors_immutable() {
|
||||
|
||||
// fork2's lineage points at fork1, not the root.
|
||||
match &store.read_all(sid, fork2).unwrap()[0] {
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
forked_from: Some(origin),
|
||||
..
|
||||
} => assert_eq!(origin.segment_id, fork1),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "standalone"
|
||||
description = "In-process standalone Worker host"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
agen.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"] }
|
||||
@@ -0,0 +1,410 @@
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use agen::llm_client::client::LlmClient;
|
||||
use protocol::{Event, Method};
|
||||
use session_store::{
|
||||
CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::broadcast;
|
||||
use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout};
|
||||
use worker::controller::WorkerControllerTransport;
|
||||
use worker::{BootstrappedWorker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext};
|
||||
|
||||
use crate::launch::ResolvedStandaloneLaunch;
|
||||
use crate::store::{
|
||||
StaleLeasePolicy, StandaloneSessionId, StandaloneSessionLease, StandaloneSessionRecord,
|
||||
StandaloneSessionStore, StandaloneShutdownReason, StandaloneStoreError,
|
||||
};
|
||||
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
type StandaloneBackingStore = CombinedStore<FsStore, FsWorkerStore>;
|
||||
|
||||
/// One client-owned top-level Worker and its standalone session 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: StandaloneSessionStore,
|
||||
worker_store: FsWorkerStore,
|
||||
record: StandaloneSessionRecord,
|
||||
lease: Option<StandaloneSessionLease>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
pub enum StandaloneStartupError {
|
||||
#[error("the standalone state store could not be opened or validated")]
|
||||
StateStore,
|
||||
#[error("the standalone session is already active")]
|
||||
SessionActive,
|
||||
#[error("the standalone session lease cannot be observed safely; recovery is rejected")]
|
||||
LeaseLivenessUnknown,
|
||||
#[error("the standalone session working directory is unavailable or changed")]
|
||||
WorkingDirectoryUnavailable,
|
||||
#[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 StandaloneRequestError {
|
||||
#[error("the standalone Worker is no longer accepting requests")]
|
||||
WorkerUnavailable,
|
||||
}
|
||||
|
||||
#[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 session 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(
|
||||
mut launch: ResolvedStandaloneLaunch,
|
||||
model_client: Option<Box<dyn LlmClient>>,
|
||||
) -> Result<Self, StandaloneStartupError> {
|
||||
let store = StandaloneSessionStore::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 id = allocation.id();
|
||||
|
||||
// The standalone session ID is the local identity. A unique internal Worker name avoids
|
||||
// process-global allocation collisions without creating a Runtime/Workspace Worker ID.
|
||||
launch.profile.manifest.worker.name = format!("standalone-{id}");
|
||||
let manifest = launch.profile.manifest.clone();
|
||||
let worker_name = manifest.worker.name.clone();
|
||||
let (backing_store, worker_store) = match backing_store(&store, 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(id);
|
||||
|
||||
let mut bootstrap = WorkerBootstrap::new(
|
||||
manifest.clone(),
|
||||
backing_store,
|
||||
launch.prompt_catalog,
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
WorkerBootstrapLayout::Direct { runtime_base },
|
||||
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, &worker_name) {
|
||||
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, 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,
|
||||
session_id: StandaloneSessionId,
|
||||
) -> Result<Self, StandaloneStartupError> {
|
||||
Self::restore_with_optional_model_client(state_dir, session_id, None).await
|
||||
}
|
||||
|
||||
pub async fn restore_with_model_client<C>(
|
||||
state_dir: PathBuf,
|
||||
session_id: StandaloneSessionId,
|
||||
model_client: C,
|
||||
) -> Result<Self, StandaloneStartupError>
|
||||
where
|
||||
C: LlmClient + 'static,
|
||||
{
|
||||
Self::restore_with_optional_model_client(
|
||||
state_dir,
|
||||
session_id,
|
||||
Some(Box::new(model_client)),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn restore_with_optional_model_client(
|
||||
state_dir: PathBuf,
|
||||
session_id: StandaloneSessionId,
|
||||
model_client: Option<Box<dyn LlmClient>>,
|
||||
) -> Result<Self, StandaloneStartupError> {
|
||||
let store =
|
||||
StandaloneSessionStore::open(state_dir).map_err(classify_store_startup_error)?;
|
||||
let record = store
|
||||
.load(session_id)
|
||||
.map_err(classify_store_startup_error)?;
|
||||
record.cwd.verify().map_err(classify_store_startup_error)?;
|
||||
let lease = store
|
||||
.acquire_lease(session_id, StaleLeasePolicy::Recover)
|
||||
.map_err(classify_store_startup_error)?;
|
||||
let (backing_store, worker_store) = backing_store(&store, session_id)?;
|
||||
let worker_name = record.worker_name.clone();
|
||||
let manifest = record.manifest.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(session_id);
|
||||
|
||||
let mut bootstrap = WorkerBootstrap::new(
|
||||
manifest,
|
||||
backing_store,
|
||||
worker::PromptCatalogSource::builtins_only(),
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
WorkerBootstrapLayout::Direct { runtime_base },
|
||||
WorkerControllerTransport::InProcess,
|
||||
);
|
||||
if let Some(model_client) = model_client {
|
||||
bootstrap = bootstrap.with_model_client(model_client);
|
||||
}
|
||||
let prepared = bootstrap
|
||||
.prepare_restored(&worker_name)
|
||||
.await
|
||||
.map_err(classify_startup_error)?;
|
||||
let started = prepared.start().await.map_err(classify_startup_error)?;
|
||||
let active = match active_pointer(&worker_store, &worker_name) {
|
||||
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: StandaloneSessionStore,
|
||||
worker_store: FsWorkerStore,
|
||||
record: StandaloneSessionRecord,
|
||||
lease: StandaloneSessionLease,
|
||||
) -> 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 session_id(&self) -> StandaloneSessionId {
|
||||
self.record.session_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn record(&self) -> &StandaloneSessionRecord {
|
||||
&self.record
|
||||
}
|
||||
|
||||
pub async fn send(&self, method: Method) -> Result<(), StandaloneRequestError> {
|
||||
self.handle
|
||||
.send(method)
|
||||
.await
|
||||
.map_err(|_| StandaloneRequestError::WorkerUnavailable)
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
|
||||
self.handle.subscribe()
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> Event {
|
||||
self.handle.snapshot_event()
|
||||
}
|
||||
|
||||
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.worker_name) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn backing_store(
|
||||
store: &StandaloneSessionStore,
|
||||
id: StandaloneSessionId,
|
||||
) -> Result<(StandaloneBackingStore, FsWorkerStore), StandaloneStartupError> {
|
||||
let session_store =
|
||||
FsStore::new(store.session_log_dir(id)).map_err(|_| StandaloneStartupError::StateStore)?;
|
||||
let worker_store = FsWorkerStore::new(store.worker_metadata_dir(id))
|
||||
.map_err(|_| StandaloneStartupError::StateStore)?;
|
||||
Ok((
|
||||
CombinedStore::new(session_store, worker_store.clone()),
|
||||
worker_store,
|
||||
))
|
||||
}
|
||||
|
||||
fn active_pointer(
|
||||
worker_store: &FsWorkerStore,
|
||||
worker_name: &str,
|
||||
) -> Result<WorkerActiveSegmentRef, StandaloneStartupError> {
|
||||
worker_store
|
||||
.read_by_name(worker_name)
|
||||
.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::SessionLeased(_) => StandaloneStartupError::SessionActive,
|
||||
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::Provider(_)) => {
|
||||
StandaloneStartupError::ModelProvider
|
||||
}
|
||||
WorkerBootstrapError::Worker(_) => StandaloneStartupError::WorkerConfiguration,
|
||||
WorkerBootstrapError::Controller { source, .. }
|
||||
if source.kind() == std::io::ErrorKind::Other =>
|
||||
{
|
||||
StandaloneStartupError::FeatureComposition
|
||||
}
|
||||
WorkerBootstrapError::Controller { .. } => StandaloneStartupError::Controller,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! 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, StandaloneRequestError, StandaloneShutdownError, StandaloneStartupError,
|
||||
};
|
||||
pub use launch::{ResolvedStandaloneLaunch, StandaloneLaunchConfig, StandaloneLaunchError};
|
||||
pub use store::{
|
||||
StaleLeasePolicy, StandaloneCwdIdentity, StandaloneListScope, StandaloneSessionId,
|
||||
StandaloneSessionRecord, StandaloneSessionStatus, StandaloneSessionStore,
|
||||
StandaloneShutdownReason, StandaloneStoreError,
|
||||
};
|
||||
@@ -0,0 +1,778 @@
|
||||
use std::fmt;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use fs4::fs_std::FileExt;
|
||||
use manifest::WorkerManifest;
|
||||
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 SESSION_DIR: &str = "session";
|
||||
const WORKER_DIR: &str = "worker";
|
||||
const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct StandaloneSessionId(Uuid);
|
||||
|
||||
impl StandaloneSessionId {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::now_v7())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn short(self) -> String {
|
||||
let simple = self.0.simple().to_string();
|
||||
simple[simple.len() - 12..].to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StandaloneSessionId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for StandaloneSessionId {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
self.0.fmt(formatter)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for StandaloneSessionId {
|
||||
type Err = uuid::Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
Uuid::parse_str(value).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
#[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 StandaloneSessionStatus {
|
||||
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 StandaloneSessionRecord {
|
||||
pub schema_version: u32,
|
||||
pub revision: u64,
|
||||
pub session_id: StandaloneSessionId,
|
||||
pub worker_name: 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: StandaloneSessionStatus,
|
||||
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 StandaloneSessionStore {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl StandaloneSessionStore {
|
||||
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<StandaloneSessionAllocation, StandaloneStoreError> {
|
||||
let id = StandaloneSessionId::new();
|
||||
let cwd = StandaloneCwdIdentity::capture(cwd)?;
|
||||
let dir = self.session_dir(id);
|
||||
fs::create_dir(&dir).map_err(StandaloneStoreError::Io)?;
|
||||
fs::create_dir(dir.join(SESSION_DIR)).map_err(StandaloneStoreError::Io)?;
|
||||
fs::create_dir(dir.join(WORKER_DIR)).map_err(StandaloneStoreError::Io)?;
|
||||
let lease = self.acquire_lease(id, policy)?;
|
||||
Ok(StandaloneSessionAllocation { id, cwd, lease })
|
||||
}
|
||||
|
||||
pub fn commit_created(
|
||||
&self,
|
||||
allocation: &StandaloneSessionAllocation,
|
||||
manifest: WorkerManifest,
|
||||
active_session_id: SessionId,
|
||||
active_segment_id: Option<SegmentId>,
|
||||
) -> Result<StandaloneSessionRecord, StandaloneStoreError> {
|
||||
let now = now_unix_ms()?;
|
||||
let record = StandaloneSessionRecord {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
revision: 1,
|
||||
session_id: allocation.id,
|
||||
worker_name: manifest.worker.name.clone(),
|
||||
cwd: allocation.cwd.clone(),
|
||||
manifest,
|
||||
active_session_id,
|
||||
active_segment_id,
|
||||
status: StandaloneSessionStatus::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: StandaloneSessionId,
|
||||
) -> Result<StandaloneSessionRecord, StandaloneStoreError> {
|
||||
let dir = self.session_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::SessionNotFound(id)
|
||||
} else {
|
||||
StandaloneStoreError::Io(error)
|
||||
}
|
||||
})?;
|
||||
let record: StandaloneSessionRecord = 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.session_id != id {
|
||||
return Err(StandaloneStoreError::InvalidRecord(id));
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub fn list(
|
||||
&self,
|
||||
cwd: impl AsRef<Path>,
|
||||
scope: StandaloneListScope,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StandaloneSessionRecord>, 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
|
||||
.session_id
|
||||
.to_string()
|
||||
.cmp(&left.session_id.to_string())
|
||||
})
|
||||
});
|
||||
records.truncate(limit);
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
pub fn acquire_lease(
|
||||
&self,
|
||||
id: StandaloneSessionId,
|
||||
policy: StaleLeasePolicy,
|
||||
) -> Result<StandaloneSessionLease, StandaloneStoreError> {
|
||||
let dir = self.session_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(StandaloneSessionLease {
|
||||
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::SessionLeased(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: &StandaloneSessionRecord,
|
||||
active_session_id: SessionId,
|
||||
active_segment_id: Option<SegmentId>,
|
||||
) -> Result<StandaloneSessionRecord, 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 = StandaloneSessionStatus::Active;
|
||||
next.shutdown_reason = None;
|
||||
self.commit_record(Some(record.revision), &next)?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub fn mark_stopped(
|
||||
&self,
|
||||
record: &StandaloneSessionRecord,
|
||||
active_session_id: SessionId,
|
||||
active_segment_id: Option<SegmentId>,
|
||||
reason: StandaloneShutdownReason,
|
||||
) -> Result<StandaloneSessionRecord, 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 = StandaloneSessionStatus::Stopped;
|
||||
next.shutdown_reason = Some(reason);
|
||||
self.commit_record(Some(record.revision), &next)?;
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
pub fn delete(&self, id: StandaloneSessionId) -> Result<(), StandaloneStoreError> {
|
||||
let record = self.load(id)?;
|
||||
if record.status != StandaloneSessionStatus::Stopped {
|
||||
return Err(StandaloneStoreError::DeleteActive(id));
|
||||
}
|
||||
let session_dir = self.session_dir(id);
|
||||
let _guard = LeaseMutationGuard::acquire(&session_dir)?;
|
||||
let lease_path = session_dir.join(LEASE_FILE);
|
||||
if lease_path.exists() {
|
||||
let lease = read_lease(&lease_path, id)?;
|
||||
return Err(match lease.liveness() {
|
||||
LeaseLiveness::Live => StandaloneStoreError::SessionLeased(id),
|
||||
LeaseLiveness::Stale => StandaloneStoreError::StaleLease(id),
|
||||
LeaseLiveness::Unknown => StandaloneStoreError::LeaseLivenessUnknown(id),
|
||||
});
|
||||
}
|
||||
fs::remove_dir_all(self.session_dir(id)).map_err(StandaloneStoreError::Io)?;
|
||||
sync_directory(&self.root)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn session_log_dir(&self, id: StandaloneSessionId) -> PathBuf {
|
||||
self.session_dir(id).join(SESSION_DIR)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn worker_metadata_dir(&self, id: StandaloneSessionId) -> PathBuf {
|
||||
self.session_dir(id).join(WORKER_DIR)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn runtime_dir(&self, id: StandaloneSessionId) -> PathBuf {
|
||||
self.session_dir(id).join("runtime")
|
||||
}
|
||||
|
||||
pub(crate) fn abandon_allocation(
|
||||
&self,
|
||||
allocation: StandaloneSessionAllocation,
|
||||
) -> Result<(), StandaloneStoreError> {
|
||||
let id = allocation.id;
|
||||
allocation.lease.release()?;
|
||||
fs::remove_dir_all(self.session_dir(id)).map_err(StandaloneStoreError::Io)?;
|
||||
sync_directory(&self.root)
|
||||
}
|
||||
|
||||
fn commit_record(
|
||||
&self,
|
||||
expected_revision: Option<u64>,
|
||||
next: &StandaloneSessionRecord,
|
||||
) -> Result<(), StandaloneStoreError> {
|
||||
let dir = self.session_dir(next.session_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.session_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.session_id)?;
|
||||
if current.revision != expected {
|
||||
let _ = fs::remove_file(&marker);
|
||||
return Err(StandaloneStoreError::RevisionConflict {
|
||||
id: next.session_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: StandaloneSessionId,
|
||||
) -> Result<StandaloneSessionRecord, StandaloneStoreError> {
|
||||
let bytes =
|
||||
fs::read(self.session_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
|
||||
}
|
||||
|
||||
fn session_dir(&self, id: StandaloneSessionId) -> PathBuf {
|
||||
self.root.join(id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StandaloneSessionAllocation {
|
||||
id: StandaloneSessionId,
|
||||
cwd: StandaloneCwdIdentity,
|
||||
lease: StandaloneSessionLease,
|
||||
}
|
||||
|
||||
impl StandaloneSessionAllocation {
|
||||
#[must_use]
|
||||
pub fn id(&self) -> StandaloneSessionId {
|
||||
self.id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn cwd(&self) -> &StandaloneCwdIdentity {
|
||||
&self.cwd
|
||||
}
|
||||
|
||||
pub fn into_lease(self) -> StandaloneSessionLease {
|
||||
self.lease
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StandaloneSessionLease {
|
||||
path: PathBuf,
|
||||
lease_id: Uuid,
|
||||
released: bool,
|
||||
}
|
||||
|
||||
impl StandaloneSessionLease {
|
||||
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 StandaloneSessionLease {
|
||||
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: StandaloneSessionId) -> 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 session")]
|
||||
CwdIdentityMismatch,
|
||||
#[error("standalone session {0} was not found")]
|
||||
SessionNotFound(StandaloneSessionId),
|
||||
#[error("standalone session {0} has an incomplete metadata commit")]
|
||||
IncompleteCommit(StandaloneSessionId),
|
||||
#[error("standalone session {0} has invalid metadata")]
|
||||
InvalidRecord(StandaloneSessionId),
|
||||
#[error("standalone session {id} metadata is corrupt")]
|
||||
CorruptRecord {
|
||||
id: StandaloneSessionId,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("standalone session {id} lease is corrupt")]
|
||||
CorruptLease {
|
||||
id: StandaloneSessionId,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("standalone session {id} uses schema {found}, newer than supported schema {supported}")]
|
||||
NewerSchema {
|
||||
id: StandaloneSessionId,
|
||||
found: u32,
|
||||
supported: u32,
|
||||
},
|
||||
#[error("standalone session {0} is already active")]
|
||||
SessionLeased(StandaloneSessionId),
|
||||
#[error("standalone session {0} lease liveness cannot be proven; recovery is rejected")]
|
||||
LeaseLivenessUnknown(StandaloneSessionId),
|
||||
#[error("standalone session {0} has a stale lease; explicit recovery is required")]
|
||||
StaleLease(StandaloneSessionId),
|
||||
#[error("standalone session lease ownership changed")]
|
||||
LeaseOwnershipLost,
|
||||
#[error("standalone session {0} must be stopped before deletion")]
|
||||
DeleteActive(StandaloneSessionId),
|
||||
#[error(
|
||||
"standalone session {id} metadata revision changed (expected {expected}, found {found})"
|
||||
)]
|
||||
RevisionConflict {
|
||||
id: StandaloneSessionId,
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
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 futures::{Stream, stream};
|
||||
use protocol::{Event, Method};
|
||||
use standalone::{
|
||||
StaleLeasePolicy, StandaloneHost, StandaloneLaunchConfig, StandaloneListScope,
|
||||
StandaloneSessionStatus, StandaloneSessionStore, StandaloneStartupError, StandaloneStoreError,
|
||||
};
|
||||
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");
|
||||
let mut events = host.subscribe();
|
||||
|
||||
host.send(Method::run_text("read the probe"))
|
||||
.await
|
||||
.expect("submit input");
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
let mut saw_text = false;
|
||||
let mut saw_tool_result = false;
|
||||
loop {
|
||||
match events.recv().await.expect("worker event") {
|
||||
Event::TextDelta { text } if text.contains("standalone response") => {
|
||||
saw_text = true;
|
||||
}
|
||||
Event::ToolResult { .. } => {
|
||||
saw_tool_result = true;
|
||||
}
|
||||
Event::RunEnd { .. } => {
|
||||
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 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-sessions");
|
||||
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 session_id = host.session_id();
|
||||
let mut events = host.subscribe();
|
||||
host.send(Method::run_text("first request")).await?;
|
||||
wait_for_run_end(&mut events).await?;
|
||||
host.send(Method::Notify {
|
||||
message: "persisted notification".to_string(),
|
||||
auto_run: true,
|
||||
})
|
||||
.await?;
|
||||
wait_for_run_end(&mut events).await?;
|
||||
host.shutdown().await?;
|
||||
|
||||
let store = StandaloneSessionStore::open(&state_dir)?;
|
||||
let current = store.list(&cwd, StandaloneListScope::CurrentCwd, 100)?;
|
||||
assert_eq!(current.len(), 1);
|
||||
assert_eq!(current[0].session_id, session_id);
|
||||
assert_eq!(current[0].status, StandaloneSessionStatus::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(), session_id, second_client)
|
||||
.await?;
|
||||
let snapshot = format!("{:?}", host.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}");
|
||||
|
||||
let mut events = host.subscribe();
|
||||
host.send(Method::run_text("continue after restore"))
|
||||
.await?;
|
||||
wait_for_run_end(&mut events).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(session_id)?;
|
||||
assert!(cwd.exists(), "deleting session state must not mutate cwd");
|
||||
assert!(matches!(
|
||||
store.load(session_id),
|
||||
Err(StandaloneStoreError::SessionNotFound(_))
|
||||
));
|
||||
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 session_id = host.session_id();
|
||||
let store = StandaloneSessionStore::open(&state_dir)?;
|
||||
assert!(matches!(
|
||||
store.acquire_lease(session_id, StaleLeasePolicy::Recover),
|
||||
Err(StandaloneStoreError::SessionLeased(id)) if id == session_id
|
||||
));
|
||||
let restore = StandaloneHost::restore_with_model_client(
|
||||
state_dir.clone(),
|
||||
session_id,
|
||||
ScriptedClient::new(Vec::new()),
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
restore,
|
||||
Err(StandaloneStartupError::SessionActive)
|
||||
));
|
||||
host.shutdown().await?;
|
||||
|
||||
std::fs::rename(&cwd, &moved)?;
|
||||
let restore = StandaloneHost::restore_with_model_client(
|
||||
state_dir,
|
||||
session_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 session_id = host.session_id();
|
||||
host.shutdown().await?;
|
||||
let store = StandaloneSessionStore::open(&state_dir)?;
|
||||
assert!(matches!(
|
||||
store.load(session_id)?.manifest.profile,
|
||||
Some(manifest::ProfileManifestSnapshot {
|
||||
source: manifest::ProfileSource::Registry {
|
||||
source: manifest::ProfileRegistrySource::User,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
let session_dir = state_dir.join(session_id.to_string());
|
||||
std::fs::write(
|
||||
session_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,
|
||||
session_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 session_id = host.session_id();
|
||||
host.shutdown().await?;
|
||||
let session_dir = state_dir.join(session_id.to_string());
|
||||
std::fs::write(
|
||||
session_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 = StandaloneSessionStore::open(&state_dir)?;
|
||||
assert!(matches!(
|
||||
store.acquire_lease(session_id, StaleLeasePolicy::Recover),
|
||||
Err(StandaloneStoreError::LeaseLivenessUnknown(id)) if id == session_id
|
||||
));
|
||||
let restore = StandaloneHost::restore_with_model_client(
|
||||
state_dir,
|
||||
session_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 session_id = host.session_id();
|
||||
host.shutdown().await?;
|
||||
let store = StandaloneSessionStore::open(&state_dir)?;
|
||||
let session_dir = state_dir.join(session_id.to_string());
|
||||
std::fs::write(session_dir.join("commit.pending"), b"interrupted\n")?;
|
||||
assert!(matches!(
|
||||
store.load(session_id),
|
||||
Err(StandaloneStoreError::IncompleteCommit(id)) if id == session_id
|
||||
));
|
||||
std::fs::remove_file(session_dir.join("commit.pending"))?;
|
||||
let record_path = session_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(session_id),
|
||||
Err(StandaloneStoreError::NewerSchema { id, .. }) if id == session_id
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_run_end(events: &mut tokio::sync::broadcast::Receiver<Event>) -> TestResult {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if matches!(events.recv().await, Ok(Event::RunEnd { .. })) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -22,7 +22,7 @@ enum OutputMode {
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct GrepParams {
|
||||
pattern: String,
|
||||
/// Logical Workdir-relative path to search. Defaults to the Workdir root.
|
||||
/// Logical Workdir-relative file or directory to search. Defaults to the Workdir root.
|
||||
#[serde(default)]
|
||||
path: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -129,7 +129,7 @@ pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(GrepParams);
|
||||
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"));
|
||||
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
|
||||
session: session.clone(),
|
||||
|
||||
@@ -10,11 +10,13 @@ e2e-test = []
|
||||
|
||||
[dependencies]
|
||||
client = { workspace = true }
|
||||
standalone = { workspace = true }
|
||||
thiserror.workspace = true
|
||||
protocol = { workspace = true }
|
||||
ratatui = { version = "0.30.0", features = ["scrolling-regions"] }
|
||||
base64 = "0.22.1"
|
||||
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 }
|
||||
unicode-width = "0.2.2"
|
||||
uuid = { workspace = true }
|
||||
@@ -22,10 +24,8 @@ toml = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
secrets = { workspace = true }
|
||||
session-store = { workspace = true }
|
||||
fs4 = { workspace = true }
|
||||
ticket = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
worker = { path = "../worker" }
|
||||
pulldown-cmark = { version = "0.13.3", default-features = false }
|
||||
agen.workspace = true
|
||||
|
||||
|
||||
+173
-192
@@ -765,7 +765,7 @@ impl App {
|
||||
|
||||
fn method_for_run(&mut self, segments: Vec<Segment>) -> Method {
|
||||
// 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,
|
||||
// while remembering enough local state to undo the visible submit if
|
||||
// the accepted run produced no assistant output and was rolled back.
|
||||
@@ -1098,10 +1098,9 @@ impl App {
|
||||
self.blocks.push(Block::UserMessage { segments });
|
||||
self.assistant_streaming = false;
|
||||
}
|
||||
Event::SegmentRotated { entry } => {
|
||||
Event::SegmentRotated { session } => {
|
||||
let retained_run_errors = self.run_error_messages.clone();
|
||||
self.reset_for_rotation();
|
||||
self.apply_log_entry_raw(&entry);
|
||||
self.restore_session(&session, self.greeting.clone());
|
||||
for message in retained_run_errors {
|
||||
self.blocks.push(Block::Alert {
|
||||
level: AlertLevel::Error,
|
||||
@@ -1408,14 +1407,14 @@ impl App {
|
||||
self.latest_memory_worker_event = Some(event.message);
|
||||
}
|
||||
Event::Snapshot {
|
||||
entries,
|
||||
session,
|
||||
greeting,
|
||||
status,
|
||||
in_flight,
|
||||
internal_workers,
|
||||
} => {
|
||||
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.set_worker_status(status);
|
||||
}
|
||||
@@ -1455,11 +1454,11 @@ impl App {
|
||||
}
|
||||
}
|
||||
Event::RewindApplied {
|
||||
entries,
|
||||
session,
|
||||
input,
|
||||
summary,
|
||||
} => {
|
||||
self.restore_rewind_snapshot(&entries);
|
||||
self.restore_rewind_snapshot(&session);
|
||||
self.rewind_refresh_fence = true;
|
||||
let restored_composer = if self.input.is_empty() {
|
||||
self.input.replace_with_segments(&input);
|
||||
@@ -2173,7 +2172,7 @@ impl App {
|
||||
) -> InternalWorkerView {
|
||||
let mut app = App::new(snapshot.worker.name.clone());
|
||||
app.mode = mode;
|
||||
app.restore_entries(&snapshot.entries, None);
|
||||
app.restore_session(&snapshot.session, None);
|
||||
app.apply_in_flight_snapshot(snapshot.in_flight);
|
||||
app.set_worker_status(snapshot.status);
|
||||
if let Some(error) = snapshot.error {
|
||||
@@ -2254,14 +2253,14 @@ impl App {
|
||||
|
||||
fn restore_snapshot(
|
||||
&mut self,
|
||||
entries: &[serde_json::Value],
|
||||
session: &protocol::SessionSnapshot,
|
||||
greeting: protocol::Greeting,
|
||||
in_flight: InFlightSnapshot,
|
||||
) {
|
||||
self.greeting = Some(greeting.clone());
|
||||
self.context_window = greeting.context_window;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2270,7 +2269,7 @@ impl App {
|
||||
/// session tail; always clear/replay from it even if this TUI instance has
|
||||
/// somehow lost connect-time greeting metadata. Skipping the restore in
|
||||
/// 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(|| {
|
||||
self.blocks.iter().find_map(|b| match b {
|
||||
Block::Greeting(g) => Some(g.clone()),
|
||||
@@ -2283,7 +2282,7 @@ impl App {
|
||||
self.session_context_tokens = greeting.context_tokens;
|
||||
}
|
||||
let missing_greeting = greeting.is_none();
|
||||
self.restore_entries(entries, greeting);
|
||||
self.restore_session(session, greeting);
|
||||
if missing_greeting {
|
||||
self.blocks.push(Block::Alert {
|
||||
level: AlertLevel::Warn,
|
||||
@@ -2293,9 +2292,9 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_entries(
|
||||
fn restore_session(
|
||||
&mut self,
|
||||
entries: &[serde_json::Value],
|
||||
session: &protocol::SessionSnapshot,
|
||||
greeting: Option<protocol::Greeting>,
|
||||
) {
|
||||
self.run_error_messages.clear();
|
||||
@@ -2309,137 +2308,90 @@ impl App {
|
||||
}
|
||||
self.assistant_streaming = false;
|
||||
|
||||
for entry in entries {
|
||||
self.apply_log_entry_raw(entry);
|
||||
for entry in &session.entries {
|
||||
use protocol::{SessionContentPart, SessionMessageRole, SessionSnapshotEntryData};
|
||||
match &entry.data {
|
||||
SessionSnapshotEntryData::UserInput { segments } => {
|
||||
self.turn_index += 1;
|
||||
self.blocks.push(Block::TurnHeader {
|
||||
turn: self.turn_index,
|
||||
});
|
||||
if !segments.is_empty() {
|
||||
self.blocks.push(Block::UserMessage {
|
||||
segments: segments.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
SessionSnapshotEntryData::Message { role, content } => {
|
||||
let role = match role {
|
||||
SessionMessageRole::User => agen::Role::User,
|
||||
SessionMessageRole::Assistant => agen::Role::Assistant,
|
||||
};
|
||||
let item = agen::Item::Message {
|
||||
id: None,
|
||||
role,
|
||||
content: content
|
||||
.iter()
|
||||
.map(|part| match part {
|
||||
SessionContentPart::Text { text } => {
|
||||
agen::ContentPart::Text { text: text.clone() }
|
||||
}
|
||||
SessionContentPart::Refusal { refusal } => {
|
||||
agen::ContentPart::Refusal {
|
||||
refusal: refusal.clone(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
status: None,
|
||||
};
|
||||
let value = serde_json::to_value(item).expect("Item is Serialize");
|
||||
self.push_history_item(&value);
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
/// 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.blocks.push(Block::TurnHeader {
|
||||
turn: self.turn_index,
|
||||
});
|
||||
if !segments.is_empty() {
|
||||
self.blocks.push(Block::UserMessage { segments });
|
||||
}
|
||||
}
|
||||
session_store::LogEntry::AssistantItem { item, .. }
|
||||
| session_store::LogEntry::ToolResult { item, .. } => {
|
||||
let it: agen::Item = item.into();
|
||||
let item_value = serde_json::to_value(&it).expect("Item is Serialize");
|
||||
self.push_history_item(&item_value);
|
||||
}
|
||||
session_store::LogEntry::SystemItem { item, .. } => {
|
||||
let value = serde_json::to_value(&item).expect("SystemItem is Serialize");
|
||||
self.apply_system_item(&value);
|
||||
}
|
||||
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);
|
||||
}
|
||||
// Non-history-bearing variants don't affect the block view.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch one `SystemItem` JSON value into the appropriate block.
|
||||
///
|
||||
/// Kind-based routing replaces the old free-text `[Notification]` /
|
||||
/// `[File: …]` parsing path: each kind maps directly to a typed
|
||||
/// 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) {
|
||||
let Ok(item) = serde_json::from_value::<session_store::SystemItem>(value.clone()) else {
|
||||
// Unknown / forward-compat shape: fall back to rendering the
|
||||
@@ -2542,6 +2494,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 {
|
||||
item["content"]
|
||||
.as_array()
|
||||
@@ -2685,7 +2646,7 @@ mod rewind_refresh_tests {
|
||||
});
|
||||
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
input: vec![Segment::text("selected rewind input")],
|
||||
summary: summary(3),
|
||||
});
|
||||
@@ -2704,7 +2665,7 @@ mod rewind_refresh_tests {
|
||||
});
|
||||
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
input: vec![Segment::text("rewound input")],
|
||||
summary: summary(1),
|
||||
});
|
||||
@@ -2747,7 +2708,7 @@ mod rewind_refresh_tests {
|
||||
});
|
||||
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
input: vec![Segment::text("rewound input")],
|
||||
summary: summary(2),
|
||||
});
|
||||
@@ -2976,6 +2937,17 @@ mod composer_history_persistence_tests {
|
||||
mod completion_flow_tests {
|
||||
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]
|
||||
fn typing_at_creates_completion_state_and_emits_query() {
|
||||
let mut app = App::new("test".into());
|
||||
@@ -3278,7 +3250,7 @@ mod completion_flow_tests {
|
||||
#[test]
|
||||
fn committed_user_message_survives_fresh_segment_rotation() {
|
||||
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(),
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -3289,7 +3261,9 @@ mod completion_flow_tests {
|
||||
};
|
||||
|
||||
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 {
|
||||
segments: vec![Segment::text("first persisted message")],
|
||||
@@ -3533,23 +3507,23 @@ mod completion_flow_tests {
|
||||
}
|
||||
|
||||
#[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 session_start = session_store::LogEntry::SegmentStart {
|
||||
let session_start = session_store::LogEntry::AnnotatedSegmentStart {
|
||||
ts: 1,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
config: Default::default(),
|
||||
history: vec![session_store::LoggedItem::from(
|
||||
&agen::Item::system_message("[File: src/main.rs]\nfn main() {}"),
|
||||
)],
|
||||
history: vec![annotated(agen::Item::system_message(
|
||||
"[File: src/main.rs]\nfn main() {}",
|
||||
))],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
};
|
||||
let session_start_value = serde_json::to_value(&session_start).unwrap();
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
entries: vec![session_start_value],
|
||||
session: public_session(vec![session_start_value]),
|
||||
status: WorkerStatus::Running,
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
@@ -3557,10 +3531,8 @@ mod completion_flow_tests {
|
||||
|
||||
assert!(matches!(app.worker_status, WorkerStatus::Running));
|
||||
assert!(app.running);
|
||||
assert!(matches!(
|
||||
app.blocks.get(1),
|
||||
Some(Block::SystemMessage { text }) if text == "[File: src/main.rs]\nfn main() {}"
|
||||
));
|
||||
assert_eq!(app.blocks.len(), 1);
|
||||
assert!(matches!(app.blocks.first(), Some(Block::Greeting(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3595,7 +3567,7 @@ mod completion_flow_tests {
|
||||
};
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
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,
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
@@ -3623,7 +3595,7 @@ mod completion_flow_tests {
|
||||
code: ErrorCode::ProviderError,
|
||||
message: "provider unavailable".into(),
|
||||
});
|
||||
let segment_start = session_store::LogEntry::SegmentStart {
|
||||
let segment_start = session_store::LogEntry::AnnotatedSegmentStart {
|
||||
ts: 5,
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -3633,7 +3605,7 @@ mod completion_flow_tests {
|
||||
compacted_from: None,
|
||||
};
|
||||
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
|
||||
@@ -3656,7 +3628,9 @@ mod completion_flow_tests {
|
||||
let mut app = App::new("test".into());
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
status: WorkerStatus::Running,
|
||||
in_flight: InFlightSnapshot {
|
||||
blocks: vec![
|
||||
@@ -3762,7 +3736,9 @@ mod completion_flow_tests {
|
||||
},
|
||||
revision,
|
||||
status: WorkerStatus::Idle,
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
in_flight: protocol::InFlightSnapshot::default(),
|
||||
error: None,
|
||||
internal_workers: Vec::new(),
|
||||
@@ -3977,7 +3953,9 @@ mod completion_flow_tests {
|
||||
assert_eq!(app.selected_worker_view().worker_name, "parent");
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
status: WorkerStatus::Idle,
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
@@ -4026,7 +4004,9 @@ mod completion_flow_tests {
|
||||
});
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
status: WorkerStatus::Idle,
|
||||
in_flight: Default::default(),
|
||||
internal_workers: vec![InternalWorkerSnapshot {
|
||||
@@ -4037,7 +4017,9 @@ mod completion_flow_tests {
|
||||
kind: protocol::InternalWorkerKind::SubWorker,
|
||||
},
|
||||
revision: 4,
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
status: WorkerStatus::Running,
|
||||
error: None,
|
||||
in_flight: Default::default(),
|
||||
@@ -4193,7 +4175,9 @@ mod completion_flow_tests {
|
||||
greeting.context_tokens = 45_000;
|
||||
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting,
|
||||
status: WorkerStatus::Idle,
|
||||
in_flight: Default::default(),
|
||||
@@ -4363,40 +4347,37 @@ mod completion_flow_tests {
|
||||
});
|
||||
|
||||
let assistant_item_entries = vec![
|
||||
serde_json::json!({
|
||||
"kind": "assistant_item",
|
||||
"ts": 1,
|
||||
"item": {
|
||||
"kind": "tool_call",
|
||||
"call_id": "c1",
|
||||
"name": "TaskCreate",
|
||||
"arguments": r#"{"subject":"a","description":"A"}"#,
|
||||
},
|
||||
}),
|
||||
serde_json::json!({
|
||||
"kind": "assistant_item",
|
||||
"ts": 2,
|
||||
"item": {
|
||||
"kind": "tool_call",
|
||||
"call_id": "c2",
|
||||
"name": "TaskCreate",
|
||||
"arguments": r#"{"subject":"b","description":"B"}"#,
|
||||
},
|
||||
}),
|
||||
serde_json::json!({
|
||||
"kind": "assistant_item",
|
||||
"ts": 3,
|
||||
"item": {
|
||||
"kind": "tool_call",
|
||||
"call_id": "u1",
|
||||
"name": "TaskUpdate",
|
||||
"arguments": r#"{"taskid":2,"status":"inprogress"}"#,
|
||||
},
|
||||
}),
|
||||
serde_json::to_value(session_store::LogEntry::AnnotatedAssistantItem {
|
||||
ts: 1,
|
||||
entry: annotated(agen::Item::tool_call(
|
||||
"c1",
|
||||
"TaskCreate",
|
||||
r#"{"subject":"a","description":"A"}"#,
|
||||
)),
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::to_value(session_store::LogEntry::AnnotatedAssistantItem {
|
||||
ts: 2,
|
||||
entry: annotated(agen::Item::tool_call(
|
||||
"c2",
|
||||
"TaskCreate",
|
||||
r#"{"subject":"b","description":"B"}"#,
|
||||
)),
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::to_value(session_store::LogEntry::AnnotatedAssistantItem {
|
||||
ts: 3,
|
||||
entry: annotated(agen::Item::tool_call(
|
||||
"u1",
|
||||
"TaskUpdate",
|
||||
r#"{"taskid":2,"status":"inprogress"}"#,
|
||||
)),
|
||||
})
|
||||
.unwrap(),
|
||||
];
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
entries: assistant_item_entries,
|
||||
session: public_session(assistant_item_entries),
|
||||
status: WorkerStatus::Running,
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
|
||||
+163
-328
@@ -1,8 +1,7 @@
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
@@ -21,26 +20,18 @@ use protocol::{Event, Method, WorkerStatus};
|
||||
use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use session_store::SegmentId;
|
||||
use tokio::sync::mpsc;
|
||||
use standalone::{StandaloneHost, StandaloneLaunchConfig};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use client::{BackendRuntimeClient, BackendRuntimeTarget, WorkerClient, WorkerRuntimeCommand};
|
||||
use client::{BackendRuntimeClient, BackendRuntimeTarget, StandaloneSessionResumeIntent};
|
||||
|
||||
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
||||
use crate::composer_keys::{ComposerEditAction, composer_edit_action};
|
||||
use crate::picker::PickerOutcome;
|
||||
use crate::spawn::{SpawnOutcome, SpawnReady};
|
||||
use crate::{picker, spawn, ui};
|
||||
use crate::ui;
|
||||
|
||||
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,
|
||||
/// releases, and wheel events without drag-capture modes (`?1002h`/`?1003h`)
|
||||
/// so terminal-native drag selection remains available during startup.
|
||||
@@ -128,75 +119,161 @@ fn copy_selection_to_terminal(app: &mut App) -> bool {
|
||||
copy_selection_to_writer(app, &mut stdout)
|
||||
}
|
||||
|
||||
fn resolve_socket(worker_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
||||
if let Some(p) = override_path {
|
||||
return p;
|
||||
}
|
||||
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(
|
||||
worker_name: String,
|
||||
socket_override: Option<PathBuf>,
|
||||
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 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),
|
||||
Standalone {
|
||||
host: Option<StandaloneHost>,
|
||||
events: broadcast::Receiver<Event>,
|
||||
initial_snapshot: Option<Event>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ConsoleConnection {
|
||||
fn standalone(host: StandaloneHost) -> Self {
|
||||
let events = host.subscribe();
|
||||
let initial_snapshot = Some(host.snapshot());
|
||||
Self::Standalone {
|
||||
host: Some(host),
|
||||
events,
|
||||
initial_snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
fn try_next_event(&mut self) -> Option<Event> {
|
||||
match self {
|
||||
Self::LegacySocket(client) => client.try_next_event(),
|
||||
Self::BackendRuntime(client) => client.try_next_event(),
|
||||
Self::Standalone {
|
||||
events,
|
||||
initial_snapshot,
|
||||
..
|
||||
} => initial_snapshot.take().or_else(|| events.try_recv().ok()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn next_event(&mut self) -> Option<Event> {
|
||||
match self {
|
||||
Self::LegacySocket(client) => client.next_event().await,
|
||||
Self::BackendRuntime(client) => client.next_event().await,
|
||||
Self::Standalone { host, events, .. } => loop {
|
||||
match events.recv().await {
|
||||
Ok(event) => break Some(event),
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
let Some(host) = host.as_ref() else {
|
||||
break None;
|
||||
};
|
||||
break Some(host.snapshot());
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break None,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match self {
|
||||
Self::LegacySocket(client) => Ok(client.send(method).await?),
|
||||
Self::BackendRuntime(client) => Ok(client.send(method).await?),
|
||||
Self::Standalone { host, .. } => {
|
||||
let host = host.as_ref().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::BrokenPipe,
|
||||
"Standalone Worker has already shut down",
|
||||
)
|
||||
})?;
|
||||
Ok(host.send(method.clone()).await?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Self::Standalone { host, .. } = self
|
||||
&& let Some(host) = 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: StandaloneSessionResumeIntent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let session_id = intent.session_id.parse().map_err(|error| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("Invalid standalone session ID: {error}"),
|
||||
)
|
||||
})?;
|
||||
let host = StandaloneHost::restore(intent.state_dir, session_id)
|
||||
.await
|
||||
.map_err(|error| io::Error::other(format!("Standalone restore failed: {error}")))?;
|
||||
let worker_label = format!("standalone-{}", session_id.short());
|
||||
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 mut connection = ConsoleConnection::standalone(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(
|
||||
@@ -208,201 +285,12 @@ pub(crate) async fn run_backend_runtime(
|
||||
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);
|
||||
app.connected = true;
|
||||
let result = run_loop(
|
||||
&mut terminal,
|
||||
&mut app,
|
||||
ConsoleConnection::BackendRuntime(client),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let mut connection = ConsoleConnection::BackendRuntime(client);
|
||||
let result = run_loop(&mut terminal, &mut app, &mut connection).await;
|
||||
let _ = leave_fullscreen(&mut terminal);
|
||||
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(®istry_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>> {
|
||||
let mut stdout = io::stdout();
|
||||
// Enable button-event tracking so the transcript can own drag selection;
|
||||
@@ -421,19 +309,6 @@ pub(crate) fn enter_dashboard_fullscreen() -> Result<ConsoleTerminal, Box<dyn st
|
||||
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<()> {
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
@@ -446,40 +321,6 @@ pub(crate) fn leave_dashboard_fullscreen(terminal: &mut ConsoleTerminal) -> io::
|
||||
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>;
|
||||
|
||||
const TERMINAL_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
@@ -547,7 +388,9 @@ async fn run_e2e_rewind_fixture(
|
||||
let mut app = App::new_with_persistent_input_history(worker_name.clone(), &workspace_root);
|
||||
app.connected = true;
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
status: WorkerStatus::Idle,
|
||||
greeting: Greeting {
|
||||
worker_name: worker_name.clone(),
|
||||
@@ -673,7 +516,9 @@ async fn run_e2e_rewind_fixture(
|
||||
if let Some(submitted_at) = pending_apply {
|
||||
if submitted_at.elapsed() >= apply_delay {
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
input: vec![Segment::text("rewind-live-refresh")],
|
||||
summary: RewindSummary {
|
||||
truncated_to_entries: 1,
|
||||
@@ -745,14 +590,13 @@ async fn drain_terminal_events(
|
||||
app: &mut App,
|
||||
client: &mut ConsoleConnection,
|
||||
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
||||
runtime_command: Option<&WorkerRuntimeCommand>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let mut handled = false;
|
||||
for _ in 0..TERMINAL_EVENT_DRAIN_LIMIT {
|
||||
match term_rx.try_recv() {
|
||||
Ok(event) => {
|
||||
handled = true;
|
||||
handle_terminal_event(app, client, event?, runtime_command).await?;
|
||||
handle_terminal_event(app, client, event?).await?;
|
||||
if app.quit {
|
||||
break;
|
||||
}
|
||||
@@ -791,8 +635,7 @@ async fn drain_worker_events(
|
||||
async fn run_loop(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut App,
|
||||
mut client: ConsoleConnection,
|
||||
runtime_command: Option<WorkerRuntimeCommand>,
|
||||
client: &mut ConsoleConnection,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
||||
|
||||
@@ -803,12 +646,11 @@ async fn run_loop(
|
||||
break;
|
||||
}
|
||||
|
||||
let handled_term_event =
|
||||
drain_terminal_events(app, &mut client, &mut term_rx, runtime_command.as_ref()).await?;
|
||||
let handled_term_event = drain_terminal_events(app, client, &mut term_rx).await?;
|
||||
if app.quit {
|
||||
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 {
|
||||
terminal.draw(|f| ui::draw(f, app))?;
|
||||
continue;
|
||||
@@ -816,8 +658,7 @@ async fn run_loop(
|
||||
|
||||
match next_loop_input(&mut term_rx, app.connected, client.next_event()).await {
|
||||
LoopInput::Terminal(term_event) => {
|
||||
handle_terminal_event(app, &mut client, term_event?, runtime_command.as_ref())
|
||||
.await?;
|
||||
handle_terminal_event(app, client, term_event?).await?;
|
||||
}
|
||||
LoopInput::Worker(event) => match event {
|
||||
Some(ev) => {
|
||||
@@ -843,7 +684,6 @@ async fn handle_terminal_event(
|
||||
app: &mut App,
|
||||
client: &mut ConsoleConnection,
|
||||
event: TermEvent,
|
||||
_runtime_command: Option<&WorkerRuntimeCommand>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match event {
|
||||
TermEvent::Key(key) => {
|
||||
@@ -865,19 +705,6 @@ async fn handle_terminal_event(
|
||||
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
|
||||
/// hand-rolling through long histories isn't tedious, but slow enough
|
||||
/// that a single notch doesn't blow past the section the user is
|
||||
@@ -1304,6 +1131,14 @@ mod tests {
|
||||
use crate::text_selection::{HistoryViewport, SelectionRow};
|
||||
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]
|
||||
fn single_worker_mouse_capture_avoids_drag_and_all_motion_modes() {
|
||||
let mut ansi = String::new();
|
||||
@@ -2023,13 +1858,13 @@ mod tests {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
entries: vec![],
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
status: WorkerStatus::Idle,
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
});
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
input: vec![Segment::Text {
|
||||
content: "retry this".into(),
|
||||
}],
|
||||
@@ -2050,7 +1885,7 @@ mod tests {
|
||||
let mut app = App::new("agent".to_string());
|
||||
app.handle_worker_event(Event::Snapshot {
|
||||
greeting: test_greeting(),
|
||||
entries: vec![],
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
status: WorkerStatus::Idle,
|
||||
in_flight: Default::default(),
|
||||
internal_workers: Vec::new(),
|
||||
@@ -2058,7 +1893,7 @@ mod tests {
|
||||
type_keys(&mut app, "draft");
|
||||
|
||||
app.handle_worker_event(Event::RewindApplied {
|
||||
entries: vec![],
|
||||
session: protocol::SessionSnapshot { entries: vec![] },
|
||||
input: vec![Segment::Text {
|
||||
content: "retry this".into(),
|
||||
}],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
+70
-94
@@ -8,24 +8,19 @@ mod command;
|
||||
mod composer_history;
|
||||
mod composer_keys;
|
||||
mod console;
|
||||
mod dashboard;
|
||||
#[cfg(feature = "e2e-test")]
|
||||
mod e2e_observer;
|
||||
mod input;
|
||||
pub mod keys;
|
||||
mod markdown;
|
||||
mod picker;
|
||||
mod role_session_registry;
|
||||
mod scroll;
|
||||
pub mod setup_model;
|
||||
mod spawn;
|
||||
mod standalone_picker;
|
||||
mod task;
|
||||
mod text_selection;
|
||||
mod tool;
|
||||
mod ui;
|
||||
mod view_mode;
|
||||
mod worker_list;
|
||||
mod workspace_panel;
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
@@ -34,7 +29,6 @@ use std::process::ExitCode;
|
||||
use crossterm::event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste};
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode, enable_raw_mode};
|
||||
use session_store::SegmentId;
|
||||
|
||||
use client::{Target, WorkerConnectionSelector, WorkerListRequest};
|
||||
|
||||
@@ -47,42 +41,69 @@ pub struct LaunchOptions {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LaunchMode {
|
||||
/// Start one client-owned in-process Standalone Worker.
|
||||
Spawn {
|
||||
worker_name: Option<String>,
|
||||
profile: Option<String>,
|
||||
},
|
||||
/// `yoi --worker <name>`: attach to a live Worker by name if possible;
|
||||
/// otherwise launch the Worker runtime command with `--worker <name>` so it
|
||||
/// resumes from name-keyed state or creates a fresh same-name Worker.
|
||||
WorkerName {
|
||||
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.
|
||||
/// Restore one client-owned standalone session. The current cwd is the default scope;
|
||||
/// `include_all` opts into all standalone sessions under the same client data root.
|
||||
StandaloneResume { include_all: bool },
|
||||
/// List Backend Workers and attach to the selected Worker.
|
||||
Workers {
|
||||
runtime_id: Option<String>,
|
||||
include_stopped: bool,
|
||||
all: bool,
|
||||
},
|
||||
/// `yoi --backend <url> --runtime-id <id> --worker-id <id>`: open one Worker
|
||||
/// through the selected connection target.
|
||||
/// Open one Backend Worker through the selected connection target.
|
||||
OpenWorker {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
},
|
||||
/// `yoi resume`: open the Worker picker, then attach to the selected live Worker
|
||||
/// or restore the selected stopped Worker by name. Without `--all`, the picker
|
||||
/// is scoped to the current runtime workspace.
|
||||
Resume { all: bool },
|
||||
/// `yoi --session <UUID>`: skip the picker, go straight to the
|
||||
/// resume name dialog with `id` baked in.
|
||||
ResumeWithSession {
|
||||
id: SegmentId,
|
||||
worker_name: Option<String>,
|
||||
},
|
||||
/// `yoi panel`: open the workspace Dashboard from the current workspace.
|
||||
Panel { include_stopped: bool },
|
||||
/// Open the Backend Workspace dashboard.
|
||||
Panel,
|
||||
}
|
||||
|
||||
struct TerminalModeGuard {
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl TerminalModeGuard {
|
||||
fn new() -> Self {
|
||||
Self { active: true }
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -109,6 +130,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
eprintln!("yoi: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
let mut terminal_mode = TerminalModeGuard::new();
|
||||
|
||||
let result = match mode {
|
||||
LaunchMode::Spawn {
|
||||
@@ -116,49 +138,34 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
profile,
|
||||
} => match target.spawn_worker() {
|
||||
Ok(spawn) => {
|
||||
console::run_spawn(None, worker_name, profile, spawn.runtime_command).await
|
||||
}
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
},
|
||||
LaunchMode::WorkerName {
|
||||
worker_name,
|
||||
socket_override,
|
||||
} => match target.worker_by_name() {
|
||||
Ok(worker_by_name) => {
|
||||
console::run_worker_name(
|
||||
console::run_standalone(
|
||||
workspace_root.clone(),
|
||||
spawn.state_dir,
|
||||
worker_name,
|
||||
socket_override,
|
||||
worker_by_name.runtime_command,
|
||||
profile,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(e) => Err(Box::new(e) 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 {
|
||||
runtime_id,
|
||||
include_stopped,
|
||||
all,
|
||||
} => match target.list_workers(if include_stopped {
|
||||
WorkerListRequest::with_stopped(runtime_id)
|
||||
} else {
|
||||
WorkerListRequest::new(runtime_id)
|
||||
}) {
|
||||
Ok(worker_list) => {
|
||||
if let Some(target) = worker_list.backend_target {
|
||||
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,
|
||||
)
|
||||
backend_worker_picker::run(worker_list.backend_target, worker_list.include_stopped)
|
||||
.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>),
|
||||
},
|
||||
@@ -169,28 +176,12 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
Ok(connection) => console::run_backend_runtime(connection.target).await,
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
},
|
||||
LaunchMode::Resume { all } => match target.resume_worker() {
|
||||
Ok(resume) => {
|
||||
console::run_resume(resume.runtime_command, workspace_root.clone(), all).await
|
||||
LaunchMode::Panel => match target.dashboard() {
|
||||
Ok(dashboard) => {
|
||||
backend_dashboard::launch(dashboard.base_url, dashboard.workspace_id).await
|
||||
}
|
||||
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
|
||||
@@ -198,15 +189,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
// alternate-screen buffer.
|
||||
#[cfg(feature = "e2e-test")]
|
||||
e2e_observer::emit("tui", "terminal_cleanup_started", serde_json::json!({}));
|
||||
let mut stdout = io::stdout();
|
||||
let _ = execute!(
|
||||
stdout,
|
||||
DisableMouseCapture,
|
||||
LeaveAlternateScreen,
|
||||
DisableBracketedPaste
|
||||
);
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(stdout, crossterm::cursor::Show);
|
||||
let _ = terminal_mode.restore();
|
||||
#[cfg(feature = "e2e-test")]
|
||||
e2e_observer::emit("tui", "terminal_cleanup_finished", serde_json::json!({}));
|
||||
|
||||
@@ -217,14 +200,7 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
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")]
|
||||
e2e_observer::emit("tui", "exit", serde_json::json!({ "status": "failure" }));
|
||||
ExitCode::FAILURE
|
||||
|
||||
@@ -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))));
|
||||
}
|
||||
}
|
||||
@@ -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(®istry)
|
||||
}
|
||||
|
||||
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()),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use client::{StandaloneSessionListIntent, StandaloneSessionResumeIntent, 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, StandaloneSessionRecord, StandaloneSessionStore};
|
||||
use thiserror::Error;
|
||||
|
||||
const LIMIT: usize = 100;
|
||||
|
||||
pub(crate) fn pick(
|
||||
target: &dyn Target,
|
||||
include_all: bool,
|
||||
) -> Result<Option<StandaloneSessionResumeIntent>, StandalonePickerError> {
|
||||
let intent = target
|
||||
.standalone_session_list(include_all)
|
||||
.map_err(StandalonePickerError::Target)?;
|
||||
let records = load_records(&intent)?;
|
||||
if records.is_empty() {
|
||||
return Err(StandalonePickerError::NoSessions { include_all });
|
||||
}
|
||||
let selected = run_picker(records)?;
|
||||
selected
|
||||
.map(|record| {
|
||||
target
|
||||
.standalone_session_resume(record.session_id.to_string())
|
||||
.map_err(StandalonePickerError::Target)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn load_records(
|
||||
intent: &StandaloneSessionListIntent,
|
||||
) -> Result<Vec<StandaloneSessionRecord>, StandalonePickerError> {
|
||||
let store = StandaloneSessionStore::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<StandaloneSessionRecord>,
|
||||
) -> Result<Option<StandaloneSessionRecord>, 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: &[StandaloneSessionRecord], 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 session",
|
||||
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(record.session_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 session state is unavailable: {0}")]
|
||||
StateStore(#[source] standalone::StandaloneStoreError),
|
||||
#[error(
|
||||
"no standalone sessions found for this cwd; use `yoi --local --resume --all` to include all cwd identities"
|
||||
)]
|
||||
NoSessions { include_all: bool },
|
||||
#[error("standalone session 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"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -107,6 +107,31 @@ pub enum WorkdirTransportErrorCode {
|
||||
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::UnknownCommand => "unknown_command",
|
||||
Self::Unavailable => "unavailable",
|
||||
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::Unsupported | Self::InvalidRequest => 400,
|
||||
Self::Unavailable => 503,
|
||||
Self::Internal => 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WorkdirTransportError {
|
||||
pub code: WorkdirTransportErrorCode,
|
||||
@@ -126,6 +151,9 @@ impl WorkdirTransportError {
|
||||
message: format!("Workdir capability {capability:?} is not available"),
|
||||
};
|
||||
}
|
||||
WorkdirError::UnsupportedOperation(_) => {
|
||||
(Code::Unsupported, "Workdir operation is not supported")
|
||||
}
|
||||
WorkdirError::UnknownCommand(_) => {
|
||||
(Code::UnknownCommand, "Workdir command was not found")
|
||||
}
|
||||
@@ -161,7 +189,7 @@ impl WorkdirTransportError {
|
||||
match self.code {
|
||||
Code::NotFound => WorkdirError::NotFound("<remote>".into()),
|
||||
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::Unavailable => WorkdirError::Unavailable(self.message),
|
||||
@@ -517,13 +545,13 @@ mod client {
|
||||
.json::<WorkdirTransportError>()
|
||||
.await
|
||||
.map(WorkdirTransportError::into_workdir_error)
|
||||
.unwrap_or_else(|error| {
|
||||
WorkdirError::Unavailable(format!("Runtime HTTP error: {error}"))
|
||||
.unwrap_or_else(|_| {
|
||||
WorkdirError::Transport("Runtime Workdir error response was invalid".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
fn http_unavailable(error: reqwest::Error) -> WorkdirError {
|
||||
WorkdirError::Unavailable(format!("Runtime Workdir HTTP request failed: {error}"))
|
||||
fn http_unavailable(_error: reqwest::Error) -> WorkdirError {
|
||||
WorkdirError::Transport("Runtime Workdir HTTP request failed".to_string())
|
||||
}
|
||||
|
||||
pub use self::RemoteWorkdirSession as ClientSession;
|
||||
@@ -536,6 +564,57 @@ pub use client::{ClientSession as RemoteWorkdirSession, WorkdirHttpAuthorization
|
||||
mod tests {
|
||||
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::Unavailable, 503, "unavailable"),
|
||||
(WorkdirTransportErrorCode::Internal, 500, "transport 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]
|
||||
fn transport_failure_remains_distinct_from_session_unavailable() {
|
||||
let transport = WorkdirTransportError::from_workdir_error(&WorkdirError::Transport(
|
||||
|
||||
@@ -225,6 +225,9 @@ pub enum WorkdirError {
|
||||
#[error("Workdir session does not support {0:?}")]
|
||||
Unsupported(WorkdirSessionCapability),
|
||||
|
||||
#[error("Workdir operation is unsupported: {0}")]
|
||||
UnsupportedOperation(String),
|
||||
|
||||
#[error("invalid Workdir path: {0}")]
|
||||
InvalidPath(String),
|
||||
|
||||
|
||||
@@ -1943,9 +1943,9 @@ mod tests {
|
||||
&workdir,
|
||||
GrepRequest {
|
||||
pattern: "NEEDLE".into(),
|
||||
path: WorkdirPath::root(),
|
||||
glob: None,
|
||||
file_type: None,
|
||||
path: WorkdirPath::new("src/main.rs").unwrap(),
|
||||
glob: Some("src/*.rs".into()),
|
||||
file_type: Some("rust".into()),
|
||||
case_insensitive: false,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
|
||||
@@ -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 RUNTIME_REQUEST_SOURCE_PROOF_HEADER: &str = "x-yoi-runtime-request-proof";
|
||||
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";
|
||||
const RUNTIME_REQUEST_SOURCE_PROOF_PREFIX: &str = "yoi-runtime-request-v1";
|
||||
const RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-runtime-request-v1.";
|
||||
|
||||
@@ -1721,17 +1721,8 @@ impl RuntimeHttpWorkdirError {
|
||||
impl From<workdir::WorkdirError> for RuntimeHttpWorkdirError {
|
||||
fn from(error: workdir::WorkdirError) -> Self {
|
||||
let payload = WorkdirTransportError::from_workdir_error(&error);
|
||||
let status = match payload.code {
|
||||
WorkdirTransportErrorCode::NotFound | WorkdirTransportErrorCode::UnknownCommand => {
|
||||
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,
|
||||
};
|
||||
let status = StatusCode::from_u16(payload.code.http_status())
|
||||
.expect("Workdir transport error status is valid");
|
||||
Self { status, payload }
|
||||
}
|
||||
}
|
||||
@@ -1886,8 +1877,8 @@ mod tests {
|
||||
use manifest::{Scope, SharedScope};
|
||||
use tower::ServiceExt;
|
||||
use workdir::{
|
||||
LocalWorkdirSession, ReadRequest, StatRequest, Workdir, WorkdirPath,
|
||||
WorkdirSessionCapabilities,
|
||||
GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, StatRequest, Workdir,
|
||||
WorkdirPath, WorkdirSessionCapabilities,
|
||||
};
|
||||
|
||||
fn test_bundle(profile: ProfileSelector) -> ConfigBundle {
|
||||
@@ -2348,6 +2339,40 @@ mod tests {
|
||||
.expect("owned operation");
|
||||
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)]
|
||||
{
|
||||
let delegated_visible = WorkdirSessionOperationRequest {
|
||||
|
||||
@@ -1530,7 +1530,9 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
Ok(protocol::Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: protocol::Greeting {
|
||||
worker_name: worker_ref.worker_id.to_string(),
|
||||
cwd: String::new(),
|
||||
@@ -3152,7 +3154,9 @@ mod tests {
|
||||
),
|
||||
);
|
||||
let snapshot = protocol::Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: protocol::Greeting {
|
||||
worker_name: "parent".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
@@ -4581,7 +4585,17 @@ mod tests {
|
||||
backend.set_worker_snapshot(
|
||||
&detail.worker_ref,
|
||||
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 {
|
||||
worker_name: "live-worker".to_string(),
|
||||
cwd: "/tmp/live".to_string(),
|
||||
@@ -4606,12 +4620,13 @@ mod tests {
|
||||
.unwrap();
|
||||
match snapshot {
|
||||
protocol::Event::Snapshot {
|
||||
entries,
|
||||
session,
|
||||
greeting,
|
||||
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!(status, protocol::WorkerStatus::Running);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ use crate::working_directory::{
|
||||
WorkingDirectoryBinding, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer,
|
||||
};
|
||||
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};
|
||||
#[cfg(test)]
|
||||
use session_store::{FsStore, FsWorkerStore};
|
||||
@@ -46,6 +46,8 @@ use tokio::runtime::Runtime;
|
||||
use tokio::sync::broadcast;
|
||||
use workdir::{LocalWorkdirSession, Workdir, WorkdirSessionCapabilities, WorkdirSessionHandle};
|
||||
|
||||
#[cfg(test)]
|
||||
use worker::WorkerController;
|
||||
use worker::feature::builtin::{
|
||||
CompositeWorkerObservationProvider, WorkerObservationError, WorkerObservationProvider,
|
||||
WorkerObservationSubject, WorkerObservationSubjectRef, WorkerSessionCapture,
|
||||
@@ -54,9 +56,10 @@ use worker::feature::builtin::{
|
||||
#[cfg(feature = "ws-server")]
|
||||
use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session};
|
||||
use worker::{
|
||||
PromptCatalogSource, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker,
|
||||
WorkerController, WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority,
|
||||
WorkerHandle, WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
|
||||
PreparedWorker, PromptCatalogSource, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN,
|
||||
Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout,
|
||||
WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle,
|
||||
WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
|
||||
};
|
||||
|
||||
const DEFAULT_BACKEND_ID: &str = "worker-crate";
|
||||
@@ -67,8 +70,7 @@ const USER_INPUT_COMMIT_TIMEOUT: Duration = Duration::from_secs(9);
|
||||
|
||||
fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
|
||||
let extensions = match entry {
|
||||
LogEntry::UserInput { extensions, .. }
|
||||
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
|
||||
LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
|
||||
_ => return false,
|
||||
};
|
||||
extensions.iter().any(|extension| {
|
||||
@@ -422,7 +424,7 @@ impl ProfileRuntimeWorkerFactory {
|
||||
fn restore_fallback_manifest(
|
||||
worker_name: &str,
|
||||
) -> 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());
|
||||
let manifest = manifest::WorkerManifest::try_from(config)
|
||||
.map_err(|err| format!("failed to build restore fallback manifest: {err}"))?;
|
||||
@@ -881,15 +883,31 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
)?;
|
||||
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 mut prepared = WorkerBootstrap::new(
|
||||
manifest,
|
||||
store,
|
||||
loader,
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
WorkerBootstrapLayout::RuntimeManagedRun {
|
||||
run_dir: run_dir.clone(),
|
||||
},
|
||||
self.controller_transport,
|
||||
)
|
||||
.prepare()
|
||||
.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)?;
|
||||
if let Some(binding) = request.working_directory.as_ref() {
|
||||
worker.bind_workdir_session(Some(runtime_local_workdir_session(
|
||||
@@ -935,21 +953,16 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
}
|
||||
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
let run_dir = worker_aggregate_dir
|
||||
.join("runs")
|
||||
.join(request.run_generation.to_string());
|
||||
let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed_run_with_transport(
|
||||
worker,
|
||||
&run_dir,
|
||||
self.controller_transport,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to spawn Worker controller in {}: {err}",
|
||||
let started = prepared.start().await.map_err(|error| match error {
|
||||
WorkerBootstrapError::Worker(source) => {
|
||||
format!("failed to prepare Worker before controller start: {source}")
|
||||
}
|
||||
WorkerBootstrapError::Controller { source, .. } => format!(
|
||||
"failed to spawn Worker controller in {}: {source}",
|
||||
run_dir.display()
|
||||
)
|
||||
),
|
||||
})?;
|
||||
let (handle, shutdown_rx) = (started.handle, started.shutdown);
|
||||
if flow_transition_enabled {
|
||||
handle.shared_state.enable_flow_transition();
|
||||
}
|
||||
@@ -1118,18 +1131,25 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
let run_dir = worker_aggregate_dir
|
||||
.join("runs")
|
||||
.join(request.run_generation.to_string());
|
||||
let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed_run_with_transport(
|
||||
let started = PreparedWorker::new(
|
||||
worker,
|
||||
&run_dir,
|
||||
WorkerBootstrapLayout::RuntimeManagedRun {
|
||||
run_dir: run_dir.clone(),
|
||||
},
|
||||
self.controller_transport,
|
||||
)
|
||||
.start()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to spawn restored Worker controller in {}: {err}",
|
||||
.map_err(|error| match error {
|
||||
WorkerBootstrapError::Worker(source) => {
|
||||
format!("failed to prepare restored Worker: {source}")
|
||||
}
|
||||
WorkerBootstrapError::Controller { source, .. } => format!(
|
||||
"failed to spawn restored Worker controller in {}: {source}",
|
||||
run_dir.display()
|
||||
)
|
||||
),
|
||||
})?;
|
||||
let (handle, shutdown_rx) = (started.handle, started.shutdown);
|
||||
if flow_transition_enabled {
|
||||
handle.shared_state.enable_flow_transition();
|
||||
}
|
||||
@@ -1443,7 +1463,6 @@ where
|
||||
let streams = subscribe_worker_protocol_session(&handle);
|
||||
let mut events = streams.events;
|
||||
let mut entry_events = streams.log_entries;
|
||||
let bridge_handle = handle.clone();
|
||||
let bridge_busy = busy.clone();
|
||||
if let Err(message) = self.spawn_on_adapter_runtime(async move {
|
||||
loop {
|
||||
@@ -1451,12 +1470,28 @@ where
|
||||
event = events.recv() => {
|
||||
match 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);
|
||||
if matches!(
|
||||
bridge_handle.shared_state.get_status(),
|
||||
WorkerStatus::Idle | WorkerStatus::Paused
|
||||
) {
|
||||
bridge_busy.store(false, Ordering::SeqCst);
|
||||
if let Some(next_busy) = next_busy {
|
||||
bridge_busy.store(next_busy, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
@@ -2566,18 +2601,22 @@ mod tests {
|
||||
) {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let matches = {
|
||||
let observed = {
|
||||
let workers = backend.workers.lock().unwrap();
|
||||
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;
|
||||
}
|
||||
assert!(
|
||||
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));
|
||||
}
|
||||
@@ -3091,9 +3130,68 @@ mod tests {
|
||||
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]
|
||||
#[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 long_component = "embedded-workspace-store-segment".repeat(4);
|
||||
let runtime_store_dir = root.path().join(long_component);
|
||||
@@ -3244,8 +3342,7 @@ mod tests {
|
||||
assert!(entries.iter().any(|entry| {
|
||||
matches!(
|
||||
entry,
|
||||
LogEntry::UserInput { segments, .. }
|
||||
| LogEntry::AnnotatedUserInput { segments, .. }
|
||||
LogEntry::AnnotatedUserInput { segments, .. }
|
||||
if segments == &vec![Segment::text("start the ticket")]
|
||||
)
|
||||
}));
|
||||
@@ -3253,8 +3350,7 @@ mod tests {
|
||||
.iter()
|
||||
.find_map(|entry| {
|
||||
let extensions = match entry {
|
||||
LogEntry::UserInput { extensions, .. }
|
||||
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
|
||||
LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
|
||||
_ => return None,
|
||||
};
|
||||
extensions
|
||||
|
||||
@@ -9,8 +9,8 @@ use worker::{
|
||||
use crate::auth::{
|
||||
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial,
|
||||
RuntimeRequestSourceSigner, RuntimeWorkerMutationSourceSigner, WORKER_REMOVE_PERMISSION,
|
||||
WORKSPACE_REQUEST_PERMISSION, WorkerMutationActorKind, WorkerMutationOperation,
|
||||
WorkerMutationSourceClaims, new_token_id,
|
||||
WORKSPACE_REQUEST_PERMISSION, WORKSPACE_WORKER_DISCOVERY_PERMISSION, WorkerMutationActorKind,
|
||||
WorkerMutationOperation, WorkerMutationSourceClaims, new_token_id,
|
||||
};
|
||||
use crate::runtime::RuntimeWorkspaceScope;
|
||||
use crate::worker_backend::WorkspacePromptProjectionCache;
|
||||
@@ -343,6 +343,51 @@ impl RuntimeOwnedWorkspaceClient {
|
||||
self.request_timeout = request_timeout;
|
||||
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 {
|
||||
@@ -377,42 +422,40 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> 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,
|
||||
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,
|
||||
)
|
||||
self.execute_with_permission(request, WORKSPACE_REQUEST_PERMISSION)
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -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(
|
||||
base_url: &str,
|
||||
workspace_id: &str,
|
||||
@@ -514,6 +570,7 @@ fn execute_runtime_owned_workspace_http(
|
||||
request_source_signer: Option<&RuntimeRequestSourceSigner>,
|
||||
request_source_audience: Option<&str>,
|
||||
request_timeout: Option<Duration>,
|
||||
permission: &'static str,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
if !request.path.starts_with('/') || request.path.starts_with("//") {
|
||||
@@ -553,7 +610,7 @@ fn execute_runtime_owned_workspace_http(
|
||||
audience,
|
||||
workspace_id,
|
||||
Some(worker_id),
|
||||
WORKSPACE_REQUEST_PERMISSION,
|
||||
permission,
|
||||
method.as_str(),
|
||||
&request.path,
|
||||
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]
|
||||
fn remote_authority_stamps_and_signs_worker_remove_without_caller_claim_choices() {
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
|
||||
|
||||
@@ -27,6 +27,7 @@ toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tools = { workspace = true }
|
||||
workdir = { workspace = true }
|
||||
workspace-api = { workspace = true }
|
||||
minijinja = "2.19.0"
|
||||
chrono = "0.4"
|
||||
config-source = { path = "../config-source" }
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
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 },
|
||||
/// A runtime-managed Worker with an exact persisted run directory.
|
||||
RuntimeManagedRun { run_dir: PathBuf },
|
||||
}
|
||||
|
||||
/// 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 } => {
|
||||
WorkerController::spawn_with_transport(worker, &runtime_base, transport).await
|
||||
}
|
||||
WorkerBootstrapLayout::RuntimeManagedRun { run_dir } => {
|
||||
WorkerController::spawn_runtime_managed_run_with_transport(worker, &run_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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,10 +84,7 @@ impl WorkerHandle {
|
||||
(entries, entry_rx, in_flight)
|
||||
};
|
||||
let event = Event::Snapshot {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.map(|entry| serde_json::to_value(entry).expect("log entry serializes"))
|
||||
.collect(),
|
||||
session: session_store::public_snapshot::project_current_session_snapshot(&entries),
|
||||
greeting: self.shared_state.greeting.clone(),
|
||||
status: self.shared_state.get_status(),
|
||||
in_flight,
|
||||
@@ -240,6 +237,20 @@ impl WorkerController {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Spawn a direct Worker while letting an in-process host select the
|
||||
/// controller transport explicitly.
|
||||
pub async fn spawn_with_transport<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
runtime_base: &Path,
|
||||
transport: WorkerControllerTransport,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||
where
|
||||
C: LlmClient + Clone + 'static,
|
||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Self::spawn_inner(worker, runtime_base, false, None, transport).await
|
||||
}
|
||||
|
||||
/// Spawn a Worker owned by `worker-runtime`.
|
||||
///
|
||||
/// The controller still uses an ephemeral directory for Unix sockets and
|
||||
@@ -634,8 +645,8 @@ fn protocol_command_status(status: WorkdirCommandStatus) -> ProtocolCommandStatu
|
||||
///
|
||||
/// `Worker::wire_history_persistence` is called separately to wire the
|
||||
/// per-item history commit callback so every assistant / tool item
|
||||
/// landing in `worker.history` becomes a singular `LogEntry::AssistantItem`
|
||||
/// / `ToolResult` commit through the sync writer.
|
||||
/// landing in `worker.history` becomes a singular `LogEntry::AnnotatedAssistantItem`
|
||||
/// / `AnnotatedToolResult` commit through the sync writer.
|
||||
pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
||||
worker: &mut Worker<C, St>,
|
||||
event_tx: &broadcast::Sender<Event>,
|
||||
@@ -887,8 +898,10 @@ where
|
||||
.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();
|
||||
if feature_config.sub_worker.enabled {
|
||||
if sub_worker_enabled && !worker_enabled {
|
||||
feature_registry.add_module(
|
||||
crate::feature::builtin::manage_worker::sub_worker_control_feature(
|
||||
worker.workspace_client_handle(),
|
||||
@@ -959,6 +972,23 @@ where
|
||||
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 {
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
let has_workspace_identity = workspace_client.workspace_id().is_some_and(|workspace_id| {
|
||||
@@ -973,7 +1003,7 @@ where
|
||||
feature_registry.add_module(
|
||||
crate::feature::builtin::manage_worker::manage_worker_feature(
|
||||
workspace_client,
|
||||
Some(spawned_registry.clone()),
|
||||
sub_worker_enabled.then(|| spawned_registry.clone()),
|
||||
feature_config.worker.direct_spawn,
|
||||
),
|
||||
);
|
||||
@@ -1320,7 +1350,7 @@ async fn controller_loop<C, St>(
|
||||
}
|
||||
// Stage the run without a speculative user-message echo.
|
||||
// `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
|
||||
// keeps every client ordered against `SegmentStart` replay and
|
||||
// makes persisted history the single source of visible user
|
||||
@@ -1346,7 +1376,7 @@ async fn controller_loop<C, St>(
|
||||
Method::Notify { message, auto_run } => {
|
||||
// Client-side live echo is delivered as `Event::SystemItem`
|
||||
// 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
|
||||
// separate echo here.
|
||||
worker.push_notify(message, auto_run);
|
||||
@@ -1874,28 +1904,16 @@ where
|
||||
St: Store,
|
||||
{
|
||||
match worker.rewind_to(target, expected_head_entries) {
|
||||
Ok(applied) => match applied
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
{
|
||||
Ok(entries) => {
|
||||
let _ = event_tx.send(Event::RewindApplied {
|
||||
entries,
|
||||
input: applied.input,
|
||||
summary: applied.summary,
|
||||
});
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::Internal,
|
||||
message: format!("failed to encode rewind snapshot: {error}"),
|
||||
});
|
||||
false
|
||||
}
|
||||
},
|
||||
Ok(applied) => {
|
||||
let session =
|
||||
session_store::public_snapshot::project_current_session_snapshot(&applied.entries);
|
||||
let _ = event_tx.send(Event::RewindApplied {
|
||||
session,
|
||||
input: applied.input,
|
||||
summary: applied.summary,
|
||||
});
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = event_tx.send(Event::Error {
|
||||
code: ErrorCode::InvalidRequest,
|
||||
@@ -2101,7 +2119,9 @@ mod tests {
|
||||
let mut writer = JsonLineWriter::new(w);
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: protocol::Greeting {
|
||||
worker_name: "parent".into(),
|
||||
cwd: "/tmp".into(),
|
||||
|
||||
@@ -14,9 +14,9 @@ use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::runtime_command::WorkerRuntimeCommand;
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use client::WorkerRuntimeCommand;
|
||||
use manifest::{Permission, ScopeRule};
|
||||
use protocol::stream::JsonLineReader;
|
||||
use protocol::{Event, Method, WorkerStatus};
|
||||
@@ -1481,7 +1481,9 @@ mod tests {
|
||||
let mut writer = JsonLineWriter::new(stream);
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: protocol::Greeting {
|
||||
worker_name: "target".into(),
|
||||
cwd: "/tmp".into(),
|
||||
@@ -1514,7 +1516,9 @@ mod tests {
|
||||
.unwrap();
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: protocol::Greeting {
|
||||
worker_name: "target".into(),
|
||||
cwd: "/tmp".into(),
|
||||
@@ -1603,7 +1607,9 @@ mod tests {
|
||||
let mut writer = JsonLineWriter::new(stream);
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: protocol::Greeting {
|
||||
worker_name: "target".into(),
|
||||
cwd: "/tmp".into(),
|
||||
@@ -1627,7 +1633,9 @@ mod tests {
|
||||
let mut writer = JsonLineWriter::new(writer_half);
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: protocol::Greeting {
|
||||
worker_name: "target".into(),
|
||||
cwd: "/tmp".into(),
|
||||
@@ -1729,7 +1737,9 @@ mod tests {
|
||||
.unwrap();
|
||||
writer
|
||||
.write(&Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: protocol::Greeting {
|
||||
worker_name: "alerted".into(),
|
||||
cwd: "/tmp".into(),
|
||||
@@ -1779,7 +1789,9 @@ mod tests {
|
||||
let mut writer = JsonLineWriter::new(stream);
|
||||
let _ = writer
|
||||
.write(&Event::Snapshot {
|
||||
entries: Vec::new(),
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: Vec::new(),
|
||||
},
|
||||
greeting: protocol::Greeting {
|
||||
worker_name: "child-live".into(),
|
||||
cwd: "/tmp".into(),
|
||||
|
||||
@@ -3,8 +3,8 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use crate::{
|
||||
PromptCatalogSource, Worker, WorkerController, WorkerFilesystemAuthority,
|
||||
WorkerWorkspaceContext,
|
||||
PromptCatalogSource, Worker, WorkerBootstrapLayout, WorkerControllerTransport,
|
||||
WorkerFilesystemAuthority, WorkerWorkspaceContext, start_worker_controller,
|
||||
};
|
||||
use clap::{CommandFactory, FromArgMatches, Parser};
|
||||
use manifest::{Permission, ScopeConfig, ScopeRule, WorkerManifest, WorkerManifestConfig, paths};
|
||||
@@ -184,15 +184,16 @@ fn load_spawn_config_json(
|
||||
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
|
||||
let config = serde_json::from_str::<WorkerManifestConfig>(config_json)
|
||||
.map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?;
|
||||
let manifest = WorkerManifest::try_from(WorkerManifestConfig::builtin_defaults().merge(config))
|
||||
.map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?;
|
||||
let manifest =
|
||||
WorkerManifest::try_from(WorkerManifestConfig::resolution_defaults().merge(config))
|
||||
.map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?;
|
||||
Ok((manifest, PromptCatalogSource::builtins_only()))
|
||||
}
|
||||
|
||||
fn load_builtin_default_manifest(
|
||||
worker_name: &str,
|
||||
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
|
||||
let mut config = WorkerManifestConfig::builtin_defaults();
|
||||
let mut config = WorkerManifestConfig::resolution_defaults();
|
||||
config.worker.name = Some(worker_name.to_string());
|
||||
let manifest = WorkerManifest::try_from(config)
|
||||
.map_err(|e| format!("failed to resolve builtin worker defaults: {e}"))?;
|
||||
@@ -259,7 +260,7 @@ fn load_single_manifest(
|
||||
absolute_path.display()
|
||||
)
|
||||
})?;
|
||||
let mut config = WorkerManifestConfig::builtin_defaults().merge(
|
||||
let mut config = WorkerManifestConfig::resolution_defaults().merge(
|
||||
WorkerManifestConfig::from_toml(&toml)
|
||||
.map_err(|e| format!("failed to parse manifest {}: {e}", path.display()))?
|
||||
.resolve_paths(base_dir),
|
||||
@@ -633,13 +634,22 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let (handle, shutdown_rx) = match WorkerController::spawn(worker, &runtime_base).await {
|
||||
Ok(pair) => pair,
|
||||
let started = match start_worker_controller(
|
||||
worker,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base: runtime_base.clone(),
|
||||
},
|
||||
WorkerControllerTransport::UnixSocket,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(started) => started,
|
||||
Err(e) => {
|
||||
eprintln!("error: failed to start worker controller: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let (handle, shutdown_rx) = (started.handle, started.shutdown);
|
||||
|
||||
let socket_path = handle.runtime_dir.socket_path();
|
||||
// Machine-readable ready line for parents that spawned this Worker
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod session_explore;
|
||||
pub mod task;
|
||||
pub mod ticket;
|
||||
pub mod worker_observation;
|
||||
pub mod workspace_worker_discovery;
|
||||
|
||||
pub(crate) use memory_extract::{MemoryExtractFeature, MemoryExtractState, render_extract_input};
|
||||
pub(crate) use session_explore::{SessionExploreFeature, SessionExploreState};
|
||||
|
||||
@@ -963,9 +963,12 @@ permission = "read"
|
||||
.append(
|
||||
session_id,
|
||||
segment_id,
|
||||
&LogEntry::UserInput {
|
||||
&LogEntry::AnnotatedUserInput {
|
||||
ts: 1,
|
||||
extensions: vec![],
|
||||
history: vec![crate::session_history::test_logged_history_entry(
|
||||
agen::Item::user_message("verify current Flow conditions"),
|
||||
)],
|
||||
segments: vec![Segment::Text {
|
||||
content: "verify current Flow conditions".into(),
|
||||
}],
|
||||
|
||||
@@ -195,11 +195,16 @@ impl WorkspaceAttachedWorkdirSession {
|
||||
.execute(request)
|
||||
.map_err(workspace_workdir_error)?;
|
||||
if !response.is_success() {
|
||||
return Err(WorkdirError::Transport(format!(
|
||||
"Workspace Workdir API returned HTTP {}: {}",
|
||||
response.status,
|
||||
bounded_error_body(&response.body)
|
||||
)));
|
||||
return Err(
|
||||
serde_json::from_str::<workdir::http::WorkdirTransportError>(&response.body)
|
||||
.map(workdir::http::WorkdirTransportError::into_workdir_error)
|
||||
.unwrap_or_else(|_| {
|
||||
WorkdirError::Transport(format!(
|
||||
"Workspace Workdir operation failed with HTTP {}",
|
||||
response.status
|
||||
))
|
||||
}),
|
||||
);
|
||||
}
|
||||
serde_json::from_str(&response.body).map_err(|error| {
|
||||
WorkdirError::Transport(format!(
|
||||
@@ -795,6 +800,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 {
|
||||
json!({
|
||||
"working_directory_id": id,
|
||||
@@ -1175,6 +1195,48 @@ mod tests {
|
||||
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]
|
||||
async fn nested_attached_session_preserves_full_delegation_chain() {
|
||||
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)
|
||||
&& entries.iter().any(|entry| {
|
||||
!matches!(
|
||||
entry.origin,
|
||||
crate::WorkerHistoryProvenance::HumanInput { .. }
|
||||
)
|
||||
})
|
||||
&& entries
|
||||
.iter()
|
||||
.any(|entry| !matches!(entry.origin, protocol::SessionEntryProvenance::HumanInput))
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"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 {
|
||||
use crate::WorkerHistoryProvenance as Origin;
|
||||
let mut evidence = EvidenceOrigin {
|
||||
kind: EvidenceOriginKind::LegacyUnknown,
|
||||
fn evidence_origin(origin: &protocol::SessionEntryProvenance) -> EvidenceOrigin {
|
||||
use protocol::SessionEntryProvenance as Origin;
|
||||
let kind = match origin {
|
||||
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,
|
||||
workspace_id: None,
|
||||
runtime_id: None,
|
||||
@@ -335,46 +345,7 @@ fn evidence_origin(origin: &crate::WorkerHistoryProvenance) -> EvidenceOrigin {
|
||||
flow_selector: None,
|
||||
flow_definition_id: 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 {
|
||||
@@ -502,12 +473,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_origin_projects_account_authority_into_evidence() {
|
||||
let origin = evidence_origin(&crate::WorkerHistoryProvenance::HumanInput {
|
||||
account_id: "account-1".into(),
|
||||
});
|
||||
fn public_human_origin_preserves_class_without_inventing_account_authority() {
|
||||
let origin = evidence_origin(&protocol::SessionEntryProvenance::HumanInput);
|
||||
assert_eq!(origin.kind, EvidenceOriginKind::HumanInput);
|
||||
assert_eq!(origin.account_id.as_deref(), Some("account-1"));
|
||||
assert_eq!(origin.account_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -422,27 +422,11 @@ fn project_relation(
|
||||
kind_key: &str,
|
||||
) -> Result<ModelRelation, String> {
|
||||
let relation = object(value, "Ticket relation")?;
|
||||
let relation_data = relation.get("relation").and_then(Value::as_object);
|
||||
let kind = if kind_key == "kind" {
|
||||
relation_data
|
||||
.ok_or_else(|| "Ticket relation is missing relation data".to_string())
|
||||
.and_then(|data| string_field(data, "kind"))?
|
||||
} else {
|
||||
string_field(relation, kind_key)?
|
||||
};
|
||||
let note = match relation_data {
|
||||
Some(data) => optional_string(data, "note")?,
|
||||
None => optional_string(relation, "note")?,
|
||||
};
|
||||
let created_at = match relation_data {
|
||||
Some(data) => optional_string(data, "at")?,
|
||||
None => optional_string(relation, "at")?,
|
||||
};
|
||||
Ok(ModelRelation {
|
||||
ticket: resource_ref(relation, ticket_key, "T-")?,
|
||||
kind,
|
||||
note,
|
||||
created_at,
|
||||
kind: string_field(relation, kind_key)?,
|
||||
note: optional_string(relation, "note")?,
|
||||
created_at: optional_string(relation, "at")?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -776,6 +760,95 @@ mod tests {
|
||||
assert!(!objective_json.contains("00001TICKETINTERNAL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relation_projection_accepts_current_workspace_api_shapes() {
|
||||
let outgoing = project_relation(
|
||||
&json!({
|
||||
"ticket_id": "internal-source-ticket",
|
||||
"kind": "depends_on",
|
||||
"target": "internal-target-ticket",
|
||||
"target_resource_key": "T-535",
|
||||
"note": "required foundation",
|
||||
"author": "internal-author",
|
||||
"at": "2026-08-22T00:00:00Z"
|
||||
}),
|
||||
"target_resource_key",
|
||||
"kind",
|
||||
)
|
||||
.expect("outgoing relation projection");
|
||||
let incoming = project_relation(
|
||||
&json!({
|
||||
"source_ticket": "internal-source-ticket",
|
||||
"source_resource_key": "T-536",
|
||||
"inverse_kind": "blocks",
|
||||
"forward_kind": "depends_on",
|
||||
"note": null,
|
||||
"author": "internal-author",
|
||||
"at": "2026-08-22T00:01:00Z"
|
||||
}),
|
||||
"source_resource_key",
|
||||
"forward_kind",
|
||||
)
|
||||
.expect("incoming relation projection");
|
||||
|
||||
let outgoing = serde_json::to_value(outgoing).expect("serialize outgoing relation");
|
||||
assert_eq!(
|
||||
outgoing,
|
||||
json!({
|
||||
"ticket": "T-535",
|
||||
"kind": "depends_on",
|
||||
"note": "required foundation",
|
||||
"created_at": "2026-08-22T00:00:00Z"
|
||||
})
|
||||
);
|
||||
let incoming = serde_json::to_value(incoming).expect("serialize incoming relation");
|
||||
assert_eq!(
|
||||
incoming,
|
||||
json!({
|
||||
"ticket": "T-536",
|
||||
"kind": "depends_on",
|
||||
"note": null,
|
||||
"created_at": "2026-08-22T00:01:00Z"
|
||||
})
|
||||
);
|
||||
let projection = format!("{outgoing}{incoming}");
|
||||
for internal in [
|
||||
"internal-source-ticket",
|
||||
"internal-target-ticket",
|
||||
"internal-author",
|
||||
] {
|
||||
assert!(!projection.contains(internal));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relation_projection_rejects_missing_workspace_keys() {
|
||||
let outgoing = json!({
|
||||
"kind": "depends_on",
|
||||
"target": "internal-target-ticket",
|
||||
"note": null,
|
||||
"author": "internal-author",
|
||||
"at": "2026-08-22T00:00:00Z"
|
||||
});
|
||||
let incoming = json!({
|
||||
"source_resource_key": "not-a-ticket-key",
|
||||
"forward_kind": "depends_on",
|
||||
"note": null,
|
||||
"author": "internal-author",
|
||||
"at": "2026-08-22T00:01:00Z"
|
||||
});
|
||||
assert!(
|
||||
project_relation(&outgoing, "target_resource_key", "kind")
|
||||
.expect_err("missing outgoing key must fail")
|
||||
.contains("T-")
|
||||
);
|
||||
assert!(
|
||||
project_relation(&incoming, "source_resource_key", "forward_kind")
|
||||
.expect_err("invalid incoming key must fail")
|
||||
.contains("T-")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_projection_rejects_noncanonical_keys() {
|
||||
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
|
||||
|
||||
@@ -6,7 +6,7 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_store::{LogEntry, collect_state};
|
||||
use session_store::LogEntry;
|
||||
|
||||
use super::manage_worker::{WORKER_CONTROL_SERVICE_ID, WorkerControlService};
|
||||
use crate::feature::{
|
||||
@@ -61,7 +61,7 @@ pub struct WorkerObservationSubject {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkerSessionCapture {
|
||||
pub segment_id: String,
|
||||
pub entries: Vec<agen::HistoryEntry<crate::SessionHistoryMetadata>>,
|
||||
pub session: protocol::SessionSnapshot,
|
||||
}
|
||||
|
||||
impl WorkerSessionCapture {
|
||||
@@ -69,17 +69,9 @@ impl WorkerSessionCapture {
|
||||
segment_id: impl Into<String>,
|
||||
log_entries: &[LogEntry],
|
||||
) -> Result<Self, String> {
|
||||
let segment_id = segment_id.into();
|
||||
let state = collect_state(log_entries);
|
||||
let parsed_segment_id = segment_id.parse().unwrap_or_default();
|
||||
let entries = crate::session_history::restore_history_entries(
|
||||
state.session_id.unwrap_or_default(),
|
||||
parsed_segment_id,
|
||||
log_entries,
|
||||
)?;
|
||||
Ok(Self {
|
||||
segment_id,
|
||||
entries,
|
||||
segment_id: segment_id.into(),
|
||||
session: session_store::public_snapshot::project_current_session_snapshot(log_entries),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -115,7 +107,7 @@ struct WorkspaceWorkerObservationListResponse {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WorkspaceWorkerObservationCaptureResponse {
|
||||
segment_id: String,
|
||||
entries: Vec<serde_json::Value>,
|
||||
session: protocol::SessionSnapshot,
|
||||
}
|
||||
|
||||
pub struct WorkspaceClientWorkerObservationProvider {
|
||||
@@ -173,26 +165,9 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
|
||||
let body = workspace_response_body(response)?;
|
||||
let response = serde_json::from_str::<WorkspaceWorkerObservationCaptureResponse>(&body)
|
||||
.map_err(|error| WorkerObservationError::Unavailable(error.to_string()))?;
|
||||
let entries = response
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
serde_json::from_value(entry)
|
||||
.map_err(|error| WorkerObservationError::Unavailable(error.to_string()))
|
||||
})
|
||||
.collect::<Result<Vec<session_store::LogEntry>, _>>()?;
|
||||
let state = collect_state(&entries);
|
||||
let segment_id = response.segment_id;
|
||||
let parsed_segment_id = segment_id.parse().unwrap_or_default();
|
||||
let typed_entries = crate::session_history::restore_history_entries(
|
||||
state.session_id.unwrap_or_default(),
|
||||
parsed_segment_id,
|
||||
&entries,
|
||||
)
|
||||
.map_err(WorkerObservationError::Unavailable)?;
|
||||
Ok(WorkerSessionCapture {
|
||||
segment_id,
|
||||
entries: typed_entries,
|
||||
segment_id: response.segment_id,
|
||||
session: response.session,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -420,16 +395,9 @@ impl WorkerObservationProvider for SpawnedSubWorkerObservationProvider {
|
||||
.get_internal(name)
|
||||
.ok_or(WorkerObservationError::NotFound)?;
|
||||
let entries = record.session.entries();
|
||||
let state = collect_state(&entries);
|
||||
let typed_entries = crate::session_history::restore_history_entries(
|
||||
state.session_id.unwrap_or_default(),
|
||||
Default::default(),
|
||||
&entries,
|
||||
)
|
||||
.map_err(WorkerObservationError::Unavailable)?;
|
||||
Ok(WorkerSessionCapture {
|
||||
segment_id: format!("subworker:{name}"),
|
||||
entries: typed_entries,
|
||||
session: session_store::public_snapshot::project_current_session_snapshot(&entries),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -699,9 +667,9 @@ async fn latest_view(
|
||||
.capture_worker_session(subject)
|
||||
.await
|
||||
.map_err(tool_error)?;
|
||||
Ok(SessionCapture::from_history_entries(
|
||||
Ok(SessionCapture::from_session_snapshot(
|
||||
capture.segment_id,
|
||||
capture.entries,
|
||||
capture.session,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -799,16 +767,43 @@ mod tests {
|
||||
.clone()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
let mut metadata = crate::SessionHistoryMetadata::legacy_unknown();
|
||||
metadata.entry_id =
|
||||
session_store::LoggedSessionHistoryEntryId(format!("fake-{index:08}"));
|
||||
agen::HistoryEntry::new(item, metadata)
|
||||
.filter_map(|(index, item)| {
|
||||
let data = match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
let role = match role {
|
||||
Role::User => protocol::SessionMessageRole::User,
|
||||
Role::Assistant => protocol::SessionMessageRole::Assistant,
|
||||
Role::System => return None,
|
||||
};
|
||||
protocol::SessionSnapshotEntryData::Message {
|
||||
role,
|
||||
content: content
|
||||
.into_iter()
|
||||
.map(|part| match part {
|
||||
agen::ContentPart::Text { text } => {
|
||||
protocol::SessionContentPart::Text { text }
|
||||
}
|
||||
agen::ContentPart::Refusal { refusal } => {
|
||||
protocol::SessionContentPart::Refusal { refusal }
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(protocol::SessionSnapshotEntry {
|
||||
entry_id: format!("fake-{index:08}"),
|
||||
timestamp: index as u64,
|
||||
provenance: protocol::SessionEntryProvenance::LegacyUnknown,
|
||||
derived_from: Vec::new(),
|
||||
data,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Ok(WorkerSessionCapture {
|
||||
segment_id: "segment".to_string(),
|
||||
entries,
|
||||
session: protocol::SessionSnapshot { entries },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Privileged, read-only discovery of Workspace-visible Workers.
|
||||
//!
|
||||
//! This feature deliberately stays separate from the canonical `WorkerList`
|
||||
//! control-grant surface. Discovery results carry the typed subject needed by a
|
||||
//! later control operation, but discovery itself grants no control authority.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureInstructionContribution,
|
||||
FeatureInstructionDeclaration, FeatureInstructionId, FeatureModule, ToolContribution,
|
||||
ToolDeclaration,
|
||||
};
|
||||
use crate::worker::{WorkspaceClient, WorkspaceWorkerDiscoveryRequest};
|
||||
|
||||
const FEATURE_ID: &str = "workspace-worker-discovery";
|
||||
const TOOL_NAME: &str = "ListWorkspaceWorkers";
|
||||
const DEFAULT_LIMIT: usize = 50;
|
||||
const MAX_LIMIT: usize = 100;
|
||||
const INSTRUCTION_ID: &str = "workspace-worker-discovery.policy";
|
||||
const PROMPT_REF: &str = "common.workspace_worker_discovery";
|
||||
const DESCRIPTION: &str = "List or directly find Workspace-visible Workers through Backend authority. Results include each W-key and the typed runtime_worker subject needed by later Worker control calls, but do not grant control authority.";
|
||||
|
||||
fn instruction() -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
FeatureInstructionId::builtin(INSTRUCTION_ID),
|
||||
PROMPT_REF,
|
||||
"Workspace Worker discovery and control-authority separation",
|
||||
)
|
||||
.expect("static Workspace Worker discovery instruction is valid")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WorkspaceWorkerDiscoveryFeature {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
pub fn workspace_worker_discovery_feature(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
) -> WorkspaceWorkerDiscoveryFeature {
|
||||
WorkspaceWorkerDiscoveryFeature { client }
|
||||
}
|
||||
|
||||
impl FeatureModule for WorkspaceWorkerDiscoveryFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin(FEATURE_ID, "Workspace Worker Discovery")
|
||||
.with_description(DESCRIPTION)
|
||||
.with_instruction(instruction())
|
||||
.with_tool(ToolDeclaration::new(TOOL_NAME, DESCRIPTION))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
context
|
||||
.instructions()
|
||||
.register(FeatureInstructionContribution::new(instruction()))?;
|
||||
let client = self.client.clone();
|
||||
let definition: ToolDefinition = Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(TOOL_NAME)
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(input_schema()),
|
||||
Arc::new(ListWorkspaceWorkersTool {
|
||||
client: client.clone(),
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
});
|
||||
context
|
||||
.tools()
|
||||
.register(ToolContribution::new(TOOL_NAME, definition))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ListWorkspaceWorkersInput {
|
||||
#[serde(default)]
|
||||
cursor: Option<String>,
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
#[serde(default)]
|
||||
query: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ListWorkspaceWorkersTool {
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ListWorkspaceWorkersTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let input: ListWorkspaceWorkersInput = serde_json::from_str(input_json)
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
let limit = input.limit.unwrap_or(DEFAULT_LIMIT);
|
||||
if !(1..=MAX_LIMIT).contains(&limit) {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"limit must be between 1 and {MAX_LIMIT}"
|
||||
)));
|
||||
}
|
||||
let query = input.query.map(|query| query.trim().to_string());
|
||||
if query.as_deref().is_some_and(str::is_empty) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"query must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if query.as_ref().is_some_and(|query| query.len() > 128) {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"query must not exceed 128 bytes".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let page = self
|
||||
.client
|
||||
.list_workspace_workers(WorkspaceWorkerDiscoveryRequest {
|
||||
cursor: input.cursor,
|
||||
limit,
|
||||
query,
|
||||
})
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
let count = page.workers.len();
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Listed {count} Workspace Worker(s)"),
|
||||
content: Some(serde_json::to_string_pretty(&page).map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"encode Workspace Worker discovery result: {error}"
|
||||
))
|
||||
})?),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn input_schema() -> serde_json::Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"cursor": {
|
||||
"type": "string",
|
||||
"description": "Opaque cursor returned by a prior page."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": MAX_LIMIT,
|
||||
"default": DEFAULT_LIMIT
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"description": "Exact W-key or Worker display name lookup."
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use workspace_api::{
|
||||
WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct RecordingClient {
|
||||
requests: Mutex<Vec<WorkspaceWorkerDiscoveryRequest>>,
|
||||
result: WorkspaceWorkerDiscoveryPage,
|
||||
unavailable: bool,
|
||||
}
|
||||
|
||||
impl WorkspaceClient for RecordingClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace-1")
|
||||
}
|
||||
|
||||
fn kind(&self) -> &str {
|
||||
"recording"
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
!self.unavailable
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
_request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
panic!("discovery must not use generic Workspace request authority")
|
||||
}
|
||||
|
||||
fn list_workspace_workers(
|
||||
&self,
|
||||
request: WorkspaceWorkerDiscoveryRequest,
|
||||
) -> Result<WorkspaceWorkerDiscoveryPage, WorkspaceClientError> {
|
||||
self.requests.lock().unwrap().push(request);
|
||||
if self.unavailable {
|
||||
Err(WorkspaceClientError::Unavailable("denied".to_string()))
|
||||
} else {
|
||||
Ok(self.result.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn page() -> WorkspaceWorkerDiscoveryPage {
|
||||
WorkspaceWorkerDiscoveryPage {
|
||||
workers: vec![WorkspaceWorkerDiscoveryItem {
|
||||
subject: WorkspaceWorkerSubject::RuntimeWorker {
|
||||
runtime_id: "arcadia".to_string(),
|
||||
worker_id: "worker-1".to_string(),
|
||||
},
|
||||
resource_key: "W-12".to_string(),
|
||||
display_name: "coder-one".to_string(),
|
||||
profile: Some("builtin:coder".to_string()),
|
||||
status: Some("idle".to_string()),
|
||||
}],
|
||||
next_cursor: Some("v1:1".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_preserves_typed_subject_and_forwards_lookup() {
|
||||
let client = Arc::new(RecordingClient {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
result: page(),
|
||||
unavailable: false,
|
||||
});
|
||||
let tool = ListWorkspaceWorkersTool {
|
||||
client: client.clone(),
|
||||
};
|
||||
let output = tool
|
||||
.execute(
|
||||
r#"{"query":" W-12 ","limit":1}"#,
|
||||
ToolExecutionContext::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(value["workers"][0]["resource_key"], "W-12");
|
||||
assert_eq!(value["workers"][0]["subject"]["kind"], "runtime_worker");
|
||||
assert_eq!(value["workers"][0]["subject"]["runtime_id"], "arcadia");
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].query.as_deref(), Some("W-12"));
|
||||
assert_eq!(requests[0].limit, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_backend_authority_fails_closed() {
|
||||
let client = Arc::new(RecordingClient {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
result: page(),
|
||||
unavailable: true,
|
||||
});
|
||||
let error = ListWorkspaceWorkersTool { client }
|
||||
.execute("{}", ToolExecutionContext::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("denied"));
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,7 @@ impl From<HookTurnEndAction> for TurnEndAction {
|
||||
///
|
||||
/// Hook code can use this handle only when the Worker host includes it in an
|
||||
/// event-specific context. The handle queues typed requests; the host drains the
|
||||
/// queue, commits each entry through `LogEntry::SystemItem`, and only then makes
|
||||
/// queue, commits each entry through `LogEntry::AnnotatedSystemItem`, and only then makes
|
||||
/// the matching system message visible to the model. It deliberately exposes no
|
||||
/// raw `agen::Item`, history writer, event sender, `Worker`, `Engine`, or
|
||||
/// notification buffer.
|
||||
|
||||
@@ -539,9 +539,9 @@ mod tests {
|
||||
text: "done".into(),
|
||||
}],
|
||||
};
|
||||
let assistant_entry = LogEntry::AssistantItem {
|
||||
let assistant_entry = LogEntry::AnnotatedAssistantItem {
|
||||
ts: 1,
|
||||
item: assistant_item.clone(),
|
||||
entry: crate::session_history::test_logged_history_entry(assistant_item.clone()),
|
||||
};
|
||||
|
||||
let in_flight_guard = in_flight.snapshot_guard();
|
||||
@@ -593,9 +593,9 @@ mod tests {
|
||||
text: "done".into(),
|
||||
}],
|
||||
};
|
||||
let assistant_entry = LogEntry::AssistantItem {
|
||||
let assistant_entry = LogEntry::AnnotatedAssistantItem {
|
||||
ts: 1,
|
||||
item: assistant_item.clone(),
|
||||
entry: crate::session_history::test_logged_history_entry(assistant_item.clone()),
|
||||
};
|
||||
|
||||
in_flight.clear_for_committed_item_then(&assistant_item, || {
|
||||
@@ -608,7 +608,7 @@ mod tests {
|
||||
|
||||
assert!(matches!(
|
||||
entries_snapshot.as_slice(),
|
||||
[LogEntry::AssistantItem { item, .. }] if item == &assistant_item
|
||||
[LogEntry::AnnotatedAssistantItem { entry, .. }] if entry.item == assistant_item
|
||||
));
|
||||
assert!(in_flight_snapshot.is_empty());
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ enum InternalWorkerSessionCommand {
|
||||
/// task; protocol access is consumed only by the owning parent registry.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct InternalWorkerSessionSnapshot {
|
||||
pub entries: Vec<LogEntry>,
|
||||
pub session: protocol::SessionSnapshot,
|
||||
pub status: WorkerStatus,
|
||||
pub error: Option<String>,
|
||||
pub in_flight: InFlightSnapshot,
|
||||
@@ -402,7 +402,7 @@ impl InternalWorkerSessionHandle {
|
||||
(entries, snapshot_from_guard(&guard))
|
||||
};
|
||||
InternalWorkerSessionSnapshot {
|
||||
entries,
|
||||
session: session_store::public_snapshot::project_current_session_snapshot(&entries),
|
||||
status: match self.status() {
|
||||
InternalWorkerSessionStatus::Running => WorkerStatus::Running,
|
||||
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
|
||||
|
||||
@@ -60,7 +60,7 @@ pub(crate) struct WorkerInterceptor {
|
||||
pending_notifies: NotifyBuffer,
|
||||
/// Submit-scoped stash of resolver-produced typed system items.
|
||||
/// Drained inside `on_prompt_submit`, committed as
|
||||
/// `LogEntry::SystemItem` entries through `log_writer`, and
|
||||
/// `LogEntry::AnnotatedSystemItem` entries through `log_writer`, and
|
||||
/// returned to the worker as `Item::system_message` via
|
||||
/// `PromptAction::ContinueWith`. Populated by `Worker::run`
|
||||
/// immediately before handing off to the worker.
|
||||
@@ -71,7 +71,7 @@ pub(crate) struct WorkerInterceptor {
|
||||
/// Workspace scope associated with Prompt projection provenance.
|
||||
prompt_workspace_id: Option<String>,
|
||||
/// Type-erased commit handle. The interceptor uses it to commit
|
||||
/// `LogEntry::SystemItem` entries directly (sync) before
|
||||
/// `LogEntry::AnnotatedSystemItem` entries directly (sync) before
|
||||
/// returning the corresponding `Item::system_message`s up to the
|
||||
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
||||
/// attached.
|
||||
@@ -142,7 +142,7 @@ impl WorkerInterceptor {
|
||||
self
|
||||
}
|
||||
|
||||
/// Commit each `SystemItem` as its own `LogEntry::SystemItem`
|
||||
/// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem`
|
||||
/// entry through the attached writer (no-op when no writer is
|
||||
/// wired). Sync — writes complete before the matching
|
||||
/// `Item::system_message`s reach the worker via
|
||||
@@ -540,7 +540,6 @@ mod tests {
|
||||
entry: session_store::LogEntry,
|
||||
) -> Result<(), session_store::StoreError> {
|
||||
let item = match entry {
|
||||
session_store::LogEntry::SystemItem { item, .. } => Some(item),
|
||||
session_store::LogEntry::AnnotatedSystemItem { entry, .. } => Some(entry.item),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! `WorkerInterceptor::pending_history_appends`, which the Engine calls
|
||||
//! at the head of each turn loop iteration. The drain renders each
|
||||
//! pending entry into a typed `SystemItem` (with the `notify_wrapper`
|
||||
//! prompt applied), commits a `LogEntry::SystemItem` per entry through
|
||||
//! prompt applied), commits a `LogEntry::AnnotatedSystemItem` per entry through
|
||||
//! the session-log sink, and returns the corresponding
|
||||
//! `Item::system_message`s for the worker to append to its
|
||||
//! persistent history.
|
||||
|
||||
@@ -29,17 +29,12 @@ pub fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtoco
|
||||
|
||||
pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
|
||||
match entry {
|
||||
entry @ (LogEntry::SegmentStart { .. } | LogEntry::AnnotatedSegmentStart { .. }) => {
|
||||
let value = serde_json::to_value(&entry).expect("LogEntry is Serialize");
|
||||
Some(Event::SegmentRotated { entry: value })
|
||||
}
|
||||
LogEntry::UserInput { segments, .. } | LogEntry::AnnotatedUserInput { segments, .. } => {
|
||||
Some(Event::UserMessage { segments })
|
||||
}
|
||||
LogEntry::SystemItem { item, .. } => {
|
||||
let value = serde_json::to_value(&item).expect("SystemItem is Serialize");
|
||||
Some(Event::SystemItem { item: value })
|
||||
entry @ LogEntry::AnnotatedSegmentStart { .. } => {
|
||||
let session =
|
||||
session_store::public_snapshot::project_current_session_snapshot(&[entry]);
|
||||
Some(Event::SegmentRotated { session })
|
||||
}
|
||||
LogEntry::AnnotatedUserInput { segments, .. } => Some(Event::UserMessage { segments }),
|
||||
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
|
||||
Some(Event::SystemItem { item: value })
|
||||
@@ -88,9 +83,12 @@ mod tests {
|
||||
#[test]
|
||||
fn user_input_log_entry_maps_to_user_message_event() {
|
||||
let segments = vec![protocol::Segment::text("hello from log")];
|
||||
let event = live_log_entry_event(LogEntry::UserInput {
|
||||
let event = live_log_entry_event(LogEntry::AnnotatedUserInput {
|
||||
ts: session_store::segment_log::now_millis(),
|
||||
extensions: vec![],
|
||||
history: vec![crate::session_history::test_logged_history_entry(
|
||||
agen::Item::user_message("hello from log"),
|
||||
)],
|
||||
segments: segments.clone(),
|
||||
})
|
||||
.expect("UserInput must be live-relevant");
|
||||
|
||||
@@ -77,7 +77,7 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: WorkerHandle)
|
||||
let mut writer = JsonLineWriter::new(writer);
|
||||
|
||||
// Hold the in-flight stream lock while taking the session-log mirror
|
||||
// snapshot. `LogEntry::AssistantItem` is mirror-only for live clients,
|
||||
// snapshot. `LogEntry::AnnotatedAssistantItem` is mirror-only for live clients,
|
||||
// so a finalized assistant block must be observed either as an already
|
||||
// committed entry or as the still-present in-flight block. This lock
|
||||
// order matches `append_entry` (in-flight clear before sink publish) and
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod bootstrap;
|
||||
pub mod compact;
|
||||
pub mod controller;
|
||||
pub mod discovery;
|
||||
@@ -10,6 +11,7 @@ pub mod ipc;
|
||||
pub mod model_client;
|
||||
pub mod prompt;
|
||||
pub mod runtime;
|
||||
pub mod runtime_command;
|
||||
pub mod segment_log_sink;
|
||||
mod session_capture;
|
||||
mod session_history;
|
||||
@@ -23,6 +25,10 @@ mod interrupt_prep;
|
||||
mod permission;
|
||||
mod worker;
|
||||
|
||||
pub use bootstrap::{
|
||||
BootstrappedWorker, PreparedWorker, WorkerBootstrap, WorkerBootstrapError,
|
||||
WorkerBootstrapLayout, start_worker_controller,
|
||||
};
|
||||
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
||||
pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle};
|
||||
pub use hook::{Hook, HookEventKind, HookRegistryBuilder};
|
||||
@@ -52,6 +58,6 @@ pub use worker::{
|
||||
LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError,
|
||||
WorkerFilesystemAuthority, WorkerRunResult, WorkerWorkspaceContext, WorkspaceClient,
|
||||
WorkspaceClientError, WorkspaceId, WorkspaceIdError, WorkspacePromptCatalogResolution,
|
||||
WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, apply_worker_manifest,
|
||||
marker_workspace_client, unavailable_workspace_client,
|
||||
WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, WorkspaceWorkerDiscoveryRequest,
|
||||
apply_worker_manifest, marker_workspace_client, unavailable_workspace_client,
|
||||
};
|
||||
|
||||
@@ -901,6 +901,23 @@ mod tests {
|
||||
.unwrap()
|
||||
.contains("BOUNDARY_MARKER")
|
||||
);
|
||||
catalog.worker_orchestration_guidance_section().unwrap();
|
||||
let orchestration = catalog.worker_orchestration_guidance_section().unwrap();
|
||||
for name in [
|
||||
"SubWorkerSpawn",
|
||||
"WorkerList",
|
||||
"WorkerSendInput",
|
||||
"WorkerStop",
|
||||
] {
|
||||
assert!(
|
||||
orchestration.contains(name),
|
||||
"missing canonical tool {name}"
|
||||
);
|
||||
}
|
||||
for alias in ["SubWorkerList", "SubWorkerSend", "SubWorkerStop"] {
|
||||
assert!(
|
||||
!orchestration.contains(alias),
|
||||
"guidance referenced stale alias {alias}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,12 +181,19 @@ impl ToolCapabilities {
|
||||
"MemoryReadDocument" => capabilities.memory_read_document = true,
|
||||
"MemoryUpdateDocument" => capabilities.memory_update_document = true,
|
||||
"SubWorkerSpawn" => capabilities.sub_worker_spawn = true,
|
||||
"SubWorkerSend" => capabilities.sub_worker_send = true,
|
||||
"SubWorkerStop" => capabilities.sub_worker_stop = true,
|
||||
"SubWorkerList" => capabilities.sub_worker_list = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if capabilities.sub_worker_spawn {
|
||||
for name in names {
|
||||
match name.as_str() {
|
||||
"WorkerSendInput" => capabilities.sub_worker_send = true,
|
||||
"WorkerStop" => capabilities.sub_worker_stop = true,
|
||||
"WorkerList" => capabilities.sub_worker_list = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
capabilities
|
||||
}
|
||||
|
||||
@@ -316,6 +323,35 @@ fn append_trailing_section(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sub_worker_capabilities_follow_the_registered_canonical_control_tools() {
|
||||
let names = [
|
||||
"SubWorkerSpawn",
|
||||
"WorkerList",
|
||||
"WorkerSendInput",
|
||||
"WorkerStop",
|
||||
]
|
||||
.map(str::to_string);
|
||||
let capabilities = ToolCapabilities::from_tool_names(&names);
|
||||
assert!(capabilities.sub_worker_management());
|
||||
assert!(capabilities.sub_worker_list);
|
||||
assert!(capabilities.sub_worker_send);
|
||||
assert!(capabilities.sub_worker_stop);
|
||||
|
||||
let stale_aliases = [
|
||||
"SubWorkerSpawn",
|
||||
"SubWorkerList",
|
||||
"SubWorkerSend",
|
||||
"SubWorkerStop",
|
||||
]
|
||||
.map(str::to_string);
|
||||
let capabilities = ToolCapabilities::from_tool_names(&stale_aliases);
|
||||
assert!(capabilities.sub_worker_spawn);
|
||||
assert!(!capabilities.sub_worker_list);
|
||||
assert!(!capabilities.sub_worker_send);
|
||||
assert!(!capabilities.sub_worker_stop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_legacy_prefix_relative_and_missing_names() {
|
||||
for reference in ["legacy/custom", "custom.md", "../custom", "missing"] {
|
||||
|
||||
@@ -50,7 +50,7 @@ struct SinkInner {
|
||||
/// Broadcast channel for live entry updates. The same `Sender`
|
||||
/// survives session swaps so existing subscribers keep their
|
||||
/// receiver — they observe the swap as a freshly broadcast
|
||||
/// `LogEntry::SegmentStart` and reset their view accordingly.
|
||||
/// `LogEntry::AnnotatedSegmentStart` and reset their view accordingly.
|
||||
broadcast_tx: broadcast::Sender<LogEntry>,
|
||||
}
|
||||
|
||||
@@ -89,9 +89,9 @@ impl SegmentLogSink {
|
||||
///
|
||||
/// Live broadcast fires for committed session-log entries that
|
||||
/// socket clients must see in log order:
|
||||
/// - `LogEntry::SegmentStart` → `Event::SegmentRotated` on the wire.
|
||||
/// - `LogEntry::UserInput` → `Event::UserMessage`.
|
||||
/// - `LogEntry::SystemItem` → `Event::SystemItem`.
|
||||
/// - `LogEntry::AnnotatedSegmentStart` → `Event::SegmentRotated` on the wire.
|
||||
/// - `LogEntry::AnnotatedUserInput` → `Event::UserMessage`.
|
||||
/// - `LogEntry::AnnotatedSystemItem` → `Event::SystemItem`.
|
||||
/// - `LogEntry::Invoke` → `Event::InvokeStart`.
|
||||
/// Everything else (AssistantItem, ToolResult, TurnEnd,
|
||||
/// RunCompleted, RunErrored, PausedTurnAbandoned, LlmUsage, Extension,
|
||||
@@ -120,11 +120,8 @@ impl SegmentLogSink {
|
||||
fn is_live_relevant(entry: &LogEntry) -> bool {
|
||||
matches!(
|
||||
entry,
|
||||
LogEntry::SegmentStart { .. }
|
||||
| LogEntry::AnnotatedSegmentStart { .. }
|
||||
| LogEntry::UserInput { .. }
|
||||
LogEntry::AnnotatedSegmentStart { .. }
|
||||
| LogEntry::AnnotatedUserInput { .. }
|
||||
| LogEntry::SystemItem { .. }
|
||||
| LogEntry::AnnotatedSystemItem { .. }
|
||||
| LogEntry::Invoke { .. }
|
||||
)
|
||||
@@ -132,7 +129,7 @@ impl SegmentLogSink {
|
||||
|
||||
/// Atomically swap the mirror to `[initial]` and broadcast the new
|
||||
/// session-start entry. Used during compaction / fork: the new
|
||||
/// `LogEntry::SegmentStart` is the first entry of the replacement
|
||||
/// `LogEntry::AnnotatedSegmentStart` is the first entry of the replacement
|
||||
/// session, and existing subscribers transition by replaying it
|
||||
/// like any other live entry.
|
||||
///
|
||||
@@ -234,7 +231,7 @@ mod tests {
|
||||
use session_store::segment_log::now_millis;
|
||||
|
||||
fn session_start() -> LogEntry {
|
||||
LogEntry::SegmentStart {
|
||||
LogEntry::AnnotatedSegmentStart {
|
||||
ts: now_millis(),
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -253,9 +250,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn user_input(text: &str) -> LogEntry {
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: now_millis(),
|
||||
extensions: vec![],
|
||||
history: vec![crate::session_history::test_logged_history_entry(
|
||||
agen::Item::user_message(text),
|
||||
)],
|
||||
segments: vec![protocol::Segment::Text {
|
||||
content: text.to_owned(),
|
||||
}],
|
||||
@@ -270,7 +270,10 @@ mod tests {
|
||||
|
||||
let (snapshot, mut rx) = sink.subscribe_with_snapshot();
|
||||
assert_eq!(snapshot.len(), 2);
|
||||
assert!(matches!(snapshot[0], LogEntry::SegmentStart { .. }));
|
||||
assert!(matches!(
|
||||
snapshot[0],
|
||||
LogEntry::AnnotatedSegmentStart { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
snapshot[1],
|
||||
LogEntry::TurnEnd { turn_count: 1, .. }
|
||||
@@ -279,13 +282,15 @@ mod tests {
|
||||
}
|
||||
|
||||
fn notification_entry(text: &str) -> LogEntry {
|
||||
LogEntry::SystemItem {
|
||||
LogEntry::AnnotatedSystemItem {
|
||||
ts: now_millis(),
|
||||
item: session_store::SystemItem::Notification {
|
||||
message: text.to_owned(),
|
||||
body: format!("[Notification] {text}"),
|
||||
prompt_provenance: None,
|
||||
},
|
||||
entry: crate::session_history::test_logged_system_entry(
|
||||
session_store::SystemItem::Notification {
|
||||
message: text.to_owned(),
|
||||
body: format!("[Notification] {text}"),
|
||||
prompt_provenance: None,
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +310,7 @@ mod tests {
|
||||
// for Event::UserMessage.
|
||||
sink.publish(user_input("hi from log"));
|
||||
match rx.try_recv() {
|
||||
Ok(LogEntry::UserInput { segments, .. }) => {
|
||||
Ok(LogEntry::AnnotatedUserInput { segments, .. }) => {
|
||||
assert_eq!(segments.len(), 1);
|
||||
}
|
||||
other => panic!("expected UserInput, got {other:?}"),
|
||||
@@ -314,7 +319,7 @@ mod tests {
|
||||
// SystemItem is live-relevant.
|
||||
sink.publish(notification_entry("hi"));
|
||||
match rx.try_recv() {
|
||||
Ok(LogEntry::SystemItem { .. }) => {}
|
||||
Ok(LogEntry::AnnotatedSystemItem { .. }) => {}
|
||||
other => panic!("expected SystemItem, got {other:?}"),
|
||||
}
|
||||
|
||||
@@ -332,7 +337,7 @@ mod tests {
|
||||
|
||||
assert_eq!(snapshot.len(), 1);
|
||||
match rx.try_recv() {
|
||||
Ok(LogEntry::SystemItem { .. }) => {}
|
||||
Ok(LogEntry::AnnotatedSystemItem { .. }) => {}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
assert!(rx.try_recv().is_err());
|
||||
@@ -348,13 +353,16 @@ mod tests {
|
||||
sink.reset_with_initial(session_start());
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(LogEntry::SegmentStart { .. }) => {}
|
||||
Ok(LogEntry::AnnotatedSegmentStart { .. }) => {}
|
||||
other => panic!("expected SegmentStart broadcast, got {other:?}"),
|
||||
}
|
||||
|
||||
let (post_snapshot, _) = sink.subscribe_with_snapshot();
|
||||
assert_eq!(post_snapshot.len(), 1);
|
||||
assert!(matches!(post_snapshot[0], LogEntry::SegmentStart { .. }));
|
||||
assert!(matches!(
|
||||
post_snapshot[0],
|
||||
LogEntry::AnnotatedSegmentStart { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,6 +8,10 @@ use std::sync::Arc;
|
||||
|
||||
use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
|
||||
use agen::{HistoryEntry, Item, Role};
|
||||
use protocol::{
|
||||
SessionContentPart, SessionEntryProvenance, SessionMessageRole, SessionSnapshot,
|
||||
SessionSnapshotEntryData,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DEFAULT_SEARCH_LIMIT: usize = 20;
|
||||
@@ -105,7 +109,7 @@ impl ToolPart {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct OverviewItem {
|
||||
pub id: SessionEntryRef,
|
||||
pub origin: WorkerHistoryProvenance,
|
||||
pub origin: SessionEntryProvenance,
|
||||
pub entry_range: [u64; 2],
|
||||
pub kind: ReferenceKind,
|
||||
pub label: String,
|
||||
@@ -116,7 +120,7 @@ pub(crate) struct OverviewItem {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ReferenceEntry {
|
||||
pub id: SessionEntryRef,
|
||||
pub origin: WorkerHistoryProvenance,
|
||||
pub origin: SessionEntryProvenance,
|
||||
pub entry_range: [u64; 2],
|
||||
pub kind: ReferenceKind,
|
||||
pub tool_part: Option<ToolPart>,
|
||||
@@ -142,7 +146,7 @@ pub(crate) struct SearchOptions {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SearchHit {
|
||||
pub id: SessionEntryRef,
|
||||
pub origin: WorkerHistoryProvenance,
|
||||
pub origin: SessionEntryProvenance,
|
||||
pub kind: ReferenceKind,
|
||||
pub tool_part: Option<ToolPart>,
|
||||
pub tool_name: Option<String>,
|
||||
@@ -188,7 +192,7 @@ impl Default for ReadOptions {
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ReadEntry {
|
||||
pub id: SessionEntryRef,
|
||||
pub origin: WorkerHistoryProvenance,
|
||||
pub origin: SessionEntryProvenance,
|
||||
pub kind: ReferenceKind,
|
||||
pub tool_part: Option<ToolPart>,
|
||||
pub tool_name: Option<String>,
|
||||
@@ -207,7 +211,7 @@ pub(crate) struct ReadResult {
|
||||
pub(crate) struct SessionEntryEvidence {
|
||||
pub segment_id: String,
|
||||
pub entry_ref: SessionEntryRef,
|
||||
pub origin: WorkerHistoryProvenance,
|
||||
pub origin: SessionEntryProvenance,
|
||||
pub entry_range: [u64; 2],
|
||||
pub kind: ReferenceKind,
|
||||
pub tool_part: Option<ToolPart>,
|
||||
@@ -216,35 +220,116 @@ pub(crate) struct SessionEntryEvidence {
|
||||
pub excerpt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CapturedHistoryEntry {
|
||||
item: Item,
|
||||
entry_id: session_store::LoggedSessionHistoryEntryId,
|
||||
origin: SessionEntryProvenance,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SessionCapture {
|
||||
segment_id: String,
|
||||
entries: Arc<Vec<HistoryEntry<SessionHistoryMetadata>>>,
|
||||
entries: Arc<Vec<CapturedHistoryEntry>>,
|
||||
overview: Vec<OverviewItem>,
|
||||
index: Vec<ReferenceEntry>,
|
||||
}
|
||||
|
||||
impl SessionCapture {
|
||||
pub(crate) fn from_session_snapshot(
|
||||
segment_id: impl Into<String>,
|
||||
snapshot: SessionSnapshot,
|
||||
) -> Self {
|
||||
let entries = snapshot
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let item = match entry.data {
|
||||
SessionSnapshotEntryData::UserInput { segments } => {
|
||||
Item::user_message(protocol::Segment::flatten_to_text(&segments))
|
||||
}
|
||||
SessionSnapshotEntryData::Message { role, content } => {
|
||||
let role = match role {
|
||||
SessionMessageRole::User => Role::User,
|
||||
SessionMessageRole::Assistant => Role::Assistant,
|
||||
};
|
||||
Item::Message {
|
||||
id: None,
|
||||
role,
|
||||
content: content
|
||||
.into_iter()
|
||||
.map(|part| match part {
|
||||
SessionContentPart::Text { text } => {
|
||||
agen::ContentPart::Text { text }
|
||||
}
|
||||
SessionContentPart::Refusal { refusal } => {
|
||||
agen::ContentPart::Refusal { refusal }
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
SessionSnapshotEntryData::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
} => Item::tool_call(call_id, name, arguments),
|
||||
SessionSnapshotEntryData::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
is_error,
|
||||
attachments: _,
|
||||
} => Item::tool_result_item(call_id, summary, content, is_error),
|
||||
// Observation deliberately excludes system items and
|
||||
// controller errors from model-visible session evidence.
|
||||
SessionSnapshotEntryData::SystemItem { .. }
|
||||
| SessionSnapshotEntryData::RunError { .. } => return None,
|
||||
};
|
||||
Some(CapturedHistoryEntry {
|
||||
item,
|
||||
entry_id: session_store::LoggedSessionHistoryEntryId(entry.entry_id),
|
||||
origin: entry.provenance,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Self::from_captured_entries(segment_id, entries)
|
||||
}
|
||||
|
||||
pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self {
|
||||
let entries = items
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
let mut metadata = SessionHistoryMetadata::legacy_unknown();
|
||||
metadata.entry_id =
|
||||
session_store::LoggedSessionHistoryEntryId(format!("{index:08}"));
|
||||
HistoryEntry::new(item, metadata)
|
||||
.map(|(index, item)| CapturedHistoryEntry {
|
||||
item,
|
||||
entry_id: session_store::LoggedSessionHistoryEntryId(format!("{index:08}")),
|
||||
origin: SessionEntryProvenance::LegacyUnknown,
|
||||
})
|
||||
.collect();
|
||||
Self::from_history_entries(segment_id, entries)
|
||||
Self::from_captured_entries(segment_id, entries)
|
||||
}
|
||||
|
||||
pub(crate) fn from_history_entries(
|
||||
segment_id: impl Into<String>,
|
||||
entries: Vec<HistoryEntry<SessionHistoryMetadata>>,
|
||||
) -> Self {
|
||||
let entries = entries
|
||||
.into_iter()
|
||||
.map(|entry| CapturedHistoryEntry {
|
||||
item: entry.item,
|
||||
entry_id: entry.annotation.entry_id,
|
||||
origin: public_provenance(&entry.annotation.origin),
|
||||
})
|
||||
.collect();
|
||||
Self::from_captured_entries(segment_id, entries)
|
||||
}
|
||||
|
||||
fn from_captured_entries(
|
||||
segment_id: impl Into<String>,
|
||||
entries: Vec<CapturedHistoryEntry>,
|
||||
) -> Self {
|
||||
let segment_id = segment_id.into();
|
||||
let entries = Arc::new(entries);
|
||||
let mut overview = Vec::new();
|
||||
let mut index = Vec::new();
|
||||
|
||||
@@ -253,7 +338,7 @@ impl SessionCapture {
|
||||
let entry_range = [idx as u64, idx as u64];
|
||||
match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
let Some(kind) = message_reference_kind(&entry.annotation.origin, role) else {
|
||||
let Some(kind) = message_reference_kind(&entry.origin, role) else {
|
||||
continue;
|
||||
};
|
||||
let text = content
|
||||
@@ -263,10 +348,10 @@ impl SessionCapture {
|
||||
.join("");
|
||||
let label = format!("{} message", kind.as_str());
|
||||
let summary = truncate_chars(&text, 240);
|
||||
let id = SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id);
|
||||
let id = SessionEntryRef::from_history_entry_id(&entry.entry_id);
|
||||
index.push(ReferenceEntry {
|
||||
id: id.clone(),
|
||||
origin: entry.annotation.origin.clone(),
|
||||
origin: entry.origin.clone(),
|
||||
entry_range,
|
||||
kind,
|
||||
tool_part: None,
|
||||
@@ -278,7 +363,7 @@ impl SessionCapture {
|
||||
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
|
||||
overview.push(OverviewItem {
|
||||
id: id.clone(),
|
||||
origin: entry.annotation.origin.clone(),
|
||||
origin: entry.origin.clone(),
|
||||
entry_range,
|
||||
kind,
|
||||
label,
|
||||
@@ -292,8 +377,8 @@ impl SessionCapture {
|
||||
} => {
|
||||
let text = format!("{name}\n{arguments}");
|
||||
index.push(ReferenceEntry {
|
||||
id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
|
||||
origin: entry.annotation.origin.clone(),
|
||||
id: SessionEntryRef::from_history_entry_id(&entry.entry_id),
|
||||
origin: entry.origin.clone(),
|
||||
entry_range,
|
||||
kind: ReferenceKind::Tool,
|
||||
tool_part: Some(ToolPart::Input),
|
||||
@@ -319,8 +404,8 @@ impl SessionCapture {
|
||||
content.as_deref().unwrap_or_default(),
|
||||
);
|
||||
index.push(ReferenceEntry {
|
||||
id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
|
||||
origin: entry.annotation.origin.clone(),
|
||||
id: SessionEntryRef::from_history_entry_id(&entry.entry_id),
|
||||
origin: entry.origin.clone(),
|
||||
entry_range,
|
||||
kind: ReferenceKind::Tool,
|
||||
tool_part: Some(ToolPart::Output),
|
||||
@@ -360,7 +445,7 @@ impl SessionCapture {
|
||||
|
||||
Self {
|
||||
segment_id,
|
||||
entries,
|
||||
entries: Arc::new(entries),
|
||||
overview,
|
||||
index,
|
||||
}
|
||||
@@ -543,25 +628,41 @@ impl SessionCapture {
|
||||
}
|
||||
}
|
||||
|
||||
fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance {
|
||||
match origin {
|
||||
WorkerHistoryProvenance::HumanInput { .. } => SessionEntryProvenance::HumanInput,
|
||||
WorkerHistoryProvenance::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
|
||||
WorkerHistoryProvenance::FlowInstruction { .. } => SessionEntryProvenance::FlowInstruction,
|
||||
WorkerHistoryProvenance::BackendInstruction { .. } => {
|
||||
SessionEntryProvenance::BackendInstruction
|
||||
}
|
||||
WorkerHistoryProvenance::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
|
||||
WorkerHistoryProvenance::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
|
||||
WorkerHistoryProvenance::DerivedSummary => SessionEntryProvenance::DerivedSummary,
|
||||
WorkerHistoryProvenance::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn message_reference_kind(
|
||||
origin: &WorkerHistoryProvenance,
|
||||
origin: &SessionEntryProvenance,
|
||||
provider_role: &Role,
|
||||
) -> Option<ReferenceKind> {
|
||||
match origin {
|
||||
WorkerHistoryProvenance::HumanInput { .. }
|
||||
| WorkerHistoryProvenance::WorkerInput { .. } => Some(ReferenceKind::User),
|
||||
WorkerHistoryProvenance::ModelOutput { .. } => Some(ReferenceKind::Assistant),
|
||||
WorkerHistoryProvenance::ToolOutput { .. } => Some(ReferenceKind::Tool),
|
||||
WorkerHistoryProvenance::LegacyUnknown => match provider_role {
|
||||
SessionEntryProvenance::HumanInput | SessionEntryProvenance::WorkerInput => {
|
||||
Some(ReferenceKind::User)
|
||||
}
|
||||
SessionEntryProvenance::ModelOutput => Some(ReferenceKind::Assistant),
|
||||
SessionEntryProvenance::ToolOutput => Some(ReferenceKind::Tool),
|
||||
SessionEntryProvenance::LegacyUnknown => match provider_role {
|
||||
Role::User => Some(ReferenceKind::User),
|
||||
Role::Assistant => Some(ReferenceKind::Assistant),
|
||||
Role::System => None,
|
||||
},
|
||||
// Flow/backend/system content remains out of the observation surface
|
||||
// even when represented with a provider user/system role.
|
||||
WorkerHistoryProvenance::FlowInstruction { .. }
|
||||
| WorkerHistoryProvenance::BackendInstruction { .. }
|
||||
| WorkerHistoryProvenance::DerivedSummary => None,
|
||||
SessionEntryProvenance::FlowInstruction
|
||||
| SessionEntryProvenance::BackendInstruction
|
||||
| SessionEntryProvenance::DerivedSummary => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,13 +759,13 @@ mod tests {
|
||||
assert_eq!(overview.len(), 1);
|
||||
assert!(matches!(
|
||||
overview[0].origin,
|
||||
WorkerHistoryProvenance::HumanInput { .. }
|
||||
SessionEntryProvenance::HumanInput
|
||||
));
|
||||
let evidence = capture.evidence_for(overview[0].id.as_str()).unwrap();
|
||||
assert!(evidence.excerpt.ends_with("remember my preference"));
|
||||
assert!(matches!(
|
||||
evidence.origin,
|
||||
WorkerHistoryProvenance::HumanInput { .. }
|
||||
SessionEntryProvenance::HumanInput
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
//! retained only as explicit `LegacyUnknown` entries.
|
||||
|
||||
use agen::{HistoryEntry, Item};
|
||||
use protocol::Segment;
|
||||
use session_store::{
|
||||
LogEntry, LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
|
||||
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedWorkerSubject, SegmentId,
|
||||
@@ -18,6 +17,32 @@ pub type WorkerHistoryProvenance = LoggedSessionHistoryOrigin;
|
||||
pub type SessionHistoryDerivation = LoggedHistoryDerivation;
|
||||
pub type WorkerSubjectSnapshot = LoggedWorkerSubject;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_logged_history_entry(item: impl Into<Item>) -> LoggedHistoryEntry {
|
||||
LoggedHistoryEntry {
|
||||
item: session_store::LoggedItem::from(item.into()),
|
||||
metadata: LoggedSessionHistoryMetadata {
|
||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
derivation: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_logged_system_entry(
|
||||
item: session_store::SystemItem,
|
||||
) -> session_store::LoggedSystemHistoryEntry {
|
||||
session_store::LoggedSystemHistoryEntry {
|
||||
item,
|
||||
metadata: LoggedSessionHistoryMetadata {
|
||||
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||
derivation: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn worker_subject(session_id: SessionId) -> WorkerSubjectSnapshot {
|
||||
WorkerSubjectSnapshot {
|
||||
workspace_id: None,
|
||||
@@ -53,10 +78,6 @@ pub(crate) fn to_logged_history_entry(
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_entry(item: Item) -> HistoryEntry<SessionHistoryMetadata> {
|
||||
HistoryEntry::new(item, SessionHistoryMetadata::legacy_unknown())
|
||||
}
|
||||
|
||||
fn from_logged(entry: &LoggedHistoryEntry) -> HistoryEntry<SessionHistoryMetadata> {
|
||||
HistoryEntry::new(Item::from(entry.item.clone()), entry.metadata.clone())
|
||||
}
|
||||
@@ -74,32 +95,15 @@ pub(crate) fn restore_history_entries(
|
||||
LogEntry::AnnotatedSegmentStart { history: seed, .. } => {
|
||||
history = seed.iter().map(from_logged).collect();
|
||||
}
|
||||
LogEntry::SegmentStart { history: seed, .. } => {
|
||||
history = seed
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(Item::from)
|
||||
.map(legacy_entry)
|
||||
.collect();
|
||||
}
|
||||
LogEntry::AnnotatedUserInput { history: input, .. } => {
|
||||
history.extend(input.iter().map(from_logged))
|
||||
}
|
||||
LogEntry::UserInput { segments, .. } => history.push(legacy_entry(Item::user_message(
|
||||
Segment::flatten_to_text(segments),
|
||||
))),
|
||||
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||
| LogEntry::AnnotatedToolResult { entry, .. } => history.push(from_logged(entry)),
|
||||
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
|
||||
history.push(legacy_entry(Item::from(item.clone())));
|
||||
}
|
||||
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
|
||||
entry.item.to_history_item(),
|
||||
entry.metadata.clone(),
|
||||
)),
|
||||
LogEntry::SystemItem { item, .. } => {
|
||||
history.push(legacy_entry(item.to_history_item()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -110,23 +114,9 @@ pub(crate) fn restore_history_entries(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use agen::llm_client::RequestConfig;
|
||||
use protocol::Segment;
|
||||
use session_store::LogEntry;
|
||||
|
||||
#[test]
|
||||
fn legacy_user_role_is_not_inferred_as_human_authority() {
|
||||
let entries = vec![LogEntry::UserInput {
|
||||
ts: 1,
|
||||
segments: vec![Segment::text("legacy")],
|
||||
extensions: Vec::new(),
|
||||
}];
|
||||
let restored =
|
||||
restore_history_entries(SessionId::now_v7(), SegmentId::now_v7(), &entries).unwrap();
|
||||
assert!(matches!(
|
||||
restored[0].annotation.origin,
|
||||
WorkerHistoryProvenance::LegacyUnknown
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_flow_and_unknown_caller_input_round_trip_without_role_inference() {
|
||||
let session_id = SessionId::now_v7();
|
||||
|
||||
@@ -1,226 +1,19 @@
|
||||
#![cfg_attr(not(test), allow(dead_code, unused_imports))]
|
||||
|
||||
//! Parent-facing tools for in-process Internal SubWorker sessions.
|
||||
//! Socket communication retained for the legacy top-level Worker callback protocol.
|
||||
//!
|
||||
//! Legacy direct-child tool constructors are test-only; production exposes the
|
||||
//! registry through the unified `worker.control` service and Worker tools.
|
||||
//! There is no Runtime catalog lookup or child socket transport, so a Worker can operate only on
|
||||
//! its direct Internal children. The socket helper at the bottom remains solely for the legacy
|
||||
//! top-level Worker callback protocol and is not part of SubWorker communication.
|
||||
//! Direct Internal SubWorker lifecycle is exposed through `worker.control` and the
|
||||
//! canonical Worker tools, not a second SubWorker-specific tool family.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||
use protocol::{Event, Method};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
use crate::spawn::registry::SpawnedWorkerRegistry;
|
||||
|
||||
/// Timeout applied to each socket-level operation — connect, write,
|
||||
/// read. Kept short so a stuck child doesn't block the spawner's turn.
|
||||
const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared input types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct NameInput {
|
||||
/// Name of a previously spawned SubWorker.
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SubWorkerListInput {}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct SubWorkerListItem {
|
||||
name: String,
|
||||
}
|
||||
|
||||
struct SubWorkerListTool {
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SubWorkerListTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let _input: SubWorkerListInput = serde_json::from_str(input_json).map_err(|error| {
|
||||
ToolError::InvalidArgument(format!("invalid SubWorkerList input: {error}"))
|
||||
})?;
|
||||
let items = self
|
||||
.registry
|
||||
.list_internal()
|
||||
.into_iter()
|
||||
.map(|record| SubWorkerListItem {
|
||||
name: record.worker_name,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let count = items.len();
|
||||
let content = serde_json::to_string_pretty(&serde_json::json!({ "sub_workers": items }))
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||
Ok(ToolOutput {
|
||||
summary: format!("listed {count} child SubWorker(s)"),
|
||||
content: Some(content),
|
||||
attachments: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn sub_worker_list_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(SubWorkerListInput);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("SubWorkerList")
|
||||
.description("List child SubWorkers owned by this Worker. Peer Workers and general Runtime Workers are excluded.")
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(SubWorkerListTool {
|
||||
registry: registry.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SubWorkerSend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SEND_TO_POD_DESCRIPTION: &str = "Send a text message to a previously spawned SubWorker. The SubWorker \
|
||||
processes it as a user turn. Fails if the SubWorker is already executing a \
|
||||
turn — retry after it finishes. Does not wait for the turn to complete; \
|
||||
use worker-observation tools to inspect its committed session.";
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct SubWorkerSendInput {
|
||||
/// Target SubWorker name.
|
||||
name: String,
|
||||
/// Text delivered to the SubWorker as the next user message.
|
||||
message: String,
|
||||
}
|
||||
|
||||
struct SubWorkerSendTool {
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SubWorkerSendTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let input: SubWorkerSendInput = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid SubWorkerSend input: {e}")))?;
|
||||
if let Some(record) = self.registry.get_internal(&input.name) {
|
||||
record.session.send(input.message).await.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!("send to `{}`: {error}", input.name))
|
||||
})?;
|
||||
return Ok(ToolOutput {
|
||||
summary: format!("sent message to `{}`", input.name),
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
});
|
||||
}
|
||||
Err(unknown_worker_err(&input.name))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn sub_worker_send_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(SubWorkerSendInput);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("SubWorkerSend")
|
||||
.description(SEND_TO_POD_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(SubWorkerSendTool {
|
||||
registry: registry.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SubWorkerStop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STOP_POD_DESCRIPTION: &str = "Cancel and stop a spawned Internal SubWorker session, remove it from the parent's direct-child registry, and reclaim delegated Write scope.";
|
||||
|
||||
struct SubWorkerStopTool {
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for SubWorkerStopTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let input: NameInput = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid SubWorkerStop input: {e}")))?;
|
||||
if let Some(summary) = self
|
||||
.registry
|
||||
.remove_internal(&input.name)
|
||||
.await
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?
|
||||
{
|
||||
return Ok(ToolOutput {
|
||||
summary: format!(
|
||||
"SubWorkerStop - done\n {} tool kind{}\n {}ms",
|
||||
summary.tool_counts.len(),
|
||||
if summary.tool_counts.len() == 1 {
|
||||
""
|
||||
} else {
|
||||
"s"
|
||||
},
|
||||
summary.elapsed_ms,
|
||||
),
|
||||
content: Some(
|
||||
serde_json::to_string(&summary)
|
||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
|
||||
),
|
||||
attachments: Vec::new(),
|
||||
});
|
||||
}
|
||||
Err(unknown_worker_err(&input.name))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn sub_worker_stop_tool(registry: Arc<SpawnedWorkerRegistry>) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(NameInput);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("SubWorkerStop")
|
||||
.description(STOP_POD_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(SubWorkerStopTool {
|
||||
registry: registry.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn unknown_worker_err(name: &str) -> ToolError {
|
||||
ToolError::InvalidArgument(format!("no spawned worker named `{name}`"))
|
||||
}
|
||||
|
||||
/// Connect with a timeout, drain the server's connect-time snapshot,
|
||||
/// write one `Method` line, flush, and close.
|
||||
///
|
||||
@@ -278,7 +71,21 @@ mod tests {
|
||||
|
||||
fn snapshot(entries: Vec<serde_json::Value>) -> Event {
|
||||
Event::Snapshot {
|
||||
entries,
|
||||
session: protocol::SessionSnapshot {
|
||||
entries: entries
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, value)| protocol::SessionSnapshotEntry {
|
||||
entry_id: format!("test-{index}"),
|
||||
timestamp: index as u64,
|
||||
provenance: protocol::SessionEntryProvenance::LegacyUnknown,
|
||||
derived_from: Vec::new(),
|
||||
data: protocol::SessionSnapshotEntryData::RunError {
|
||||
message: value.to_string(),
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
greeting: Greeting {
|
||||
worker_name: "server".into(),
|
||||
cwd: "/tmp".into(),
|
||||
|
||||
@@ -112,8 +112,12 @@ impl InternalSpawnedWorkerRecord {
|
||||
fn stop_summary(&self) -> SubWorkerStopSummary {
|
||||
let mut counts = BTreeMap::<String, u64>::new();
|
||||
for entry in self.session.entries() {
|
||||
if let session_store::LogEntry::AssistantItem {
|
||||
item: LoggedItem::ToolCall { name, .. },
|
||||
if let session_store::LogEntry::AnnotatedAssistantItem {
|
||||
entry:
|
||||
session_store::LoggedHistoryEntry {
|
||||
item: LoggedItem::ToolCall { name, .. },
|
||||
..
|
||||
},
|
||||
..
|
||||
} = entry
|
||||
{
|
||||
@@ -806,11 +810,7 @@ fn internal_worker_snapshot(
|
||||
InternalWorkerSnapshot {
|
||||
worker,
|
||||
revision,
|
||||
entries: snapshot
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter_map(|entry| serde_json::to_value(entry).ok())
|
||||
.collect(),
|
||||
session: snapshot.session,
|
||||
status: snapshot.status,
|
||||
error: snapshot.error,
|
||||
in_flight: snapshot.in_flight,
|
||||
@@ -1028,11 +1028,16 @@ mod tests {
|
||||
&& worker.parent_session_id.as_deref() == Some("parent-session")
|
||||
&& matches!(*event, Event::TextDone { ref text } if text == "answer")
|
||||
));
|
||||
record.session.publish_test_entry(LogEntry::UserInput {
|
||||
ts: 1,
|
||||
segments: vec![protocol::Segment::text("question")],
|
||||
extensions: Vec::new(),
|
||||
});
|
||||
record
|
||||
.session
|
||||
.publish_test_entry(LogEntry::AnnotatedUserInput {
|
||||
ts: 1,
|
||||
segments: vec![protocol::Segment::text("question")],
|
||||
history: vec![crate::session_history::test_logged_history_entry(
|
||||
agen::Item::user_message("question"),
|
||||
)],
|
||||
extensions: Vec::new(),
|
||||
});
|
||||
let committed = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
@@ -1045,7 +1050,7 @@ mod tests {
|
||||
let snapshots = registry.internal_worker_snapshots();
|
||||
assert_eq!(snapshots.len(), 1);
|
||||
assert_eq!(snapshots[0].revision, 2);
|
||||
assert_eq!(snapshots[0].entries.len(), 1);
|
||||
assert_eq!(snapshots[0].session.entries.len(), 1);
|
||||
|
||||
record.session.emit_test_text_delta("partial");
|
||||
let streamed = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
|
||||
@@ -1163,14 +1168,18 @@ mod tests {
|
||||
let (mut record, _events) = record("child", InternalWorkerVisibility::ParentClient).await;
|
||||
record.change_tracker = Some(tracker);
|
||||
for (index, name) in ["Read", "Read", "Grep"].into_iter().enumerate() {
|
||||
record.session.publish_test_entry(LogEntry::AssistantItem {
|
||||
ts: index as u64,
|
||||
item: LoggedItem::ToolCall {
|
||||
call_id: format!("call-{index}"),
|
||||
name: name.to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
});
|
||||
record
|
||||
.session
|
||||
.publish_test_entry(LogEntry::AnnotatedAssistantItem {
|
||||
ts: index as u64,
|
||||
entry: crate::session_history::test_logged_history_entry(
|
||||
LoggedItem::ToolCall {
|
||||
call_id: format!("call-{index}"),
|
||||
name: name.to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
registry.start_protocol_forwarding(record.clone());
|
||||
install_record(®istry, record);
|
||||
|
||||
@@ -403,11 +403,10 @@ impl Tool for SubWorkerSpawnTool {
|
||||
allow: scope_allow.clone(),
|
||||
deny: Vec::new(),
|
||||
};
|
||||
let mut child_manifest =
|
||||
WorkerManifest::try_from(WorkerManifestConfig::builtin_defaults().merge(child_config))
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!("resolve child manifest: {error}"))
|
||||
})?;
|
||||
let mut child_manifest = WorkerManifest::try_from(
|
||||
WorkerManifestConfig::resolution_defaults().merge(child_config),
|
||||
)
|
||||
.map_err(|error| ToolError::ExecutionFailed(format!("resolve child manifest: {error}")))?;
|
||||
// Delegated children stay bound to their scoped session and cannot use
|
||||
// Workspace attachment tools to replace it with parent-level authority.
|
||||
child_manifest.feature.manage_workdir.enabled = false;
|
||||
@@ -960,9 +959,7 @@ mod tests {
|
||||
|
||||
use crate::WorkspaceId;
|
||||
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
|
||||
use agen::llm_client::types::ContentPart;
|
||||
use agen::llm_client::{ClientError, LlmClient, Request};
|
||||
use agen::{Item, Role};
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use manifest::{AuthRef, ModelManifest, SchemeKind, WorkerManifest};
|
||||
@@ -1228,14 +1225,11 @@ extract_threshold = 4000
|
||||
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
|
||||
));
|
||||
|
||||
let context = agen::tool::ToolExecutionContext::direct();
|
||||
let list = (crate::spawn::comm_tools::sub_worker_list_tool(registry.clone()))().1;
|
||||
let listed = list.execute("{}", context.clone()).await.unwrap();
|
||||
assert!(
|
||||
listed
|
||||
.content
|
||||
.unwrap_or_default()
|
||||
.contains("reviewer-child")
|
||||
registry
|
||||
.list_internal()
|
||||
.iter()
|
||||
.any(|record| record.worker_name == "reviewer-child")
|
||||
);
|
||||
|
||||
let observation =
|
||||
@@ -1252,17 +1246,17 @@ extract_threshold = 4000
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(first_capture.entries.iter().map(|entry| &entry.item).any(|item| {
|
||||
matches!(item, Item::Message { role: Role::Assistant, content, .. } if content.iter().any(|part| matches!(part, ContentPart::Text { text } if text.contains("reviewed"))))
|
||||
}));
|
||||
assert!(
|
||||
serde_json::to_string(&first_capture.session)
|
||||
.unwrap()
|
||||
.contains("reviewed")
|
||||
);
|
||||
|
||||
let send = (crate::spawn::comm_tools::sub_worker_send_tool(registry.clone()))().1;
|
||||
send.execute(
|
||||
r#"{"name":"reviewer-child","message":"review follow-up"}"#,
|
||||
context.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
record
|
||||
.session
|
||||
.send("review follow-up".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
record.session.wait_until_idle().await,
|
||||
crate::internal_worker::InternalWorkerSessionStatus::Idle
|
||||
@@ -1274,15 +1268,14 @@ extract_threshold = 4000
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(latest_capture.entries.len() > first_capture.entries.len());
|
||||
assert!(latest_capture.session.entries.len() > first_capture.session.entries.len());
|
||||
|
||||
fail_requests.store(true, Ordering::SeqCst);
|
||||
send.execute(
|
||||
r#"{"name":"reviewer-child","message":"trigger terminal failure"}"#,
|
||||
context.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
record
|
||||
.session
|
||||
.send("trigger terminal failure".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
record.session.wait_until_idle().await,
|
||||
InternalWorkerSessionStatus::Stopped
|
||||
@@ -1298,10 +1291,13 @@ extract_threshold = 4000
|
||||
);
|
||||
assert!(registry.get_internal("reviewer-child").is_some());
|
||||
|
||||
let stop = (crate::spawn::comm_tools::sub_worker_stop_tool(registry.clone()))().1;
|
||||
stop.execute(r#"{"name":"reviewer-child"}"#, context)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
registry
|
||||
.remove_internal("reviewer-child")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(registry.get_internal("reviewer-child").is_none());
|
||||
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
||||
|
||||
@@ -1315,9 +1311,6 @@ extract_threshold = 4000
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
||||
drop(list);
|
||||
drop(send);
|
||||
drop(stop);
|
||||
drop(observation);
|
||||
drop(tool);
|
||||
drop(registry);
|
||||
@@ -1631,7 +1624,7 @@ max_tokens = 3333
|
||||
Some(true)
|
||||
);
|
||||
|
||||
let manifest: WorkerManifest = WorkerManifestConfig::builtin_defaults()
|
||||
let manifest: WorkerManifest = WorkerManifestConfig::resolution_defaults()
|
||||
.merge(parsed)
|
||||
.try_into()
|
||||
.unwrap();
|
||||
@@ -1851,7 +1844,7 @@ max_tokens = 3333
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ambiguous_and_no_default_diagnostics_include_available_selectors() {
|
||||
fn invalid_and_ambiguous_diagnostics_include_available_selectors() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
std::fs::create_dir_all(&project).unwrap();
|
||||
@@ -1869,17 +1862,21 @@ max_tokens = 3333
|
||||
assert!(invalid.contains("Use `default`, `inherit`"));
|
||||
assert!(invalid.contains("`project:coder`"));
|
||||
|
||||
let default_error = build_spawn_config_json_for_profile(
|
||||
&parent,
|
||||
&available,
|
||||
&project,
|
||||
"child",
|
||||
None,
|
||||
&scope,
|
||||
SpawnProfileSelector::Default,
|
||||
let default_config: serde_json::Value = serde_json::from_str(
|
||||
&build_spawn_config_json_for_profile(
|
||||
&parent,
|
||||
&available,
|
||||
&project,
|
||||
"child",
|
||||
None,
|
||||
&scope,
|
||||
SpawnProfileSelector::Default,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(default_error.contains("no default profile is configured"));
|
||||
.unwrap();
|
||||
assert_eq!(default_config["feature"]["sub_worker"]["enabled"], true);
|
||||
assert_eq!(default_config["feature"]["ticket"]["enabled"], false);
|
||||
|
||||
let user_config = tmp.path().join("user-profiles.toml");
|
||||
std::fs::write(&user_config, "[profile]\ncoder = \"user-coder.toml\"\n").unwrap();
|
||||
|
||||
+167
-47
@@ -251,6 +251,13 @@ impl WorkspacePromptCatalogResolution {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceWorkerDiscoveryRequest {
|
||||
pub cursor: Option<String>,
|
||||
pub limit: usize,
|
||||
pub query: Option<String>,
|
||||
}
|
||||
|
||||
/// Path-free Workspace operation authority injected by Runtime/host code.
|
||||
///
|
||||
/// Workers receive this trait object rather than a Backend URL. The concrete
|
||||
@@ -263,6 +270,18 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync {
|
||||
fn execute(&self, request: WorkspaceRequest)
|
||||
-> Result<WorkspaceResponse, WorkspaceClientError>;
|
||||
|
||||
/// Lists Workspace-visible Workers through dedicated Runtime-owned source
|
||||
/// proof. Implementations must not fall back to generic Workspace request
|
||||
/// authority.
|
||||
fn list_workspace_workers(
|
||||
&self,
|
||||
_request: WorkspaceWorkerDiscoveryRequest,
|
||||
) -> Result<workspace_api::WorkspaceWorkerDiscoveryPage, WorkspaceClientError> {
|
||||
Err(WorkspaceClientError::Unavailable(
|
||||
"Workspace Worker discovery authority is unavailable".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Resolve the Workspace's current immutable Prompt projection for future
|
||||
/// operation boundaries. Creation and restore continue to use persisted
|
||||
/// launch/session state; this hook never reconstructs historical prompts.
|
||||
@@ -900,7 +919,6 @@ where
|
||||
self.state.increment_entries();
|
||||
if let Some(in_flight) = &self.in_flight {
|
||||
let committed_item = match &entry {
|
||||
LogEntry::AssistantItem { item, .. } => Some(item.clone()),
|
||||
LogEntry::AnnotatedAssistantItem { entry, .. } => Some(entry.item.clone()),
|
||||
_ => None,
|
||||
};
|
||||
@@ -1207,11 +1225,11 @@ pub struct Worker<C: LlmClient, St: Store> {
|
||||
memory_task: Option<JoinHandle<()>>,
|
||||
/// Typed user submissions in submit order. K-th entry corresponds to
|
||||
/// the K-th `Item::user_message` in `worker.history()` (modulo seed
|
||||
/// history loaded via `SegmentStart.history`, whose original segments
|
||||
/// history loaded via `AnnotatedSegmentStart.history`, whose original segments
|
||||
/// are not preserved). Populated from log on `restore_from_manifest`,
|
||||
/// appended after `save_user_input` on each `run`. Pre-`Event::Snapshot`
|
||||
/// this fed `WorkerSharedState.user_segments`; the new wire format
|
||||
/// carries typed atoms via `LogEntry::UserInput { segments }` so
|
||||
/// carries typed atoms via `LogEntry::AnnotatedUserInput { segments }` so
|
||||
/// this remains purely an in-memory tracker for compact alignment.
|
||||
user_segments: Vec<Vec<Segment>>,
|
||||
/// Worker-side session-log mirror + broadcast sink. Populated alongside
|
||||
@@ -1221,7 +1239,8 @@ pub struct Worker<C: LlmClient, St: Store> {
|
||||
sink: SegmentLogSink,
|
||||
/// `true` once `wire_history_persistence` has installed the
|
||||
/// `Engine::on_history_append` callback that commits each appended
|
||||
/// item as a singular `LogEntry::AssistantItem` / `ToolResult`
|
||||
/// item as a singular `LogEntry::AnnotatedAssistantItem` /
|
||||
/// `AnnotatedToolResult`
|
||||
/// directly through the writer. Tests that drive `Worker::new` without
|
||||
/// going through the controller leave this `false`; `persist_turn`
|
||||
/// then walks the post-`history_before` slice inline so entries
|
||||
@@ -1345,15 +1364,16 @@ impl<C: LlmClient + 'static, St: Store + Clone + 'static> Worker<C, St> {
|
||||
}
|
||||
|
||||
/// Wire `Engine::on_history_append` to commit each appended item
|
||||
/// directly as a singular `LogEntry::AssistantItem` / `ToolResult`
|
||||
/// directly as a singular `LogEntry::AnnotatedAssistantItem` /
|
||||
/// `AnnotatedToolResult`
|
||||
/// through the writer. The controller calls this once per spawned
|
||||
/// Worker after the worker is built; tests that drive `Worker::new` may
|
||||
/// opt in to the same wiring or leave it off (in which case
|
||||
/// `persist_turn`'s inline fallback writes entries at turn end).
|
||||
///
|
||||
/// `user_message` items are skipped because they are committed
|
||||
/// up-front via `commit_entry(LogEntry::UserInput { segments })`.
|
||||
/// `role:system` items are committed as typed `LogEntry::SystemItem`
|
||||
/// up-front via `commit_entry(LogEntry::AnnotatedUserInput { segments })`.
|
||||
/// `role:system` items are committed as typed `LogEntry::AnnotatedSystemItem`
|
||||
/// entries by their producers (for example `WorkerInterceptor` and
|
||||
/// interrupted-turn prep) before they reach the worker's history, so this
|
||||
/// callback would otherwise double-write them.
|
||||
@@ -1941,8 +1961,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
}
|
||||
|
||||
let input = match entries.get(target.user_input_entry_index) {
|
||||
Some(LogEntry::UserInput { segments, .. })
|
||||
| Some(LogEntry::AnnotatedUserInput { segments, .. }) => segments.clone(),
|
||||
Some(LogEntry::AnnotatedUserInput { segments, .. }) => segments.clone(),
|
||||
_ => {
|
||||
return Err(RewindError::Invalid(
|
||||
"rewind target is no longer a user message".into(),
|
||||
@@ -2081,8 +2100,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
/// Cheap clone via `Option<Clone>`.
|
||||
/// Snapshot of the typed user segments tracked alongside worker
|
||||
/// history. The K-th entry corresponds to the K-th `Item::user_message`
|
||||
/// derived from `LogEntry::UserInput` entries (post-compaction); seed
|
||||
/// history loaded via `SegmentStart.history` does not contribute,
|
||||
/// derived from `LogEntry::AnnotatedUserInput` entries (post-compaction); seed
|
||||
/// history loaded via `AnnotatedSegmentStart.history` does not contribute,
|
||||
/// which is acceptable because the original segments are unrecoverable.
|
||||
pub fn user_segments(&self) -> &[Vec<Segment>] {
|
||||
&self.user_segments
|
||||
@@ -3533,12 +3552,13 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
// slice from `history_before` inline so the test's
|
||||
// `restore`-style assertions still see entries on disk.
|
||||
if !self.history_persistence_wired {
|
||||
let new_items: Vec<Item> = self.session.history().entries()[history_before..]
|
||||
let new_entries: Vec<_> = self.session.history().entries()[history_before..]
|
||||
.iter()
|
||||
.map(|entry| entry.item.clone())
|
||||
.map(to_logged_history_entry)
|
||||
.collect();
|
||||
let ts = segment_log::now_millis();
|
||||
for item in &new_items {
|
||||
for history_entry in new_entries {
|
||||
let item = Item::from(history_entry.item.clone());
|
||||
if item.is_user_message() {
|
||||
continue;
|
||||
}
|
||||
@@ -3551,7 +3571,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let entry = session_store::classify_history_item(item, ts);
|
||||
let entry = session_store::classify_logged_history_entry(history_entry, ts);
|
||||
self.commit_entry(entry)?;
|
||||
}
|
||||
}
|
||||
@@ -5107,15 +5127,35 @@ where
|
||||
loader: PromptCatalogSource,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
) -> Result<Self, WorkerError> {
|
||||
Self::from_manifest_with_context_and_model_client(
|
||||
manifest,
|
||||
store,
|
||||
loader,
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn from_manifest_with_context_and_model_client(
|
||||
manifest: WorkerManifest,
|
||||
store: St,
|
||||
loader: PromptCatalogSource,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
model_client: Option<Box<dyn LlmClient>>,
|
||||
) -> Result<Self, WorkerError> {
|
||||
validate_workspace_memory_snapshot(&manifest.worker.name, &manifest, &workspace_context)?;
|
||||
let common = prepare_worker_common_with_context(
|
||||
let common = prepare_worker_common_with_context_and_model_client(
|
||||
&manifest,
|
||||
&loader,
|
||||
/* parse_template */ true,
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
manifest.scope.clone(),
|
||||
model_client,
|
||||
)?;
|
||||
|
||||
// Segment creation is deferred to the first run (see
|
||||
@@ -5495,6 +5535,27 @@ where
|
||||
loader: PromptCatalogSource,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
) -> Result<Self, WorkerError> {
|
||||
Self::restore_pending_from_worker_metadata_with_context_and_model_client(
|
||||
worker_name,
|
||||
fallback,
|
||||
store,
|
||||
loader,
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn restore_pending_from_worker_metadata_with_context_and_model_client(
|
||||
worker_name: &str,
|
||||
fallback: WorkerManifest,
|
||||
store: St,
|
||||
loader: PromptCatalogSource,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
model_client: Option<Box<dyn LlmClient>>,
|
||||
) -> Result<Self, WorkerError> {
|
||||
let metadata =
|
||||
store
|
||||
@@ -5515,7 +5576,7 @@ where
|
||||
worker_name: worker_name.to_string(),
|
||||
})?;
|
||||
if let Some(segment_id) = active.segment_id {
|
||||
return Self::restore_from_manifest_with_context(
|
||||
return Self::restore_from_manifest_with_context_and_model_client(
|
||||
active.session_id,
|
||||
segment_id,
|
||||
restore_manifest_from_worker_metadata_snapshot(
|
||||
@@ -5527,6 +5588,7 @@ where
|
||||
loader,
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
model_client,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -5537,12 +5599,13 @@ where
|
||||
})?;
|
||||
let manifest =
|
||||
restore_manifest_from_worker_metadata_snapshot(worker_name, Some(snapshot), fallback)?;
|
||||
Self::from_manifest_with_context(
|
||||
Self::from_manifest_with_context_and_model_client(
|
||||
manifest,
|
||||
store,
|
||||
loader,
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
model_client,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -5594,6 +5657,29 @@ where
|
||||
loader: PromptCatalogSource,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
) -> Result<Self, WorkerError> {
|
||||
Self::restore_from_manifest_with_context_and_model_client(
|
||||
session_id,
|
||||
segment_id,
|
||||
manifest,
|
||||
store,
|
||||
loader,
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn restore_from_manifest_with_context_and_model_client(
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
manifest: WorkerManifest,
|
||||
store: St,
|
||||
loader: PromptCatalogSource,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
model_client: Option<Box<dyn LlmClient>>,
|
||||
) -> Result<Self, WorkerError> {
|
||||
// Read raw entries once so we can both reconstruct state and
|
||||
// seed the broadcast sink's mirror with the same prefix that
|
||||
@@ -5609,13 +5695,14 @@ where
|
||||
let mirror_entries: Vec<LogEntry> = raw_entries.clone();
|
||||
let scope_config = effective_restore_scope_config(&store, &manifest)?;
|
||||
|
||||
let common = prepare_worker_common_with_context(
|
||||
let common = prepare_worker_common_with_context_and_model_client(
|
||||
&manifest,
|
||||
&loader,
|
||||
/* parse_template */ false,
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
scope_config,
|
||||
model_client,
|
||||
)?;
|
||||
|
||||
// Atomic: register_worker inside install_top_level rejects when
|
||||
@@ -6257,8 +6344,7 @@ fn build_rewind_targets(segment_id: uuid::Uuid, entries: &[LogEntry]) -> Vec<Rew
|
||||
let mut targets = Vec::new();
|
||||
for (entry_index, entry) in entries.iter().enumerate() {
|
||||
let (segments, ts) = match entry {
|
||||
LogEntry::UserInput { segments, ts, .. }
|
||||
| LogEntry::AnnotatedUserInput { segments, ts, .. } => (segments, ts),
|
||||
LogEntry::AnnotatedUserInput { segments, ts, .. } => (segments, ts),
|
||||
_ => continue,
|
||||
};
|
||||
turn_index += 1;
|
||||
@@ -6300,8 +6386,7 @@ fn rewind_truncate_entries(entries: &[LogEntry], user_input_entry_index: usize)
|
||||
|
||||
fn suffix_has_tool_side_effects(entries: &[LogEntry]) -> bool {
|
||||
entries.iter().any(|entry| match entry {
|
||||
LogEntry::ToolResult { .. } | LogEntry::AnnotatedToolResult { .. } => true,
|
||||
LogEntry::AssistantItem { item, .. } => logged_item_is_tool_call(item),
|
||||
LogEntry::AnnotatedToolResult { .. } => true,
|
||||
LogEntry::AnnotatedAssistantItem { entry, .. } => logged_item_is_tool_call(&entry.item),
|
||||
_ => false,
|
||||
})
|
||||
@@ -6592,6 +6677,26 @@ fn prepare_worker_common_with_context(
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
scope_config: ScopeConfig,
|
||||
) -> Result<WorkerCommon, WorkerError> {
|
||||
prepare_worker_common_with_context_and_model_client(
|
||||
manifest,
|
||||
loader,
|
||||
parse_template,
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
scope_config,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn prepare_worker_common_with_context_and_model_client(
|
||||
manifest: &WorkerManifest,
|
||||
loader: &PromptCatalogSource,
|
||||
parse_template: bool,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
scope_config: ScopeConfig,
|
||||
model_client: Option<Box<dyn LlmClient>>,
|
||||
) -> Result<WorkerCommon, WorkerError> {
|
||||
let filesystem_authority = match filesystem_authority {
|
||||
WorkerFilesystemAuthority::None => WorkerFilesystemAuthority::None,
|
||||
@@ -6627,6 +6732,7 @@ fn prepare_worker_common_with_context(
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
scope,
|
||||
model_client,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6637,6 +6743,7 @@ fn prepare_worker_common_from_scope(
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
filesystem_authority: WorkerFilesystemAuthority,
|
||||
scope: Scope,
|
||||
model_client: Option<Box<dyn LlmClient>>,
|
||||
) -> Result<WorkerCommon, WorkerError> {
|
||||
if let Some(local) = filesystem_authority.as_local() {
|
||||
if !scope.is_readable(&local.root) {
|
||||
@@ -6653,7 +6760,10 @@ fn prepare_worker_common_from_scope(
|
||||
let delegation_scope =
|
||||
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
|
||||
|
||||
let client = crate::model_client::build_client(&manifest.model)?;
|
||||
let client = match model_client {
|
||||
Some(client) => client,
|
||||
None => crate::model_client::build_client(&manifest.model)?,
|
||||
};
|
||||
let prompts = Arc::new(ArcSwap::from(PromptCatalog::load(loader)?));
|
||||
let system_prompt_template = if parse_template {
|
||||
Some(
|
||||
@@ -7636,7 +7746,7 @@ mod build_summary_prompt_tests {
|
||||
);
|
||||
assert!(checkpoint.is_none());
|
||||
|
||||
let mut replacement_entries = vec![LogEntry::SegmentStart {
|
||||
let mut replacement_entries = vec![LogEntry::AnnotatedSegmentStart {
|
||||
ts: segment_log::now_millis(),
|
||||
session_id: uuid::Uuid::nil(),
|
||||
system_prompt: None,
|
||||
@@ -7964,9 +8074,12 @@ mod build_summary_prompt_tests {
|
||||
);
|
||||
append_test_entry(
|
||||
worker,
|
||||
LogEntry::UserInput {
|
||||
LogEntry::AnnotatedUserInput {
|
||||
ts: ts + 1,
|
||||
extensions: vec![],
|
||||
history: vec![crate::session_history::test_logged_history_entry(
|
||||
Item::user_message(text),
|
||||
)],
|
||||
segments: vec![text_segment(text)],
|
||||
},
|
||||
);
|
||||
@@ -7986,16 +8099,18 @@ mod build_summary_prompt_tests {
|
||||
append_user_turn(&worker, 20, "second message");
|
||||
append_test_entry(
|
||||
&worker,
|
||||
LogEntry::ToolResult {
|
||||
LogEntry::AnnotatedToolResult {
|
||||
ts: 30,
|
||||
item: session_store::LoggedItem::ToolResult {
|
||||
call_id: "call-1".into(),
|
||||
summary: "wrote a file".into(),
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
disposition: Default::default(),
|
||||
is_error: false,
|
||||
},
|
||||
entry: crate::session_history::test_logged_history_entry(
|
||||
session_store::LoggedItem::ToolResult {
|
||||
call_id: "call-1".into(),
|
||||
summary: "wrote a file".into(),
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
disposition: Default::default(),
|
||||
is_error: false,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8029,16 +8144,18 @@ mod build_summary_prompt_tests {
|
||||
append_user_turn(&worker, 20, "second message");
|
||||
append_test_entry(
|
||||
&worker,
|
||||
LogEntry::ToolResult {
|
||||
LogEntry::AnnotatedToolResult {
|
||||
ts: 30,
|
||||
item: session_store::LoggedItem::ToolResult {
|
||||
call_id: "call-1".into(),
|
||||
summary: "wrote a file".into(),
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
disposition: Default::default(),
|
||||
is_error: false,
|
||||
},
|
||||
entry: crate::session_history::test_logged_history_entry(
|
||||
session_store::LoggedItem::ToolResult {
|
||||
call_id: "call-1".into(),
|
||||
summary: "wrote a file".into(),
|
||||
content: None,
|
||||
attachments: Vec::new(),
|
||||
disposition: Default::default(),
|
||||
is_error: false,
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
|
||||
@@ -8456,9 +8573,9 @@ mod build_summary_prompt_tests {
|
||||
worker.wire_history_persistence();
|
||||
let dangling_call = Item::tool_call("call-1", "SideEffect", "{}");
|
||||
worker
|
||||
.commit_entry(LogEntry::AssistantItem {
|
||||
.commit_entry(LogEntry::AnnotatedAssistantItem {
|
||||
ts: segment_log::now_millis(),
|
||||
item: dangling_call.clone().into(),
|
||||
entry: crate::session_history::test_logged_history_entry(dangling_call.clone()),
|
||||
})
|
||||
.unwrap();
|
||||
worker.set_history_for_test(vec![dangling_call]);
|
||||
@@ -8863,9 +8980,12 @@ mod build_summary_prompt_tests {
|
||||
);
|
||||
worker.set_history_for_test(vec![evidence.clone()]);
|
||||
worker
|
||||
.commit_entry(LogEntry::UserInput {
|
||||
.commit_entry(LogEntry::AnnotatedUserInput {
|
||||
ts: segment_log::now_millis(),
|
||||
extensions: vec![],
|
||||
history: vec![crate::session_history::test_logged_history_entry(
|
||||
evidence.clone(),
|
||||
)],
|
||||
segments: vec![text_segment(
|
||||
"The cancellation regression must leave this evidence available for retry.",
|
||||
)],
|
||||
|
||||
@@ -25,6 +25,17 @@ use worker::{Worker, WorkerController};
|
||||
|
||||
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
|
||||
|
||||
fn annotated(item: 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MockClient {
|
||||
responses: Arc<Vec<Vec<LlmEvent>>>,
|
||||
@@ -210,7 +221,6 @@ fn system_texts_in_sink_session_start(
|
||||
.into_iter()
|
||||
.map(|entry| entry.item)
|
||||
.collect::<Vec<_>>(),
|
||||
session_store::LogEntry::SegmentStart { history, .. } => history,
|
||||
_ => continue,
|
||||
};
|
||||
return history
|
||||
@@ -310,17 +320,14 @@ permission = "write"
|
||||
// Simulate a foreign writer appending to the same segment. This bumps
|
||||
// the on-disk entry count past the Worker's own append tally without
|
||||
// updating the Worker's `entries_written`.
|
||||
store
|
||||
.append(
|
||||
session_id,
|
||||
source_segment_id,
|
||||
&LogEntry::UserInput {
|
||||
ts: 9999,
|
||||
segments: vec![protocol::Segment::text("interloper")],
|
||||
extensions: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
session_store::save_user_input(
|
||||
&store,
|
||||
session_id,
|
||||
source_segment_id,
|
||||
vec![protocol::Segment::text("interloper")],
|
||||
vec![annotated(Item::user_message("interloper"))],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Next run triggers ensure_segment_head, which sees the drift.
|
||||
worker.run_text("second").await.unwrap();
|
||||
@@ -348,11 +355,6 @@ permission = "write"
|
||||
session_id: seg_session,
|
||||
forked_from: Some(origin),
|
||||
..
|
||||
}
|
||||
| LogEntry::SegmentStart {
|
||||
session_id: seg_session,
|
||||
forked_from: Some(origin),
|
||||
..
|
||||
} => {
|
||||
assert_eq!(*seg_session, session_id);
|
||||
assert_eq!(origin.segment_id, source_segment_id);
|
||||
@@ -366,7 +368,7 @@ permission = "write"
|
||||
assert_eq!(source_after.len(), source_len_before + 1);
|
||||
assert!(matches!(
|
||||
source_after.last(),
|
||||
Some(LogEntry::UserInput { .. })
|
||||
Some(LogEntry::AnnotatedUserInput { .. })
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ use workdir::{
|
||||
|
||||
use worker::{
|
||||
Event, Method, Worker, WorkerController, WorkerFilesystemAuthority, WorkerHandle,
|
||||
WorkerManifest, WorkerStatus, WorkerWorkspaceContext,
|
||||
WorkerManifest, WorkerStatus, WorkerWorkspaceContext, WorkspaceClient, WorkspaceClientError,
|
||||
WorkspaceRequest, WorkspaceResponse,
|
||||
};
|
||||
|
||||
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
|
||||
@@ -35,29 +36,16 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
|
||||
LogEntry::AnnotatedSegmentStart { history, .. } => {
|
||||
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
|
||||
}
|
||||
LogEntry::SegmentStart { history, .. } => {
|
||||
items.extend(history.into_iter().map(Item::from));
|
||||
}
|
||||
LogEntry::AnnotatedUserInput { history, .. } => {
|
||||
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
|
||||
}
|
||||
LogEntry::UserInput { segments, .. } => {
|
||||
let text = protocol::Segment::flatten_to_text(&segments);
|
||||
items.push(Item::user_message(text));
|
||||
}
|
||||
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||
| LogEntry::AnnotatedToolResult { entry, .. } => {
|
||||
items.push(Item::from(entry.item));
|
||||
}
|
||||
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
|
||||
items.push(Item::from(item));
|
||||
}
|
||||
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||
items.push(entry.item.to_history_item());
|
||||
}
|
||||
LogEntry::SystemItem { item, .. } => {
|
||||
items.push(item.to_history_item());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -67,7 +55,6 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
|
||||
fn system_item(entry: &LogEntry) -> Option<&session_store::SystemItem> {
|
||||
match entry {
|
||||
LogEntry::AnnotatedSystemItem { entry, .. } => Some(&entry.item),
|
||||
LogEntry::SystemItem { item, .. } => Some(item),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -193,9 +180,72 @@ async fn make_worker_with_pwd(
|
||||
make_worker_with_pwd_and_manifest(client, MANIFEST_TOML).await
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AvailableWorkspaceClient;
|
||||
|
||||
impl WorkspaceClient for AvailableWorkspaceClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace-1")
|
||||
}
|
||||
|
||||
fn kind(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
_request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
Err(WorkspaceClientError::Unavailable("test".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NoopWorkspaceClient;
|
||||
|
||||
impl WorkspaceClient for NoopWorkspaceClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace-test")
|
||||
}
|
||||
|
||||
fn kind(&self) -> &str {
|
||||
"test-noop"
|
||||
}
|
||||
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn execute(
|
||||
&self,
|
||||
_request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
Err(WorkspaceClientError::Unavailable(
|
||||
"test client does not execute requests".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
async fn make_worker_with_pwd_and_manifest(
|
||||
client: MockClient,
|
||||
manifest_toml: &str,
|
||||
) -> (Worker<MockClient, TestStore>, std::path::PathBuf) {
|
||||
make_worker_with_pwd_manifest_and_workspace_context(
|
||||
client,
|
||||
manifest_toml,
|
||||
WorkerWorkspaceContext::local_filesystem(None),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn make_worker_with_pwd_manifest_and_workspace_context(
|
||||
client: MockClient,
|
||||
manifest_toml: &str,
|
||||
workspace_context: WorkerWorkspaceContext,
|
||||
) -> (Worker<MockClient, TestStore>, std::path::PathBuf) {
|
||||
let manifest = WorkerManifest::from_toml(manifest_toml).unwrap();
|
||||
let store_tmp = tempfile::tempdir().unwrap();
|
||||
@@ -216,16 +266,9 @@ async fn make_worker_with_pwd_and_manifest(
|
||||
let worker =
|
||||
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
|
||||
let authority = WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone());
|
||||
let worker = Worker::new(
|
||||
manifest,
|
||||
worker,
|
||||
store,
|
||||
WorkerWorkspaceContext::local_filesystem(None),
|
||||
authority,
|
||||
scope,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let worker = Worker::new(manifest, worker, store, workspace_context, authority, scope)
|
||||
.await
|
||||
.unwrap();
|
||||
(worker, pwd)
|
||||
}
|
||||
|
||||
@@ -677,9 +720,125 @@ permission = "write"
|
||||
"{} role SubWorker tool exposure mismatch: {names:?}",
|
||||
case.role
|
||||
);
|
||||
for control_tool in ["WorkerList", "WorkerSendInput", "WorkerStop"] {
|
||||
assert_eq!(
|
||||
names.iter().any(|name| name == control_tool),
|
||||
case.sub_worker_enabled,
|
||||
"{} role {control_tool} exposure mismatch: {names:?}",
|
||||
case.role
|
||||
);
|
||||
}
|
||||
for stale_alias in ["SubWorkerList", "SubWorkerSend", "SubWorkerStop"] {
|
||||
assert!(
|
||||
!names.iter().any(|name| name == stale_alias),
|
||||
"{} role exposed stale alias {stale_alias}: {names:?}",
|
||||
case.role
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_and_sub_worker_features_install_one_canonical_control_surface() {
|
||||
let manifest = r#"
|
||||
[worker]
|
||||
name = "combined-worker-control-feature-test"
|
||||
pwd = "./"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "test-model"
|
||||
|
||||
[engine]
|
||||
max_tokens = 100
|
||||
|
||||
[feature.worker]
|
||||
enabled = true
|
||||
direct_spawn = false
|
||||
|
||||
[feature.sub_worker]
|
||||
enabled = true
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
permission = "write"
|
||||
|
||||
[[delegation_scope.allow]]
|
||||
target = "/tmp"
|
||||
permission = "write"
|
||||
"#;
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let client_for_assert = client.clone();
|
||||
let worker = make_worker_with_pwd_manifest_and_workspace_context(
|
||||
client,
|
||||
manifest,
|
||||
WorkerWorkspaceContext::with_client(None, Arc::new(NoopWorkspaceClient)),
|
||||
)
|
||||
.await
|
||||
.0;
|
||||
let handle = spawn_controller(worker).await;
|
||||
|
||||
handle.send(Method::run_text("Hello")).await.unwrap();
|
||||
wait_for_status(&handle, WorkerStatus::Idle).await;
|
||||
|
||||
let request = wait_for_captured_request(&client_for_assert).await;
|
||||
let names = request_tool_names(&request);
|
||||
assert!(names.iter().any(|name| name == "SubWorkerSpawn"));
|
||||
assert!(!names.iter().any(|name| name == "WorkerSpawn"));
|
||||
for control_tool in ["WorkerList", "WorkerSendInput", "WorkerStop"] {
|
||||
assert_eq!(
|
||||
names
|
||||
.iter()
|
||||
.filter(|name| name.as_str() == control_tool)
|
||||
.count(),
|
||||
1,
|
||||
"expected one {control_tool} contribution: {names:?}"
|
||||
);
|
||||
}
|
||||
for stale_alias in ["SubWorkerList", "SubWorkerSend", "SubWorkerStop"] {
|
||||
assert!(!names.iter().any(|name| name == stale_alias));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_worker_discovery_requires_workspace_authority_and_stays_separate_from_worker_list()
|
||||
{
|
||||
let manifest_toml = r#"
|
||||
[worker]
|
||||
name = "workspace-worker-discovery-test"
|
||||
pwd = "./"
|
||||
|
||||
[model]
|
||||
scheme = "anthropic"
|
||||
model_id = "test-model"
|
||||
|
||||
[engine]
|
||||
max_tokens = 100
|
||||
|
||||
[feature.workspace_worker_discovery]
|
||||
enabled = true
|
||||
|
||||
[[scope.allow]]
|
||||
target = "./"
|
||||
permission = "write"
|
||||
"#;
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let client_for_assert = client.clone();
|
||||
let (worker, _pwd) = make_worker_with_pwd_manifest_and_workspace_context(
|
||||
client,
|
||||
manifest_toml,
|
||||
WorkerWorkspaceContext::with_client(None, Arc::new(AvailableWorkspaceClient)),
|
||||
)
|
||||
.await;
|
||||
let handle = spawn_controller(worker).await;
|
||||
handle.send(Method::run_text("Hello")).await.unwrap();
|
||||
wait_for_status(&handle, WorkerStatus::Idle).await;
|
||||
let request = wait_for_captured_request(&client_for_assert).await;
|
||||
let names = request_tool_names(&request);
|
||||
assert!(names.iter().any(|name| name == "ListWorkspaceWorkers"));
|
||||
assert!(!names.iter().any(|name| name == "WorkerList"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_worker_feature_exposure_does_not_require_delegation_scope() {
|
||||
let manifest = r#"
|
||||
@@ -839,26 +998,26 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
|
||||
loop {
|
||||
let event = reader.next::<Event>().await.unwrap().unwrap();
|
||||
match event {
|
||||
Event::Snapshot { entries, .. } => {
|
||||
// Walk the entries, find a `LogEntry::UserInput` and
|
||||
// confirm its segments flatten to our submitted text.
|
||||
let mut found = false;
|
||||
for value in &entries {
|
||||
let entry: session_store::LogEntry =
|
||||
serde_json::from_value(value.clone()).expect("LogEntry deserialise");
|
||||
if let session_store::LogEntry::UserInput { segments, .. }
|
||||
| session_store::LogEntry::AnnotatedUserInput { segments, .. } = entry
|
||||
{
|
||||
let text = protocol::Segment::flatten_to_text(&segments);
|
||||
if text == "hello in-flight" {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
Event::Snapshot { session, .. } => {
|
||||
let found = session.entries.iter().any(|entry| match &entry.data {
|
||||
protocol::SessionSnapshotEntryData::UserInput { segments } => {
|
||||
protocol::Segment::flatten_to_text(segments) == "hello in-flight"
|
||||
}
|
||||
}
|
||||
protocol::SessionSnapshotEntryData::Message {
|
||||
role: protocol::SessionMessageRole::User,
|
||||
content,
|
||||
} => content.iter().any(|part| {
|
||||
matches!(
|
||||
part,
|
||||
protocol::SessionContentPart::Text { text }
|
||||
if text == "hello in-flight"
|
||||
)
|
||||
}),
|
||||
_ => false,
|
||||
});
|
||||
assert!(
|
||||
found,
|
||||
"snapshot must carry the in-flight UserInput entry: {entries:?}"
|
||||
"snapshot must carry the in-flight UserInput entry: {session:?}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1095,7 +1254,7 @@ async fn run_with_paste_segment_inlines_content_and_emits_typed_user_message() {
|
||||
// Mixed input: plain text + a paste chip + trailing text. Worker must
|
||||
// flatten this into one user-message string (paste content inlined,
|
||||
// no `[Clipboard ...]` label leaking to the LLM); the committed
|
||||
// `LogEntry::UserInput` must carry the typed segments unchanged so
|
||||
// `LogEntry::AnnotatedUserInput` must carry the typed segments unchanged so
|
||||
// socket clients can derive `Event::UserMessage` and re-render the chip.
|
||||
let segments = vec![
|
||||
protocol::Segment::text("see "),
|
||||
@@ -1130,7 +1289,7 @@ async fn run_with_paste_segment_inlines_content_and_emits_typed_user_message() {
|
||||
_ => {}
|
||||
},
|
||||
entry = entry_rx.recv() => match entry {
|
||||
Ok(session_store::LogEntry::UserInput { segments, .. } | session_store::LogEntry::AnnotatedUserInput { segments, .. }) => {
|
||||
Ok(session_store::LogEntry::AnnotatedUserInput { segments, .. }) => {
|
||||
user_input_segments = Some(segments);
|
||||
if saw_turn_end {
|
||||
break;
|
||||
@@ -2410,17 +2569,12 @@ async fn snapshot_contains_user_input(handle: &WorkerHandle, needle: &str) -> bo
|
||||
loop {
|
||||
let event = reader.next::<Event>().await.unwrap().unwrap();
|
||||
match event {
|
||||
Event::Snapshot { entries, .. } => {
|
||||
return entries.into_iter().any(|value| {
|
||||
let entry: session_store::LogEntry =
|
||||
serde_json::from_value(value).expect("LogEntry deserialise");
|
||||
match entry {
|
||||
session_store::LogEntry::UserInput { segments, .. }
|
||||
| session_store::LogEntry::AnnotatedUserInput { segments, .. } => {
|
||||
protocol::Segment::flatten_to_text(&segments).contains(needle)
|
||||
}
|
||||
_ => false,
|
||||
Event::Snapshot { session, .. } => {
|
||||
return session.entries.into_iter().any(|entry| match entry.data {
|
||||
protocol::SessionSnapshotEntryData::UserInput { segments } => {
|
||||
protocol::Segment::flatten_to_text(&segments).contains(needle)
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
}
|
||||
Event::Alert(_) => continue,
|
||||
|
||||
@@ -203,12 +203,12 @@ async fn session_start_state_captures_rendered_prompt() {
|
||||
.unwrap();
|
||||
let first = entries.first().expect("at least one entry");
|
||||
match first {
|
||||
LogEntry::SegmentStart { system_prompt, .. } => {
|
||||
LogEntry::AnnotatedSegmentStart { system_prompt, .. } => {
|
||||
let sp = system_prompt.as_deref().expect("system prompt set");
|
||||
assert!(sp.starts_with("hello"));
|
||||
assert!(sp.contains(&pwd.display().to_string()));
|
||||
}
|
||||
other => panic!("expected SegmentStart as first entry, got {other:?}"),
|
||||
other => panic!("expected AnnotatedSegmentStart as first entry, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -237,6 +237,7 @@ pub enum RuntimeSourceStatus {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeIdentityAuthority {
|
||||
RuntimeRegistryProjection,
|
||||
ServerRuntimeConfiguration,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -263,6 +264,46 @@ pub struct RuntimeSummary {
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RuntimeManagementSummary {
|
||||
pub built_in: bool,
|
||||
pub config_managed: bool,
|
||||
pub removable: bool,
|
||||
pub endpoint_configured: bool,
|
||||
pub token_ref_configured: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkspaceRuntimeResource {
|
||||
#[serde(flatten)]
|
||||
pub runtime: RuntimeSummary,
|
||||
pub management: RuntimeManagementSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateRemoteRuntimeRequest {
|
||||
pub runtime_id: String,
|
||||
pub display_name: Option<String>,
|
||||
pub endpoint: String,
|
||||
pub token_ref: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RuntimeConnectionTestResponse {
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub checked_at: String,
|
||||
pub state: String,
|
||||
pub protocol_version: Option<String>,
|
||||
pub compatibility_basis: String,
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<String>,
|
||||
pub health_result: String,
|
||||
#[serde(default)]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkerWorkspaceSummary {
|
||||
pub visibility: String,
|
||||
@@ -283,6 +324,35 @@ pub struct WorkerCapabilitySummary {
|
||||
pub can_spawn_followup: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum WorkspaceWorkerSubject {
|
||||
RuntimeWorker {
|
||||
runtime_id: String,
|
||||
worker_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Bounded, model-safe projection used by privileged Workspace Worker discovery.
|
||||
/// Runtime placement appears only in the typed subject required by Worker
|
||||
/// control operations; provider and launch internals are intentionally omitted.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkspaceWorkerDiscoveryItem {
|
||||
pub subject: WorkspaceWorkerSubject,
|
||||
pub resource_key: String,
|
||||
pub display_name: String,
|
||||
pub profile: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WorkspaceWorkerDiscoveryPage {
|
||||
pub workers: Vec<WorkspaceWorkerDiscoveryItem>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
/// Workspace-authoritative Worker projection.
|
||||
///
|
||||
/// `resource_key` is required here even though Runtime-internal Worker summaries
|
||||
|
||||
@@ -105,8 +105,13 @@ pub fn token_hash(token: &str) -> String {
|
||||
}
|
||||
|
||||
pub fn new_user_code() -> String {
|
||||
let hex = Uuid::now_v7().simple().to_string().to_ascii_uppercase();
|
||||
format!("{}-{}", &hex[0..4], &hex[4..8])
|
||||
user_code_from_uuid(Uuid::now_v7())
|
||||
}
|
||||
|
||||
fn user_code_from_uuid(id: Uuid) -> String {
|
||||
let hex = id.simple().to_string().to_ascii_uppercase();
|
||||
let random_tail = &hex[24..32];
|
||||
format!("{}-{}", &random_tail[..4], &random_tail[4..])
|
||||
}
|
||||
|
||||
pub fn parse_bearer(headers: &HeaderMap) -> Option<String> {
|
||||
@@ -207,3 +212,27 @@ pub fn auth_error(code: &str, message: &str) -> Error {
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn user_code_uses_the_uuid_random_tail() {
|
||||
let id = Uuid::parse_str("01234567-89ab-7cde-8123-456789abcdef").expect("UUID");
|
||||
assert_eq!(user_code_from_uuid(id), "89AB-CDEF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_code_preserves_the_human_readable_format() {
|
||||
let code = new_user_code();
|
||||
assert_eq!(code.len(), 9);
|
||||
assert_eq!(code.as_bytes()[4], b'-');
|
||||
assert!(
|
||||
code.chars()
|
||||
.enumerate()
|
||||
.all(|(index, value)| index == 4 || value.is_ascii_hexdigit())
|
||||
);
|
||||
assert_eq!(code, code.to_ascii_uppercase());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
future::Future,
|
||||
path::PathBuf,
|
||||
pin::Pin,
|
||||
@@ -3722,94 +3721,6 @@ fn builtin_profile_config_bundle(
|
||||
.with_computed_digest())
|
||||
}
|
||||
|
||||
fn builtin_profile_source_archive(
|
||||
profile: &ProfileSelector,
|
||||
) -> Result<ProfileSourceArchive, String> {
|
||||
let selected = embedded_profile_label(profile)
|
||||
.ok_or_else(|| "profile selector must identify a concrete profile".to_string())?;
|
||||
let selected_path = embedded_profile_path(profile)?;
|
||||
let mut entrypoints = BTreeMap::new();
|
||||
for slug in [
|
||||
"companion",
|
||||
"intake",
|
||||
"orchestrator",
|
||||
"coder",
|
||||
"reviewer",
|
||||
"memory-consolidation",
|
||||
] {
|
||||
entrypoints.insert(format!("builtin:{slug}"), format!("profiles/{slug}.dcdl"));
|
||||
}
|
||||
entrypoints.insert(selected, selected_path);
|
||||
|
||||
let mut sources = BTreeMap::new();
|
||||
sources.insert(
|
||||
"profiles/base.dcdl".to_string(),
|
||||
include_str!("../../../resources/profiles/base.dcdl").to_string(),
|
||||
);
|
||||
sources.insert(
|
||||
"profiles/companion.dcdl".to_string(),
|
||||
include_str!("../../../resources/profiles/companion.dcdl").to_string(),
|
||||
);
|
||||
sources.insert(
|
||||
"profiles/intake.dcdl".to_string(),
|
||||
include_str!("../../../resources/profiles/intake.dcdl").to_string(),
|
||||
);
|
||||
sources.insert(
|
||||
"profiles/orchestrator.dcdl".to_string(),
|
||||
include_str!("../../../resources/profiles/orchestrator.dcdl").to_string(),
|
||||
);
|
||||
sources.insert(
|
||||
"profiles/coder.dcdl".to_string(),
|
||||
include_str!("../../../resources/profiles/coder.dcdl").to_string(),
|
||||
);
|
||||
sources.insert(
|
||||
"profiles/reviewer.dcdl".to_string(),
|
||||
include_str!("../../../resources/profiles/reviewer.dcdl").to_string(),
|
||||
);
|
||||
sources.insert(
|
||||
"profiles/memory-consolidation.dcdl".to_string(),
|
||||
include_str!("../../../resources/profiles/memory-consolidation.dcdl").to_string(),
|
||||
);
|
||||
|
||||
let mut imports = BTreeMap::new();
|
||||
for slug in [
|
||||
"companion",
|
||||
"intake",
|
||||
"orchestrator",
|
||||
"coder",
|
||||
"reviewer",
|
||||
"memory-consolidation",
|
||||
] {
|
||||
imports.insert(
|
||||
format!("profiles/{slug}.dcdl\0./base.dcdl"),
|
||||
"profiles/base.dcdl".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
ProfileSourceArchive::build(ProfileSourceArchiveInput {
|
||||
id: "builtin-decodal-profiles-v1".to_string(),
|
||||
entrypoints,
|
||||
imports,
|
||||
sources,
|
||||
})
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn embedded_profile_path(profile: &ProfileSelector) -> Result<String, String> {
|
||||
match profile {
|
||||
ProfileSelector::Builtin(name) => match name.strip_prefix("builtin:").unwrap_or(name) {
|
||||
"companion" => Ok("profiles/companion.dcdl".to_string()),
|
||||
"intake" => Ok("profiles/intake.dcdl".to_string()),
|
||||
"orchestrator" => Ok("profiles/orchestrator.dcdl".to_string()),
|
||||
"coder" => Ok("profiles/coder.dcdl".to_string()),
|
||||
"reviewer" => Ok("profiles/reviewer.dcdl".to_string()),
|
||||
"memory-consolidation" => Ok("profiles/memory-consolidation.dcdl".to_string()),
|
||||
other => Err(format!("unknown builtin profile selector: builtin:{other}")),
|
||||
},
|
||||
ProfileSelector::Named(name) => Err(format!("unknown named profile selector: {name}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn embedded_profile_label(profile: &ProfileSelector) -> Option<String> {
|
||||
Some(match profile {
|
||||
ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => {
|
||||
@@ -3825,6 +3736,39 @@ fn embedded_profile_label(profile: &ProfileSelector) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn builtin_profile_source_archive(
|
||||
profile: &ProfileSelector,
|
||||
) -> Result<ProfileSourceArchive, String> {
|
||||
let selected_profile = match profile {
|
||||
ProfileSelector::Builtin(name) => {
|
||||
if name.starts_with("builtin:") {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("builtin:{name}")
|
||||
}
|
||||
}
|
||||
ProfileSelector::Named(name) => {
|
||||
return Err(format!(
|
||||
"embedded runtime does not provide named Profile `{name}`"
|
||||
));
|
||||
}
|
||||
};
|
||||
let catalog = manifest::builtin_profile_catalog_snapshot();
|
||||
if !catalog.entrypoints.contains_key(&selected_profile) {
|
||||
return Err(format!(
|
||||
"embedded runtime does not provide Profile `{selected_profile}`"
|
||||
));
|
||||
}
|
||||
|
||||
ProfileSourceArchive::build(ProfileSourceArchiveInput {
|
||||
id: catalog.id.to_owned(),
|
||||
sources: catalog.sources,
|
||||
entrypoints: catalog.entrypoints,
|
||||
imports: catalog.imports,
|
||||
})
|
||||
.map_err(|error| format!("failed to build built-in Profile source archive: {error}"))
|
||||
}
|
||||
|
||||
const MEMORY_CONSOLIDATION_PROFILE: &str = "memory-consolidation";
|
||||
const MEMORY_CONSOLIDATION_SINGLETON_KEY: &str = "workspace-memory-consolidation";
|
||||
const WORKSPACE_ORCHESTRATOR_PROFILE: &str = "orchestrator";
|
||||
@@ -4450,7 +4394,40 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(companion.feature.manage_workdir.enabled);
|
||||
assert!(companion.feature.sub_worker.enabled);
|
||||
assert!(!companion.feature.worker.enabled);
|
||||
assert!(companion.feature.worker.enabled);
|
||||
assert!(!companion.feature.worker.direct_spawn);
|
||||
assert!(companion.feature.workspace_worker_discovery.enabled);
|
||||
let default_from_archive = archive
|
||||
.resolve_profile("builtin:default", root.path(), "embedded-test-default")
|
||||
.unwrap();
|
||||
let default_from_native = manifest::ProfileResolver::new()
|
||||
.with_workspace_base(root.path())
|
||||
.resolve(
|
||||
&manifest::ProfileSelector::source_named(
|
||||
manifest::ProfileRegistrySource::Builtin,
|
||||
"default",
|
||||
),
|
||||
manifest::ProfileResolveOptions::with_worker_name("embedded-test-default"),
|
||||
)
|
||||
.unwrap()
|
||||
.manifest;
|
||||
let mut archive_value = serde_json::to_value(&default_from_archive).unwrap();
|
||||
let mut native_value = serde_json::to_value(&default_from_native).unwrap();
|
||||
let archive_profile = archive_value
|
||||
.as_object_mut()
|
||||
.and_then(|value| value.remove("profile"))
|
||||
.expect("archive resolution records Profile provenance");
|
||||
let native_profile = native_value
|
||||
.as_object_mut()
|
||||
.and_then(|value| value.remove("profile"))
|
||||
.expect("native resolution records Profile provenance");
|
||||
assert_eq!(archive_value, native_value);
|
||||
assert_eq!(archive_profile["source"]["kind"], "archive");
|
||||
assert_eq!(native_profile["source"]["kind"], "registry");
|
||||
assert!(default_from_archive.feature.sub_worker.enabled);
|
||||
assert!(!default_from_archive.feature.ticket.enabled);
|
||||
assert!(!default_from_archive.feature.objective.enabled);
|
||||
|
||||
let coder = archive
|
||||
.resolve_profile("builtin:coder", root.path(), "embedded-test-coder")
|
||||
.unwrap();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -996,7 +996,7 @@ pub trait ControlPlaneStore: Send + Sync {
|
||||
fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()>;
|
||||
fn resolve_api_token(&self, token_hash: &str) -> Result<Option<ApiTokenRecord>>;
|
||||
fn mark_api_token_used(&self, token_hash: &str, used_at: &str) -> Result<()>;
|
||||
fn create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<()>;
|
||||
fn try_create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<bool>;
|
||||
fn get_device_login_flow_by_user_code(
|
||||
&self,
|
||||
user_code: &str,
|
||||
@@ -3123,14 +3123,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<()> {
|
||||
fn try_create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<bool> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
let changed = conn.execute(
|
||||
r#"INSERT INTO device_login_flows (device_code, user_code, verification_uri, client_name, user_id, api_token_id, issued_access_token, created_at, expires_at, approved_at, consumed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"#,
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
||||
ON CONFLICT(user_code) DO NOTHING"#,
|
||||
params![record.device_code, record.user_code, record.verification_uri, record.client_name, record.user_id, record.api_token_id, record.issued_access_token, record.created_at, record.expires_at, record.approved_at, record.consumed_at],
|
||||
)?;
|
||||
Ok(())
|
||||
Ok(changed == 1)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13524,7 +13525,67 @@ CREATE TABLE ticket_assignment_operations (
|
||||
approved_at: None,
|
||||
consumed_at: None,
|
||||
};
|
||||
store.create_device_login_flow(&flow).unwrap();
|
||||
assert!(store.try_create_device_login_flow(&flow).unwrap());
|
||||
let conflicting_flow = DeviceLoginFlowRecord {
|
||||
device_code: "device-2".into(),
|
||||
user_code: flow.user_code.clone(),
|
||||
verification_uri: flow.verification_uri.clone(),
|
||||
client_name: Some("other-cli".into()),
|
||||
user_id: None,
|
||||
api_token_id: None,
|
||||
issued_access_token: None,
|
||||
created_at: "2026-08-22T00:00:01Z".into(),
|
||||
expires_at: "2026-08-22T00:10:01Z".into(),
|
||||
approved_at: None,
|
||||
consumed_at: None,
|
||||
};
|
||||
assert!(
|
||||
!store
|
||||
.try_create_device_login_flow(&conflicting_flow)
|
||||
.expect("user code collision")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_device_login_flow_by_device_code("device-2")
|
||||
.expect("read conflicting flow")
|
||||
.is_none()
|
||||
);
|
||||
let fresh_flow = DeviceLoginFlowRecord {
|
||||
device_code: "device-3".into(),
|
||||
user_code: "DCBA-5678".into(),
|
||||
verification_uri: flow.verification_uri.clone(),
|
||||
client_name: Some("retry-cli".into()),
|
||||
user_id: None,
|
||||
api_token_id: None,
|
||||
issued_access_token: None,
|
||||
created_at: "2026-08-22T00:00:02Z".into(),
|
||||
expires_at: "2026-08-22T00:10:02Z".into(),
|
||||
approved_at: None,
|
||||
consumed_at: None,
|
||||
};
|
||||
assert!(
|
||||
store
|
||||
.try_create_device_login_flow(&fresh_flow)
|
||||
.expect("fresh user code")
|
||||
);
|
||||
let device_code_conflict = DeviceLoginFlowRecord {
|
||||
device_code: flow.device_code.clone(),
|
||||
user_code: "ABCD-9999".into(),
|
||||
verification_uri: flow.verification_uri.clone(),
|
||||
client_name: Some("invalid-cli".into()),
|
||||
user_id: None,
|
||||
api_token_id: None,
|
||||
issued_access_token: None,
|
||||
created_at: "2026-08-22T00:00:03Z".into(),
|
||||
expires_at: "2026-08-22T00:10:03Z".into(),
|
||||
approved_at: None,
|
||||
consumed_at: None,
|
||||
};
|
||||
assert!(
|
||||
store
|
||||
.try_create_device_login_flow(&device_code_conflict)
|
||||
.is_err()
|
||||
);
|
||||
store
|
||||
.approve_device_login_flow(
|
||||
"device",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use client::{BackendTarget, LocalTarget, Target, TargetKind};
|
||||
use client::{BackendTarget, StandaloneTarget, Target, TargetKind};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{ParseError, read_client_default_connection, resolve_backend_url};
|
||||
@@ -74,13 +74,14 @@ impl CliCommand {
|
||||
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub(crate) enum ClientDefaultConnection {
|
||||
Local,
|
||||
#[serde(rename = "local")]
|
||||
Standalone,
|
||||
Backend,
|
||||
}
|
||||
|
||||
impl Default for ClientDefaultConnection {
|
||||
fn default() -> Self {
|
||||
Self::Local
|
||||
Self::Standalone
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@ pub(crate) enum CliConnectionInput<'a> {
|
||||
DefaultTarget {
|
||||
workspace_id: Option<&'a str>,
|
||||
},
|
||||
LocalTarget,
|
||||
StandaloneTarget,
|
||||
BackendTarget {
|
||||
explicit_backend_url: Option<String>,
|
||||
workspace_id: Option<&'a str>,
|
||||
@@ -107,6 +108,20 @@ pub(crate) trait CliConnectionResolver {
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub(crate) struct ClientConfigCliConnectionResolver;
|
||||
|
||||
fn standalone_target() -> Result<Box<dyn Target>, ParseError> {
|
||||
let state_dir = manifest::paths::data_dir()
|
||||
.ok_or_else(|| {
|
||||
ParseError(
|
||||
"Standalone state directory is unavailable; set YOI_DATA_DIR, YOI_HOME, XDG_DATA_HOME, or HOME"
|
||||
.to_string(),
|
||||
)
|
||||
})?
|
||||
.join("client")
|
||||
.join("standalone")
|
||||
.join("sessions");
|
||||
Ok(Box::new(StandaloneTarget::new(state_dir)))
|
||||
}
|
||||
|
||||
impl CliConnectionResolver for ClientConfigCliConnectionResolver {
|
||||
fn resolve_connection(
|
||||
&self,
|
||||
@@ -116,15 +131,15 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver {
|
||||
match (command.connection_requirement(), input) {
|
||||
(
|
||||
CliConnectionRequirement::LocalOnly,
|
||||
CliConnectionInput::DefaultTarget { .. } | CliConnectionInput::LocalTarget,
|
||||
) => Ok(Box::new(LocalTarget::new())),
|
||||
CliConnectionInput::DefaultTarget { .. } | CliConnectionInput::StandaloneTarget,
|
||||
) => standalone_target(),
|
||||
(CliConnectionRequirement::LocalOnly, CliConnectionInput::BackendTarget { .. }) => {
|
||||
Err(ParseError(format!(
|
||||
"{} uses a local connection target and cannot accept Backend target options",
|
||||
"{} uses a host-only connection target and cannot accept Backend target options",
|
||||
command.display_name()
|
||||
)))
|
||||
}
|
||||
(CliConnectionRequirement::BackendOnly, CliConnectionInput::LocalTarget) => {
|
||||
(CliConnectionRequirement::BackendOnly, CliConnectionInput::StandaloneTarget) => {
|
||||
Err(ParseError(format!(
|
||||
"{} requires a Backend connection target",
|
||||
command.display_name()
|
||||
@@ -147,14 +162,14 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver {
|
||||
resolve_backend_url(explicit_backend_url, workspace_id)?,
|
||||
workspace_id.map(str::to_string),
|
||||
))),
|
||||
(CliConnectionRequirement::ConnectionAware, CliConnectionInput::LocalTarget) => {
|
||||
Ok(Box::new(LocalTarget::new()))
|
||||
(CliConnectionRequirement::ConnectionAware, CliConnectionInput::StandaloneTarget) => {
|
||||
standalone_target()
|
||||
}
|
||||
(
|
||||
CliConnectionRequirement::ConnectionAware,
|
||||
CliConnectionInput::DefaultTarget { workspace_id },
|
||||
) => match read_client_default_connection()? {
|
||||
ClientDefaultConnection::Local => Ok(Box::new(LocalTarget::new())),
|
||||
ClientDefaultConnection::Standalone => standalone_target(),
|
||||
ClientDefaultConnection::Backend => Ok(Box::new(BackendTarget::new(
|
||||
resolve_backend_url(None, workspace_id)?,
|
||||
workspace_id.map(str::to_string),
|
||||
@@ -168,11 +183,11 @@ pub(crate) fn resolve_local_cli_connection<R: CliConnectionResolver + ?Sized>(
|
||||
resolver: &R,
|
||||
command: CliCommand,
|
||||
) -> Result<Box<dyn Target>, ParseError> {
|
||||
let target = resolver.resolve_connection(command, CliConnectionInput::LocalTarget)?;
|
||||
let target = resolver.resolve_connection(command, CliConnectionInput::StandaloneTarget)?;
|
||||
match target.kind() {
|
||||
TargetKind::Local => Ok(target),
|
||||
TargetKind::Standalone => Ok(target),
|
||||
TargetKind::Backend => Err(ParseError(format!(
|
||||
"{} resolved a Backend target where a local target was required",
|
||||
"{} resolved a Backend target where a Standalone target was required",
|
||||
command.display_name()
|
||||
))),
|
||||
}
|
||||
@@ -193,8 +208,8 @@ pub(crate) fn resolve_backend_cli_connection<R: CliConnectionResolver + ?Sized>(
|
||||
)?;
|
||||
match target.kind() {
|
||||
TargetKind::Backend => Ok(target),
|
||||
TargetKind::Local => Err(ParseError(format!(
|
||||
"{} resolved a local target where a Backend target was required",
|
||||
TargetKind::Standalone => Err(ParseError(format!(
|
||||
"{} resolved a non-Backend target where a Backend target was required",
|
||||
command.display_name()
|
||||
))),
|
||||
}
|
||||
@@ -213,7 +228,7 @@ pub(crate) fn resolve_connection_aware_cli_connection<R: CliConnectionResolver +
|
||||
));
|
||||
}
|
||||
if explicit_local {
|
||||
return resolver.resolve_connection(command, CliConnectionInput::LocalTarget);
|
||||
return resolver.resolve_connection(command, CliConnectionInput::StandaloneTarget);
|
||||
}
|
||||
if explicit_backend_url.is_some() {
|
||||
return resolver.resolve_connection(
|
||||
@@ -232,7 +247,7 @@ pub(crate) fn backend_target_option_error_for_local_command(
|
||||
option: &str,
|
||||
) -> ParseError {
|
||||
ParseError(format!(
|
||||
"{} uses a local connection target and cannot accept Backend target option `{option}`",
|
||||
"{} uses a host-only connection target and cannot accept Backend target option `{option}`",
|
||||
command.display_name()
|
||||
))
|
||||
}
|
||||
@@ -294,7 +309,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_connection_resolver_rejects_local_only_backend_target() {
|
||||
fn cli_connection_resolver_rejects_host_only_backend_target() {
|
||||
let resolver = ClientConfigCliConnectionResolver;
|
||||
let err = resolver
|
||||
.resolve_connection(
|
||||
@@ -308,15 +323,15 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"yoi keys uses a local connection target and cannot accept Backend target options"
|
||||
"yoi keys uses a host-only connection target and cannot accept Backend target options"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_connection_resolver_rejects_backend_only_local_target() {
|
||||
fn cli_connection_resolver_rejects_backend_only_standalone_target() {
|
||||
let resolver = ClientConfigCliConnectionResolver;
|
||||
let err = resolver
|
||||
.resolve_connection(CliCommand::Login, CliConnectionInput::LocalTarget)
|
||||
.resolve_connection(CliCommand::Login, CliConnectionInput::StandaloneTarget)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
@@ -340,7 +355,7 @@ mod tests {
|
||||
let workers = target
|
||||
.list_workers(client::WorkerListRequest::new(None))
|
||||
.unwrap();
|
||||
let backend_target = workers.backend_target.as_ref().unwrap();
|
||||
let backend_target = workers.backend_target;
|
||||
assert_eq!(backend_target.base_url, "http://127.0.0.1:8787");
|
||||
assert_eq!(backend_target.workspace_id, None);
|
||||
}
|
||||
|
||||
+676
-363
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user