Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cae4fd05c | ||
|
|
ebb272324c | ||
|
|
c0290512b3 | ||
|
|
2bab8a9bb6 | ||
|
|
32fdd076bf | ||
|
|
402ae0d466 | ||
|
|
acb3c6d68b | ||
|
|
40ac83e632 | ||
|
|
58da395941 | ||
|
|
0e3ef94c9e | ||
|
|
1f68dfc2b5 | ||
|
|
84977a464c | ||
|
|
8cc1dc042d | ||
|
|
651d64f34d | ||
|
|
b98d4b59f5 | ||
|
|
df6d99c07d | ||
|
|
9843510e1f | ||
|
|
83bda3dfb2 |
Generated
+1
@@ -637,6 +637,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
name = "client"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"futures",
|
||||
"manifest",
|
||||
"protocol",
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Err(error) => diagnostics.push(BackendDiagnostic {
|
||||
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;
|
||||
}
|
||||
};
|
||||
if matches!(
|
||||
response.status(),
|
||||
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
|
||||
) {
|
||||
api.check_status(response.status())?;
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
diagnostics.push(BackendDiagnostic {
|
||||
code: "runtime_worker_list_failed".to_string(),
|
||||
severity: BackendDiagnosticSeverity::Error,
|
||||
message: format!(
|
||||
"failed to list workers for runtime {}: Backend returned HTTP {}",
|
||||
runtime.runtime_id,
|
||||
response.status().as_u16()
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let response = response
|
||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||
.await?;
|
||||
diagnostics.extend(response.diagnostics);
|
||||
items.extend(response.items);
|
||||
}
|
||||
|
||||
Ok(BackendRuntimeListResponse {
|
||||
@@ -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() {
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
//!
|
||||
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
||||
|
||||
pub mod backend_auth;
|
||||
pub mod backend_api;
|
||||
mod backend_auth;
|
||||
pub mod backend_runtime;
|
||||
pub mod backend_workspace;
|
||||
pub mod runtime_command;
|
||||
@@ -18,6 +19,10 @@ 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,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::fmt;
|
||||
|
||||
use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
|
||||
use crate::{
|
||||
BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget,
|
||||
BackendRuntimeTarget, WorkerRuntimeCommand,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TargetKind {
|
||||
@@ -62,11 +65,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)]
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -279,4 +279,58 @@ mod tests {
|
||||
assert_eq!(grep.matched_files, 2);
|
||||
assert!(!grep.output.contains("c.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_content_groups_lines_by_file_and_marks_matches() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
temp.path().join("first.txt"),
|
||||
"before\nneedle one\nafter\nomitted one\nomitted two\nbefore distant\nneedle distant\nafter distant\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(temp.path().join("second.txt"), "needle two\n").unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
let readable = RootAccess(root.clone());
|
||||
|
||||
let grep = run_grep(
|
||||
&root,
|
||||
root.clone(),
|
||||
GrepRequest {
|
||||
pattern: "needle".to_string(),
|
||||
path: FsPath::root(),
|
||||
glob: Some("*.txt".to_string()),
|
||||
output_mode: GrepOutputMode::Content,
|
||||
case_insensitive: false,
|
||||
before_context: 1,
|
||||
after_context: 1,
|
||||
multiline: false,
|
||||
file_type: None,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
},
|
||||
&readable,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(grep.match_count, 3);
|
||||
assert_eq!(grep.matched_files, 2);
|
||||
assert_eq!(
|
||||
grep.output,
|
||||
concat!(
|
||||
"first.txt\n",
|
||||
" 1 │ before\n",
|
||||
" > 2 │ needle one\n",
|
||||
" 3 │ after\n",
|
||||
" …\n",
|
||||
" 6 │ before distant\n",
|
||||
" > 7 │ needle distant\n",
|
||||
" 8 │ after distant\n",
|
||||
"\n",
|
||||
"second.txt\n",
|
||||
" > 1 │ needle two\n",
|
||||
)
|
||||
);
|
||||
assert_eq!(grep.output.matches("first.txt").count(), 1);
|
||||
assert_eq!(grep.output.matches("second.txt").count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::FsAccessPolicy;
|
||||
@@ -57,20 +59,11 @@ impl GrepReport {
|
||||
}
|
||||
}
|
||||
GrepOutputMode::Content => {
|
||||
for line in &self.lines {
|
||||
let separator = if line.is_match { ':' } else { '-' };
|
||||
let path = logical_display(root, &line.path);
|
||||
if self.show_line_numbers
|
||||
&& let Some(number) = line.line_number
|
||||
{
|
||||
output.push_str(&format!(
|
||||
"{path}{separator}{number}{separator}{}\n",
|
||||
line.text
|
||||
output.push_str(&render_content_lines(
|
||||
root,
|
||||
&self.lines,
|
||||
self.show_line_numbers,
|
||||
));
|
||||
} else {
|
||||
output.push_str(&format!("{path}{separator}{}\n", line.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GrepResult {
|
||||
@@ -82,6 +75,48 @@ impl GrepReport {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_content_lines(root: &Path, lines: &[ContentLine], show_line_numbers: bool) -> String {
|
||||
let mut grouped = BTreeMap::<&Path, Vec<&ContentLine>>::new();
|
||||
for line in lines {
|
||||
grouped.entry(&line.path).or_default().push(line);
|
||||
}
|
||||
|
||||
let mut output = String::new();
|
||||
for (file_index, (path, file_lines)) in grouped.into_iter().enumerate() {
|
||||
if file_index > 0 {
|
||||
output.push('\n');
|
||||
}
|
||||
let _ = writeln!(output, "{}", logical_display(root, path));
|
||||
|
||||
let number_width = file_lines
|
||||
.iter()
|
||||
.filter_map(|line| line.line_number)
|
||||
.map(|number| number.to_string().len())
|
||||
.max()
|
||||
.unwrap_or(1);
|
||||
let mut previous_line_end = None;
|
||||
for line in file_lines {
|
||||
if let (Some(previous_end), Some(number)) = (previous_line_end, line.line_number)
|
||||
&& number > previous_end
|
||||
{
|
||||
let _ = writeln!(output, " …");
|
||||
}
|
||||
|
||||
let marker = if line.is_match { '>' } else { ' ' };
|
||||
if show_line_numbers && let Some(number) = line.line_number {
|
||||
let _ = writeln!(output, " {marker} {number:>number_width$} │ {}", line.text);
|
||||
} else {
|
||||
let _ = writeln!(output, " {marker} │ {}", line.text);
|
||||
}
|
||||
previous_line_end = line
|
||||
.line_number
|
||||
.map(|number| number + line.text.split('\n').count() as u64);
|
||||
}
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn logical_display(root: &Path, path: &Path) -> String {
|
||||
path.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
|
||||
@@ -946,7 +946,7 @@ fn apply_role_profile(
|
||||
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": matches!(slug, "companion" | "orchestrator"),
|
||||
"enabled": slug == "orchestrator",
|
||||
"direct_spawn": slug != "orchestrator"
|
||||
});
|
||||
value["feature"]["manage_workdir"] = serde_json::json!({
|
||||
@@ -1408,7 +1408,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_companion_can_manage_workdirs() {
|
||||
fn builtin_coder_uses_sub_worker_control_without_worker_control() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"),
|
||||
ProfileResolveOptions::with_worker_name("coder-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||
assert!(!resolved.manifest.feature.worker.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_companion_uses_sub_worker_control_without_worker_control() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
@@ -1419,6 +1434,8 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
assert!(resolved.manifest.feature.manage_workdir.enabled);
|
||||
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||
assert!(!resolved.manifest.feature.worker.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1546,7 +1546,7 @@ fn model_ticket_reference(
|
||||
match ticket.meta.resource_key {
|
||||
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
|
||||
Some(_) => Err(ToolError::ExecutionFailed(format!(
|
||||
"{tool_name} failed: required Ticket human key is unavailable"
|
||||
"{tool_name} failed: required Ticket key is unavailable"
|
||||
))),
|
||||
None => Ok(ticket.meta.id),
|
||||
}
|
||||
|
||||
@@ -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. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
|
||||
.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.")
|
||||
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
|
||||
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
|
||||
session: session.clone(),
|
||||
|
||||
@@ -998,14 +998,6 @@ where
|
||||
|
||||
if feature_config.sub_worker.enabled {
|
||||
worker.register_worker_orchestration_instruction();
|
||||
if !feature_config.worker.enabled {
|
||||
feature_registry.add_module(
|
||||
crate::feature::builtin::manage_worker::sub_worker_control_feature(
|
||||
worker.workspace_client_handle(),
|
||||
spawned_registry.clone(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let host_worker_observation_provider = worker.worker_observation_provider();
|
||||
|
||||
@@ -209,9 +209,7 @@ impl WorkspaceHttpObjectiveBackend {
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|key| is_canonical_resource_key(key, "T-"))
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
ToolError::ExecutionFailed("required T- human key is unavailable".to_string())
|
||||
})
|
||||
.ok_or_else(|| ToolError::ExecutionFailed("required T- key is unavailable".to_string()))
|
||||
}
|
||||
|
||||
fn objective_url(&self, id: &str) -> String {
|
||||
@@ -295,7 +293,7 @@ fn is_canonical_resource_key(resource_key: &str, prefix: &str) -> bool {
|
||||
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
|
||||
if !is_canonical_resource_key(&response.resource_key, "O-") {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"required O- human key is unavailable".to_string(),
|
||||
"required O- key is unavailable".to_string(),
|
||||
));
|
||||
}
|
||||
let projected = serde_json::json!({
|
||||
@@ -712,7 +710,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn objective_show_summary_uses_projected_human_key() {
|
||||
async fn objective_show_summary_uses_projected_key() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
@@ -764,7 +762,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn objective_link_summaries_resolve_internal_ticket_ids_to_human_keys() {
|
||||
async fn objective_link_summaries_resolve_internal_ticket_ids_to_keys() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
@@ -833,7 +831,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objective_output_rejects_noncanonical_human_keys() {
|
||||
fn objective_output_rejects_noncanonical_keys() {
|
||||
let response = ObjectiveDetail {
|
||||
resource_key: "O-internal".to_string(),
|
||||
title: "Objective".to_string(),
|
||||
|
||||
@@ -228,7 +228,7 @@ pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryRespo
|
||||
fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, String> {
|
||||
let item = object(value, "Ticket query item")?;
|
||||
Ok(ModelTicketQueryItem {
|
||||
ticket: human_ref(item, "resource_key", "T-")?,
|
||||
ticket: resource_ref(item, "resource_key", "T-")?,
|
||||
title: string_field(item, "title")?,
|
||||
state: string_field(item, "state")?,
|
||||
readiness: optional_string(item, "readiness")?,
|
||||
@@ -245,7 +245,7 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
||||
.transpose()?,
|
||||
linked_objectives: string_array(item, "linked_objective_keys")?
|
||||
.into_iter()
|
||||
.map(|key| validate_human_ref(key, "O-"))
|
||||
.map(|key| validate_resource_ref(key, "O-"))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
relation_count: usize_field(item, "relation_count")?,
|
||||
blocker_count: usize_field(item, "blocker_count")?,
|
||||
@@ -273,7 +273,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(ModelTicketDetail {
|
||||
ticket: human_ref(root, "resource_key", "T-")?,
|
||||
ticket: resource_ref(root, "resource_key", "T-")?,
|
||||
title: string_field(root, "title")?,
|
||||
body: string_field(root, "body")?,
|
||||
state: string_field(root, "state")?,
|
||||
@@ -332,10 +332,10 @@ fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem
|
||||
let item = object(value, "Objective query item")?;
|
||||
let linked_tickets = string_array(item, "linked_ticket_keys")?
|
||||
.into_iter()
|
||||
.map(|key| validate_human_ref(key, "T-"))
|
||||
.map(|key| validate_resource_ref(key, "T-"))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(ModelObjectiveQueryItem {
|
||||
objective: human_ref(item, "resource_key", "O-")?,
|
||||
objective: resource_ref(item, "resource_key", "O-")?,
|
||||
title: string_field(item, "title")?,
|
||||
summary: optional_string(item, "snippet")?,
|
||||
state: string_field(item, "state")?,
|
||||
@@ -349,7 +349,7 @@ fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem
|
||||
pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDetail, String> {
|
||||
let root = object(&value, "Objective detail response")?;
|
||||
Ok(ModelObjectiveDetail {
|
||||
objective: human_ref(root, "resource_key", "O-")?,
|
||||
objective: resource_ref(root, "resource_key", "O-")?,
|
||||
title: string_field(root, "title")?,
|
||||
body: string_field(root, "body")?,
|
||||
state: string_field(root, "state")?,
|
||||
@@ -373,7 +373,7 @@ pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDet
|
||||
fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
|
||||
let worker = object(value, "Worker summary")?;
|
||||
Ok(ModelWorkerSummary {
|
||||
worker: human_ref(worker, "worker_resource_key", "W-")?,
|
||||
worker: resource_ref(worker, "worker_resource_key", "W-")?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -422,34 +422,18 @@ 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: human_ref(relation, ticket_key, "T-")?,
|
||||
kind,
|
||||
note,
|
||||
created_at,
|
||||
ticket: resource_ref(relation, ticket_key, "T-")?,
|
||||
kind: string_field(relation, kind_key)?,
|
||||
note: optional_string(relation, "note")?,
|
||||
created_at: optional_string(relation, "at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
|
||||
let blocker = object(value, "Ticket blocker")?;
|
||||
Ok(ModelBlocker {
|
||||
ticket: human_ref(blocker, "blocking_resource_key", "T-")?,
|
||||
ticket: resource_ref(blocker, "blocking_resource_key", "T-")?,
|
||||
kind: string_field(blocker, "relation_kind")?,
|
||||
state: optional_string(blocker, "blocking_state")?,
|
||||
resolved: bool_field(blocker, "resolved")?,
|
||||
@@ -466,7 +450,7 @@ fn project_notice(value: &Value) -> Result<ModelNotice, String> {
|
||||
fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, String> {
|
||||
let summary = object(value, "Objective summary")?;
|
||||
Ok(ModelObjectiveSummary {
|
||||
objective: human_ref(summary, "resource_key", "O-")?,
|
||||
objective: resource_ref(summary, "resource_key", "O-")?,
|
||||
title: string_field(summary, "title")?,
|
||||
state: string_field(summary, "state")?,
|
||||
})
|
||||
@@ -475,7 +459,7 @@ fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, Str
|
||||
fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
|
||||
let summary = object(value, "Ticket summary")?;
|
||||
Ok(ModelTicketSummary {
|
||||
ticket: human_ref(summary, "resource_key", "T-")?,
|
||||
ticket: resource_ref(summary, "resource_key", "T-")?,
|
||||
title: string_field(summary, "title")?,
|
||||
state: string_field(summary, "state")?,
|
||||
})
|
||||
@@ -491,9 +475,7 @@ fn project_assignment(
|
||||
let principal = match kind.as_str() {
|
||||
"worker" => current_coder
|
||||
.map(|coder| coder.worker.clone())
|
||||
.ok_or_else(|| {
|
||||
"Worker assignment is missing a Workspace human key projection".to_string()
|
||||
})?,
|
||||
.ok_or_else(|| "Worker assignment is missing a Workspace key projection".to_string())?,
|
||||
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
|
||||
"user" => "user".to_string(),
|
||||
other => format!("source:{other}"),
|
||||
@@ -645,23 +627,23 @@ fn string_array(object: &Map<String, Value>, key: &str) -> Result<Vec<String>, S
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn human_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
|
||||
fn resource_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
|
||||
let value = object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| format!("required {prefix} human key is unavailable"))?;
|
||||
validate_human_ref(value, prefix)
|
||||
.ok_or_else(|| format!("required {prefix} key is unavailable"))?;
|
||||
validate_resource_ref(value, prefix)
|
||||
}
|
||||
|
||||
fn validate_human_ref(value: String, prefix: &str) -> Result<String, String> {
|
||||
fn validate_resource_ref(value: String, prefix: &str) -> Result<String, String> {
|
||||
let valid = value.strip_prefix(prefix).is_some_and(|sequence| {
|
||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||
});
|
||||
if valid {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(format!("required {prefix} human key is unavailable"))
|
||||
Err(format!("required {prefix} key is unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,7 +653,7 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn objective_projection_exposes_only_human_resource_references() {
|
||||
fn objective_projection_exposes_only_resource_references() {
|
||||
let projected = project_objective_detail(json!({
|
||||
"id": "00001M10HW6BV",
|
||||
"resource_key": "O-543",
|
||||
@@ -779,9 +761,98 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn human_resource_projection_rejects_noncanonical_keys() {
|
||||
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-")] {
|
||||
assert!(validate_human_ref(key.to_string(), prefix).is_err());
|
||||
assert!(validate_resource_ref(key.to_string(), prefix).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -306,9 +306,11 @@ struct BackendTicketService {
|
||||
backend: TicketToolBackend,
|
||||
}
|
||||
|
||||
impl TicketService for BackendTicketService {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
let ticket = self.backend.show(ticket_ref.into())?;
|
||||
struct WorkspaceTicketService {
|
||||
backend: WorkspaceHttpTicketBackend,
|
||||
}
|
||||
|
||||
fn ticket_handoff_from_record(ticket: Ticket) -> Result<TicketHandoff, TicketError> {
|
||||
let resource_key = ticket
|
||||
.meta
|
||||
.resource_key
|
||||
@@ -320,6 +322,17 @@ impl TicketService for BackendTicketService {
|
||||
workflow_state: ticket.meta.workflow_state,
|
||||
})
|
||||
}
|
||||
|
||||
impl TicketService for BackendTicketService {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
ticket_handoff_from_record(self.backend.show(ticket_ref.into())?)
|
||||
}
|
||||
}
|
||||
|
||||
impl TicketService for WorkspaceTicketService {
|
||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn ticket_workflow_instruction() -> FeatureInstructionDeclaration {
|
||||
@@ -640,9 +653,14 @@ impl FeatureModule for TicketFeature {
|
||||
let Some(backend) = self.tool_backend(context) else {
|
||||
return Ok(());
|
||||
};
|
||||
let ticket_service: Arc<dyn TicketService> = Arc::new(BackendTicketService {
|
||||
let ticket_service: Arc<dyn TicketService> = match &self.backend {
|
||||
TicketFeatureBackend::WorkspaceClient(client) => Arc::new(WorkspaceTicketService {
|
||||
backend: WorkspaceHttpTicketBackend::new(client.clone()),
|
||||
}),
|
||||
TicketFeatureBackend::Local { .. } => Arc::new(BackendTicketService {
|
||||
backend: backend.clone(),
|
||||
});
|
||||
}),
|
||||
};
|
||||
context.services().provide(
|
||||
ServiceDeclaration::new(
|
||||
ServiceId::builtin(TICKET_SERVICE_ID),
|
||||
@@ -714,6 +732,26 @@ impl WorkspaceHttpTicketBackend {
|
||||
Self::invoke_client(client, workspace_id, operation)
|
||||
}
|
||||
|
||||
fn show_unprojected(&self, ticket_ref: &str) -> TicketResult<Ticket> {
|
||||
let client = self.client.clone();
|
||||
let workspace_id = self.client.workspace_id().unwrap_or_default().to_string();
|
||||
let ticket_path = Self::ticket_path(&TicketIdOrSlug::from(ticket_ref));
|
||||
let request = move || {
|
||||
Self::request_unprojected(
|
||||
client,
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!("/api/w/{workspace_id}/tickets/{ticket_path}/record"),
|
||||
None,
|
||||
)
|
||||
};
|
||||
if tokio::runtime::Handle::try_current().is_ok() {
|
||||
return std::thread::spawn(request).join().map_err(|_| {
|
||||
TicketError::Conflict("ticket REST request thread panicked".to_string())
|
||||
})?;
|
||||
}
|
||||
request()
|
||||
}
|
||||
|
||||
fn ticket_path(id: &TicketIdOrSlug) -> String {
|
||||
let value = match id {
|
||||
TicketIdOrSlug::Id(value)
|
||||
@@ -738,6 +776,29 @@ impl WorkspaceHttpTicketBackend {
|
||||
endpoint: String,
|
||||
body: Option<serde_json::Value>,
|
||||
) -> TicketResult<T> {
|
||||
let mut value = Self::request_value(client, method, endpoint, body)?;
|
||||
Self::canonicalize_ticket_references(&mut value);
|
||||
serde_json::from_value(value)
|
||||
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
||||
}
|
||||
|
||||
fn request_unprojected<T: serde::de::DeserializeOwned>(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
method: WorkspaceRequestMethod,
|
||||
endpoint: String,
|
||||
body: Option<serde_json::Value>,
|
||||
) -> TicketResult<T> {
|
||||
let value = Self::request_value(client, method, endpoint, body)?;
|
||||
serde_json::from_value(value)
|
||||
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
||||
}
|
||||
|
||||
fn request_value(
|
||||
client: Arc<dyn WorkspaceClient>,
|
||||
method: WorkspaceRequestMethod,
|
||||
endpoint: String,
|
||||
body: Option<serde_json::Value>,
|
||||
) -> TicketResult<Value> {
|
||||
let request = match body {
|
||||
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
|
||||
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
|
||||
@@ -756,11 +817,7 @@ impl WorkspaceHttpTicketBackend {
|
||||
response.status
|
||||
)));
|
||||
}
|
||||
let mut value: Value = serde_json::from_str(&response.body).map_err(|error| {
|
||||
TicketError::Conflict(format!("decode ticket REST response: {error}"))
|
||||
})?;
|
||||
Self::canonicalize_ticket_references(&mut value);
|
||||
serde_json::from_value(value)
|
||||
serde_json::from_str(&response.body)
|
||||
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
||||
}
|
||||
|
||||
@@ -810,9 +867,7 @@ impl WorkspaceHttpTicketBackend {
|
||||
.and_then(Value::as_str)
|
||||
.filter(|key| is_canonical_ticket_resource_key(key))
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| {
|
||||
TicketError::Conflict("required Ticket human key is unavailable".to_string())
|
||||
})
|
||||
.ok_or_else(|| TicketError::Conflict("required Ticket key is unavailable".to_string()))
|
||||
}
|
||||
|
||||
fn request_unit(
|
||||
@@ -889,7 +944,7 @@ impl WorkspaceHttpTicketBackend {
|
||||
.is_some_and(is_canonical_ticket_resource_key)
|
||||
{
|
||||
return Err(TicketError::Conflict(
|
||||
"required Ticket human key is unavailable".to_string(),
|
||||
"required Ticket key is unavailable".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(TicketBackendOperationResult::Ticket(ticket))
|
||||
@@ -1861,7 +1916,7 @@ provider = "github"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_http_backend_records_relation_with_authoritative_human_keys() {
|
||||
fn workspace_http_backend_records_relation_with_authoritative_keys() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = thread::spawn(move || {
|
||||
@@ -2000,6 +2055,46 @@ provider = "github"
|
||||
assert_eq!(removed.target, "T-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_ticket_service_preserves_internal_identity_for_handoff() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let local = LocalTicketBackend::new(temp.path().join("tickets"));
|
||||
let created = local.create(NewTicket::new("Ticket handoff")).unwrap();
|
||||
let mut ticket = local.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
|
||||
ticket.meta.resource_key = Some("T-548".to_string());
|
||||
ticket.meta.workflow_state = TicketWorkflowState::Queued;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let response_body = serde_json::to_string(&ticket).unwrap();
|
||||
let server = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
let len = stream.read(&mut buffer).unwrap();
|
||||
let request = String::from_utf8_lossy(&buffer[..len]);
|
||||
assert!(request.starts_with("GET /api/w/workspace-a/tickets/T-548/record HTTP/1.1"));
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
|
||||
response_body.len(),
|
||||
response_body
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let service = WorkspaceTicketService {
|
||||
backend: WorkspaceHttpTicketBackend::new(Arc::new(
|
||||
crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url),
|
||||
)),
|
||||
};
|
||||
let handoff = service.ticket_handoff("T-548").unwrap();
|
||||
|
||||
server.join().unwrap();
|
||||
assert_eq!(handoff.id, created.id);
|
||||
assert_eq!(handoff.resource_key, "T-548");
|
||||
assert_eq!(handoff.workflow_state, TicketWorkflowState::Queued);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
|
||||
assert!(is_canonical_ticket_resource_key("T-482"));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4449,6 +4449,13 @@ mod tests {
|
||||
.resolve_profile("builtin:companion", root.path(), "embedded-test-companion")
|
||||
.unwrap();
|
||||
assert!(companion.feature.manage_workdir.enabled);
|
||||
assert!(companion.feature.sub_worker.enabled);
|
||||
assert!(!companion.feature.worker.enabled);
|
||||
let coder = archive
|
||||
.resolve_profile("builtin:coder", root.path(), "embedded-test-coder")
|
||||
.unwrap();
|
||||
assert!(coder.feature.sub_worker.enabled);
|
||||
assert!(!coder.feature.worker.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4475,6 +4482,8 @@ mod tests {
|
||||
.resolve_profile("builtin:coder", root.path(), "remote-test-worker")
|
||||
.unwrap();
|
||||
assert_eq!(manifest.worker.name, "remote-test-worker");
|
||||
assert!(manifest.feature.sub_worker.enabled);
|
||||
assert!(!manifest.feature.worker.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5458,6 +5458,36 @@ fn resolve_workspace_ticket_reference(
|
||||
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())).into())
|
||||
}
|
||||
|
||||
fn resolve_workspace_ticket_identity(
|
||||
api: &WorkspaceApi,
|
||||
workspace_id: &str,
|
||||
reference: &str,
|
||||
) -> ApiResult<String> {
|
||||
let ticket_id = resolve_workspace_ticket_reference(api, workspace_id, reference)?;
|
||||
let ticket = browser_ticket_backend(api)?
|
||||
.show(ticket_id.clone().into())
|
||||
.map_err(Error::from)?;
|
||||
if ticket.meta.id != ticket_id {
|
||||
return Err(Error::InvalidInput(
|
||||
"resolved Ticket identity does not match Ticket authority".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(ticket_id)
|
||||
}
|
||||
|
||||
fn resolve_workspace_worker_ticket_assignment(
|
||||
api: &WorkspaceApi,
|
||||
workspace_id: &str,
|
||||
assignment: &mut Option<CreateWorkspaceWorkerTicketAssignmentRequest>,
|
||||
) -> ApiResult<()> {
|
||||
if let Some(assignment) = assignment {
|
||||
assignment.ticket_id =
|
||||
resolve_workspace_ticket_identity(api, workspace_id, &assignment.ticket_id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct MergeRequestListHttpQuery {
|
||||
state: Option<String>,
|
||||
@@ -6418,9 +6448,18 @@ fn canonical_ticket_resource_key(resource_key: &str) -> Option<&str> {
|
||||
.then_some(resource_key)
|
||||
}
|
||||
|
||||
fn ticket_notification_content(resource_key: &str, current_state: &str) -> String {
|
||||
fn ticket_notification_content(
|
||||
resource_key: &str,
|
||||
previous_state: &str,
|
||||
current_state: &str,
|
||||
) -> String {
|
||||
if previous_state == current_state {
|
||||
return format!(
|
||||
"Ticket {resource_key} has new activity while {current_state}. Reread the current Ticket before acting."
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"Ticket {resource_key} changed to {current_state}. Reread the current Ticket before acting."
|
||||
"Ticket {resource_key} changed state from {previous_state} to {current_state}. Reread the current Ticket before acting."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6498,7 +6537,7 @@ fn notify_ticket_recipients(
|
||||
api: &WorkspaceApi,
|
||||
workspace_id: &str,
|
||||
ticket_id: &str,
|
||||
_previous_state: &str,
|
||||
previous_state: &str,
|
||||
current_state: &str,
|
||||
source: Option<RuntimeWorkerRef>,
|
||||
) {
|
||||
@@ -6532,7 +6571,7 @@ fn notify_ticket_recipients(
|
||||
recipients.sort();
|
||||
recipients.dedup();
|
||||
|
||||
let content = ticket_notification_content(resource_key, current_state);
|
||||
let content = ticket_notification_content(resource_key, previous_state, current_state);
|
||||
for recipient in recipients {
|
||||
if source.as_ref().is_some_and(|source| source == &recipient) {
|
||||
continue;
|
||||
@@ -8217,6 +8256,11 @@ async fn spawn_known_worker(
|
||||
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?;
|
||||
resolve_workspace_worker_ticket_assignment(
|
||||
&api,
|
||||
&path.workspace_id,
|
||||
&mut request.ticket_assignment,
|
||||
)?;
|
||||
let relation = if request.ticket_assignment.is_some() {
|
||||
"assigned"
|
||||
} else {
|
||||
@@ -9261,9 +9305,8 @@ fn cleanup_working_directory_for_runtime(
|
||||
result.diagnostics,
|
||||
));
|
||||
};
|
||||
let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary);
|
||||
api.store.upsert_workdir_registry(&record)?;
|
||||
let mut summary = working_directory.summary;
|
||||
persist_workdir_cleanup_observation(&api, runtime_id, &summary)?;
|
||||
apply_workdir_occupancy_projection(&api, &mut summary)?;
|
||||
Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
||||
workspace_id: api.config.workspace_id.clone(),
|
||||
@@ -10533,19 +10576,22 @@ async fn post_device_login_start(
|
||||
State(api): State<ServerAuthApi>,
|
||||
Json(request): Json<DeviceLoginStartRequest>,
|
||||
) -> ApiResult<Json<DeviceLoginStartResponse>> {
|
||||
const MAX_CODE_ALLOCATION_ATTEMPTS: usize = 8;
|
||||
|
||||
let auth = auth_public_config(&api.config);
|
||||
let device_code = mint_secret("yoi_device");
|
||||
let user_code = new_user_code();
|
||||
let verification_uri = format!(
|
||||
"{}/login/device",
|
||||
auth.public_base_url.trim_end_matches('/')
|
||||
);
|
||||
for _ in 0..MAX_CODE_ALLOCATION_ATTEMPTS {
|
||||
let device_code = mint_secret("yoi_device");
|
||||
let user_code = new_user_code();
|
||||
let verification_uri_complete = format!("{verification_uri}?user_code={user_code}");
|
||||
api.store.create_device_login_flow(&DeviceLoginFlowRecord {
|
||||
let flow = DeviceLoginFlowRecord {
|
||||
device_code: device_code.clone(),
|
||||
user_code: user_code.clone(),
|
||||
verification_uri: verification_uri.clone(),
|
||||
client_name: request.client_name,
|
||||
client_name: request.client_name.clone(),
|
||||
user_id: None,
|
||||
api_token_id: None,
|
||||
issued_access_token: None,
|
||||
@@ -10553,15 +10599,25 @@ async fn post_device_login_start(
|
||||
expires_at: rfc3339_after(Duration::minutes(10)),
|
||||
approved_at: None,
|
||||
consumed_at: None,
|
||||
})?;
|
||||
Ok(Json(DeviceLoginStartResponse {
|
||||
};
|
||||
if !api.store.try_create_device_login_flow(&flow)? {
|
||||
continue;
|
||||
}
|
||||
return Ok(Json(DeviceLoginStartResponse {
|
||||
device_code,
|
||||
user_code,
|
||||
verification_uri,
|
||||
verification_uri_complete,
|
||||
expires_in: 600,
|
||||
interval: 5,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
Err(auth_error(
|
||||
"device_login_code_allocation_failed",
|
||||
"could not allocate a unique device login user code",
|
||||
)
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn post_device_login_approve(
|
||||
@@ -14154,11 +14210,7 @@ fn sync_runtime_workdir_observations(
|
||||
api.store.upsert_workdir_registry(&updated)?;
|
||||
}
|
||||
} else {
|
||||
record.materialization_status =
|
||||
workdir_status_from_runtime_miss(result.diagnostics.as_slice()).to_string();
|
||||
record.cleanliness = "unknown".to_string();
|
||||
record.updated_at = now_registry_timestamp();
|
||||
api.store.upsert_workdir_registry(&record)?;
|
||||
persist_workdir_runtime_miss(api, record, result.diagnostics.as_slice())?;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
@@ -14172,17 +14224,54 @@ fn sync_runtime_workdir_observations(
|
||||
Ok(response.diagnostics)
|
||||
}
|
||||
|
||||
fn workdir_status_from_runtime_miss(diagnostics: &[RuntimeDiagnostic]) -> &'static str {
|
||||
if diagnostics
|
||||
fn persist_workdir_cleanup_observation(
|
||||
api: &WorkspaceApi,
|
||||
runtime_id: &str,
|
||||
summary: &WorkingDirectorySummary,
|
||||
) -> ApiResult<()> {
|
||||
if summary.status == WorkingDirectoryStatusKind::NotFound {
|
||||
api.store.delete_workdir_registry(
|
||||
&api.config.workspace_id,
|
||||
summary.working_directory_id.as_str(),
|
||||
)?;
|
||||
} else {
|
||||
let record = workdir_record_from_summary(api, runtime_id, summary);
|
||||
api.store.upsert_workdir_registry(&record)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workdir_runtime_miss_is_not_found(diagnostics: &[RuntimeDiagnostic]) -> bool {
|
||||
diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.code == "working_directory_not_found")
|
||||
{
|
||||
}
|
||||
|
||||
fn workdir_status_from_runtime_miss(diagnostics: &[RuntimeDiagnostic]) -> &'static str {
|
||||
if workdir_runtime_miss_is_not_found(diagnostics) {
|
||||
"not_found"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
fn persist_workdir_runtime_miss(
|
||||
api: &WorkspaceApi,
|
||||
mut record: WorkdirRegistryRecord,
|
||||
diagnostics: &[RuntimeDiagnostic],
|
||||
) -> ApiResult<()> {
|
||||
if workdir_runtime_miss_is_not_found(diagnostics) {
|
||||
api.store
|
||||
.delete_workdir_registry(&api.config.workspace_id, record.workdir_id.as_str())?;
|
||||
} else {
|
||||
record.materialization_status = "unknown".to_string();
|
||||
record.cleanliness = "unknown".to_string();
|
||||
record.updated_at = now_registry_timestamp();
|
||||
api.store.upsert_workdir_registry(&record)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sync_all_runtime_workdir_observations(api: &WorkspaceApi) -> Vec<RuntimeDiagnostic> {
|
||||
let mut diagnostics = Vec::new();
|
||||
let runtimes = api.runtime.list_runtimes(api.config.max_records.min(200));
|
||||
@@ -16980,22 +17069,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workdir_runtime_miss_uses_exact_typed_code() {
|
||||
assert_eq!(
|
||||
workdir_status_from_runtime_miss(&[RuntimeDiagnostic {
|
||||
let typed_not_found = [RuntimeDiagnostic {
|
||||
code: "working_directory_not_found".to_string(),
|
||||
severity: DiagnosticSeverity::Warning,
|
||||
message: "missing".to_string(),
|
||||
}]),
|
||||
}];
|
||||
assert_eq!(
|
||||
workdir_status_from_runtime_miss(&typed_not_found),
|
||||
"not_found"
|
||||
);
|
||||
assert_eq!(
|
||||
workdir_status_from_runtime_miss(&[RuntimeDiagnostic {
|
||||
assert!(workdir_runtime_miss_is_not_found(&typed_not_found));
|
||||
|
||||
let unrelated = [RuntimeDiagnostic {
|
||||
code: "some_other_not_found".to_string(),
|
||||
severity: DiagnosticSeverity::Warning,
|
||||
message: "not a typed workdir miss".to_string(),
|
||||
}]),
|
||||
"unknown"
|
||||
);
|
||||
}];
|
||||
assert_eq!(workdir_status_from_runtime_miss(&unrelated), "unknown");
|
||||
assert!(!workdir_runtime_miss_is_not_found(&unrelated));
|
||||
}
|
||||
|
||||
struct DeterministicExecutionBackend {
|
||||
@@ -17684,6 +17775,49 @@ mod tests {
|
||||
assert_ne!(csrf_accepted.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn device_login_start_can_retry_while_previous_flow_is_pending() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let api = test_api(workspace.path()).await;
|
||||
let app = build_router(api.clone());
|
||||
let mut starts = Vec::new();
|
||||
|
||||
for _ in 0..2 {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/auth/device-login/start")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(r#"{"client_name":"retry-test"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
starts.push(
|
||||
serde_json::from_slice::<serde_json::Value>(&body)
|
||||
.expect("device login start response"),
|
||||
);
|
||||
}
|
||||
|
||||
assert_ne!(starts[0]["device_code"], starts[1]["device_code"]);
|
||||
assert_ne!(starts[0]["user_code"], starts[1]["user_code"]);
|
||||
for start in starts {
|
||||
let device_code = start["device_code"].as_str().expect("device code");
|
||||
let user_code = start["user_code"].as_str().expect("user code");
|
||||
assert_eq!(user_code.len(), 9);
|
||||
assert!(
|
||||
api.store
|
||||
.get_device_login_flow_by_device_code(device_code)
|
||||
.expect("stored device login flow")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn direct_workspace_router_enforces_origin_on_browser_auth_mutations() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
@@ -18243,16 +18377,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_notification_projection_exposes_only_resource_key_and_current_state() {
|
||||
fn ticket_notification_projection_distinguishes_state_changes_from_new_activity() {
|
||||
const INTERNAL_ID: &str = "00001KZ9SR97B";
|
||||
for current_state in ["queued", "inprogress"] {
|
||||
let content = ticket_notification_content("T-429", current_state);
|
||||
let state_change = ticket_notification_content("T-429", "ready", "queued");
|
||||
assert_eq!(
|
||||
content,
|
||||
format!(
|
||||
"Ticket T-429 changed to {current_state}. Reread the current Ticket before acting."
|
||||
)
|
||||
state_change,
|
||||
"Ticket T-429 changed state from ready to queued. Reread the current Ticket before acting."
|
||||
);
|
||||
let new_activity = ticket_notification_content("T-429", "inprogress", "inprogress");
|
||||
assert_eq!(
|
||||
new_activity,
|
||||
"Ticket T-429 has new activity while inprogress. Reread the current Ticket before acting."
|
||||
);
|
||||
for content in [state_change, new_activity] {
|
||||
assert!(!content.contains(INTERNAL_ID));
|
||||
for forbidden in [
|
||||
"workspace_id",
|
||||
@@ -18427,15 +18564,23 @@ mod tests {
|
||||
}
|
||||
|
||||
let inputs = execution.take_inputs();
|
||||
let expected_states = ["queued", "inprogress", "inprogress", "inprogress"];
|
||||
let expected_states = [
|
||||
("ready", "queued"),
|
||||
("inprogress", "inprogress"),
|
||||
("inprogress", "inprogress"),
|
||||
("inprogress", "inprogress"),
|
||||
];
|
||||
assert_eq!(inputs.len(), expected_states.len());
|
||||
for ((recipient, content), current_state) in inputs.iter().zip(expected_states) {
|
||||
for ((recipient, content), (previous_state, current_state)) in
|
||||
inputs.iter().zip(expected_states)
|
||||
{
|
||||
assert_eq!(recipient.worker_id.to_string(), orchestrator.worker_id);
|
||||
assert!(!content.contains(&ticket.id));
|
||||
assert_eq!(
|
||||
content,
|
||||
&ticket_notification_content(
|
||||
ticket.resource_key.as_deref().unwrap(),
|
||||
previous_state,
|
||||
current_state,
|
||||
)
|
||||
);
|
||||
@@ -19554,6 +19699,7 @@ mod tests {
|
||||
notifications[0].1,
|
||||
ticket_notification_content(
|
||||
ticket_ref.resource_key.as_deref().unwrap(),
|
||||
TicketWorkflowState::Queued.as_str(),
|
||||
TicketWorkflowState::Queued.as_str()
|
||||
)
|
||||
);
|
||||
@@ -20186,6 +20332,25 @@ mod tests {
|
||||
.unwrap(),
|
||||
ticket_id
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_workspace_ticket_identity(&api, TEST_WORKSPACE_ID, &ticket_resource_key)
|
||||
.unwrap(),
|
||||
ticket_id
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_workspace_ticket_identity(&api, TEST_WORKSPACE_ID, &ticket_id).unwrap(),
|
||||
ticket_id
|
||||
);
|
||||
let mut assignment = Some(CreateWorkspaceWorkerTicketAssignmentRequest {
|
||||
ticket_id: ticket_resource_key.clone(),
|
||||
operation_id: "ticket-key-assignment".to_string(),
|
||||
});
|
||||
resolve_workspace_worker_ticket_assignment(&api, TEST_WORKSPACE_ID, &mut assignment)
|
||||
.unwrap();
|
||||
assert_eq!(assignment.unwrap().ticket_id, ticket_id);
|
||||
let missing =
|
||||
resolve_workspace_ticket_identity(&api, TEST_WORKSPACE_ID, "T-999999").unwrap_err();
|
||||
assert_eq!(missing.into_response().status(), StatusCode::NOT_FOUND);
|
||||
let path = || ScopedRecordPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
id: ticket_id.clone(),
|
||||
@@ -21308,6 +21473,87 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirmed_runtime_miss_removes_registry_record_but_unknown_is_retained() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(workspace.path());
|
||||
let api = test_api(workspace.path()).await;
|
||||
seed_cleanup_workdir(&api, "deleted-workdir", "present", "clean");
|
||||
let deleted = api
|
||||
.store
|
||||
.get_workdir_registry(TEST_WORKSPACE_ID, "deleted-workdir")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
persist_workdir_runtime_miss(
|
||||
&api,
|
||||
deleted,
|
||||
&[RuntimeDiagnostic {
|
||||
code: "working_directory_not_found".to_string(),
|
||||
severity: DiagnosticSeverity::Warning,
|
||||
message: "missing".to_string(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
api.store
|
||||
.get_workdir_registry(TEST_WORKSPACE_ID, "deleted-workdir")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
|
||||
seed_cleanup_workdir(&api, "unknown-workdir", "present", "clean");
|
||||
let unknown = api
|
||||
.store
|
||||
.get_workdir_registry(TEST_WORKSPACE_ID, "unknown-workdir")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
persist_workdir_runtime_miss(
|
||||
&api,
|
||||
unknown,
|
||||
&[RuntimeDiagnostic {
|
||||
code: "runtime_unavailable".to_string(),
|
||||
severity: DiagnosticSeverity::Warning,
|
||||
message: "temporarily unavailable".to_string(),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
api.store
|
||||
.get_workdir_registry(TEST_WORKSPACE_ID, "unknown-workdir")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.materialization_status,
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleanup_not_found_observation_removes_registry_record() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(workspace.path());
|
||||
let api = test_api(workspace.path()).await;
|
||||
let working_directory_id = "cleanup-existing";
|
||||
seed_cleanup_workdir(&api, working_directory_id, "present", "clean");
|
||||
let record = api
|
||||
.store
|
||||
.get_workdir_registry(TEST_WORKSPACE_ID, working_directory_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut summary = workdir_summary_from_record(&record);
|
||||
summary.status = WorkingDirectoryStatusKind::NotFound;
|
||||
|
||||
persist_workdir_cleanup_observation(&api, "runtime-test", &summary).unwrap();
|
||||
|
||||
assert!(
|
||||
api.store
|
||||
.get_workdir_registry(TEST_WORKSPACE_ID, working_directory_id)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
fn seed_cleanup_link(api: &WorkspaceApi, runtime_worker_id: &str, workdir_id: &str) {
|
||||
let runtime_worker_id = runtime_worker_id.parse::<u64>().unwrap();
|
||||
api.store
|
||||
|
||||
@@ -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",
|
||||
|
||||
+7
-56
@@ -22,7 +22,7 @@ use cli_connection::{
|
||||
};
|
||||
use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login};
|
||||
use memory_lint::{LintCliOptions, LintStatus};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use session_store::SegmentId;
|
||||
use tui::{LaunchMode, LaunchOptions};
|
||||
|
||||
@@ -1220,65 +1220,16 @@ async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> {
|
||||
)
|
||||
.await
|
||||
.map_err(|error| ParseError(error.to_string()))?;
|
||||
save_backend_token(backend_url, &token)?;
|
||||
println!("Saved Backend API token for {backend_url}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct BackendTokenFile {
|
||||
#[serde(default)]
|
||||
tokens: BTreeMap<String, BackendTokenEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct BackendTokenEntry {
|
||||
token_type: String,
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
fn save_backend_token(backend_url: &str, access_token: &str) -> Result<(), ParseError> {
|
||||
let path = backend_token_path().ok_or_else(|| {
|
||||
ParseError("HOME or XDG_CONFIG_HOME is required to save Backend token".to_string())
|
||||
})?;
|
||||
let mut file = if path.is_file() {
|
||||
let contents = fs::read_to_string(&path)
|
||||
.map_err(|error| ParseError(format!("failed to read {}: {error}", path.display())))?;
|
||||
serde_json::from_str::<BackendTokenFile>(&contents)
|
||||
.map_err(|error| ParseError(format!("failed to parse {}: {error}", path.display())))?
|
||||
} else {
|
||||
BackendTokenFile::default()
|
||||
};
|
||||
file.tokens.insert(
|
||||
backend_url.trim_end_matches('/').to_string(),
|
||||
BackendTokenEntry {
|
||||
token_type: "Bearer".to_string(),
|
||||
access_token: access_token.to_string(),
|
||||
},
|
||||
let token_path = client::save_backend_token(backend_url, "Bearer", &token)
|
||||
.map_err(|error| ParseError(error.to_string()))?;
|
||||
println!(
|
||||
"Saved Backend API token for {} in {}",
|
||||
client::BackendOrigin::parse(backend_url).map_err(|error| ParseError(error.to_string()))?,
|
||||
token_path.display()
|
||||
);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
ParseError(format!("failed to create {}: {error}", parent.display()))
|
||||
})?;
|
||||
}
|
||||
let serialized = serde_json::to_string_pretty(&file)
|
||||
.map_err(|error| ParseError(format!("failed to serialize Backend token file: {error}")))?;
|
||||
fs::write(&path, format!("{serialized}\n"))
|
||||
.map_err(|error| ParseError(format!("failed to write {}: {error}", path.display())))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn backend_token_path() -> Option<PathBuf> {
|
||||
yoi_config_dir().map(|dir| dir.join("backend-tokens.json"))
|
||||
}
|
||||
|
||||
fn yoi_config_dir() -> Option<PathBuf> {
|
||||
if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||
return Some(PathBuf::from(home).join("yoi"));
|
||||
}
|
||||
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("yoi"))
|
||||
}
|
||||
|
||||
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
|
||||
let Some((subcommand, rest)) = args.split_first() else {
|
||||
return Err(ParseError(
|
||||
|
||||
@@ -19,7 +19,7 @@ The Workspace Server owns one control-plane SQLite database. Schema changes are
|
||||
|
||||
Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration:
|
||||
|
||||
- rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity;
|
||||
- rebuilds Ticket, Objective, assignment, Artifact, and resource-key tables with Workspace-scoped composite identity;
|
||||
- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references;
|
||||
- materializes assignment-specific Worker tombstones for pre-v39 historical assignments whose valid Worker UUID no longer has a matching live registry row (including Workers deleted by the legacy cleanup path and Workers moved between Runtimes); a Worker ID that resolves only in another Workspace remains a preflight error;
|
||||
- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; parent delete/Runtime-move triggers record exact Workspace-scoped tombstones, and startup accepts a missing live parent only when that tombstone exists, so an unrelated same ID in another Workspace cannot change the result; reservation operation ids remain intentionally unconstrained until their resources exist;
|
||||
|
||||
@@ -10,7 +10,6 @@ import "./base.dcdl" // {
|
||||
web = { enabled = true; };
|
||||
sub_worker = { enabled = true; };
|
||||
flow = { enabled = true; };
|
||||
worker = { enabled = true; };
|
||||
ticket = { enabled = true; thread = true; };
|
||||
merge_request = {
|
||||
show = true;
|
||||
|
||||
@@ -8,7 +8,6 @@ import "./base.dcdl" // {
|
||||
memory = { enabled = true; };
|
||||
web = { enabled = true; };
|
||||
sub_worker = { enabled = true; };
|
||||
worker = { enabled = true; };
|
||||
manage_workdir = { enabled = true; };
|
||||
ticket = { enabled = true; authoring = true; thread = true; };
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
||||
Generated
+22
-12
@@ -9,9 +9,9 @@
|
||||
"npm:@codemirror/view@6.43.8": "6.43.8",
|
||||
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
|
||||
"npm:@lezer/highlight@1.2.3": "1.2.3",
|
||||
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
|
||||
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
|
||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7",
|
||||
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0",
|
||||
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
|
||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
|
||||
"npm:clsx@2.1.1": "2.1.1",
|
||||
"npm:cookie@0.6.0": "0.6.0",
|
||||
"npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10",
|
||||
@@ -23,7 +23,8 @@
|
||||
"npm:svelte-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3",
|
||||
"npm:svelte@5.45.6": "5.45.6",
|
||||
"npm:typescript@5.9.3": "5.9.3",
|
||||
"npm:vite@7.2.7": "7.2.7"
|
||||
"npm:vite@7.2.7": "7.2.7_yaml@2.9.0",
|
||||
"npm:yaml@2.9.0": "2.9.0"
|
||||
},
|
||||
"jsr": {
|
||||
"@std/assert@1.0.19": {
|
||||
@@ -433,13 +434,13 @@
|
||||
"acorn"
|
||||
]
|
||||
},
|
||||
"@sveltejs/adapter-static@3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7": {
|
||||
"@sveltejs/adapter-static@3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0": {
|
||||
"integrity": "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==",
|
||||
"dependencies": [
|
||||
"@sveltejs/kit"
|
||||
]
|
||||
},
|
||||
"@sveltejs/kit@2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7": {
|
||||
"@sveltejs/kit@2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
|
||||
"integrity": "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg==",
|
||||
"dependencies": [
|
||||
"@standard-schema/spec",
|
||||
@@ -465,7 +466,7 @@
|
||||
],
|
||||
"bin": true
|
||||
},
|
||||
"@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_vite@7.2.7": {
|
||||
"@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
|
||||
"integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
|
||||
"dependencies": [
|
||||
"@sveltejs/vite-plugin-svelte",
|
||||
@@ -474,7 +475,7 @@
|
||||
"vite"
|
||||
]
|
||||
},
|
||||
"@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.45.6_vite@7.2.7": {
|
||||
"@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
|
||||
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
|
||||
"dependencies": [
|
||||
"@sveltejs/vite-plugin-svelte-inspector",
|
||||
@@ -966,7 +967,7 @@
|
||||
"vfile-message"
|
||||
]
|
||||
},
|
||||
"vite@7.2.7": {
|
||||
"vite@7.2.7_yaml@2.9.0": {
|
||||
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||
"dependencies": [
|
||||
"esbuild",
|
||||
@@ -974,14 +975,18 @@
|
||||
"picomatch",
|
||||
"postcss",
|
||||
"rollup",
|
||||
"tinyglobby"
|
||||
"tinyglobby",
|
||||
"yaml"
|
||||
],
|
||||
"optionalDependencies": [
|
||||
"fsevents"
|
||||
],
|
||||
"optionalPeers": [
|
||||
"yaml"
|
||||
],
|
||||
"bin": true
|
||||
},
|
||||
"vitefu@1.1.2_vite@7.2.7": {
|
||||
"vitefu@1.1.2_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
|
||||
"integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==",
|
||||
"dependencies": [
|
||||
"vite"
|
||||
@@ -993,6 +998,10 @@
|
||||
"w3c-keyname@2.2.8": {
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
|
||||
},
|
||||
"yaml@2.9.0": {
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"bin": true
|
||||
},
|
||||
"zimmerframe@1.1.4": {
|
||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
|
||||
},
|
||||
@@ -1024,7 +1033,8 @@
|
||||
"packageJson": {
|
||||
"dependencies": [
|
||||
"npm:@fontsource/ibm-plex-mono@5.3.0",
|
||||
"npm:gen-interface-jp@0.8.0"
|
||||
"npm:gen-interface-jp@0.8.0",
|
||||
"npm:yaml@2.9.0"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-mono": "5.3.0",
|
||||
"gen-interface-jp": "0.8.0"
|
||||
"gen-interface-jp": "0.8.0",
|
||||
"yaml": "2.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
};
|
||||
|
||||
let { item }: Props = $props();
|
||||
let detailOpen = $state(false);
|
||||
let nowMs = $state(Date.now());
|
||||
|
||||
$effect(() => {
|
||||
@@ -55,22 +56,20 @@
|
||||
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
|
||||
}
|
||||
|
||||
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
|
||||
const [firstLine = '', ...rest] = line.body.split('\n');
|
||||
const [label, suffix = ''] = firstLine.split(' — ', 2);
|
||||
return {
|
||||
label,
|
||||
suffix,
|
||||
rest: rest.join('\n')
|
||||
};
|
||||
function toolLabel(line: ConsoleLine): string {
|
||||
return line.toolCallLabel ?? line.toolCall?.name ?? line.title;
|
||||
}
|
||||
|
||||
function toolStatus(line: ConsoleLine): string {
|
||||
return line.toolStatus ?? line.toolCall?.state ?? '';
|
||||
}
|
||||
|
||||
function shouldRenderMarkdown(line: ConsoleLine): boolean {
|
||||
return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system';
|
||||
}
|
||||
|
||||
function bodyTextAfterToolSummary(line: ConsoleLine): string {
|
||||
return toolSummary(line).rest;
|
||||
function toolBodyText(line: ConsoleLine): string {
|
||||
return detailOpen ? (line.expandedBody ?? line.body) : line.body;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -107,20 +106,27 @@
|
||||
</div>
|
||||
{:else if item.kind === 'tool'}
|
||||
<div class="tool-summary">
|
||||
<span class="tool-label">{toolSummary(item).label}</span>
|
||||
<span class="tool-separator"> — </span>
|
||||
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
|
||||
<span class="tool-label">{toolLabel(item)}</span>
|
||||
<span class={`tool-status ${item.toolCall?.state ?? ''}`}>{toolStatus(item)}</span>
|
||||
{#if item.detail}
|
||||
<button
|
||||
type="button"
|
||||
class="tool-detail-button"
|
||||
aria-expanded={detailOpen}
|
||||
onclick={() => (detailOpen = !detailOpen)}
|
||||
>detail</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.compaction}
|
||||
<!-- rendered as one lifecycle item above -->
|
||||
{:else if item.kind === 'tool'}
|
||||
{#if bodyTextAfterToolSummary(item)}
|
||||
{#if toolBodyText(item)}
|
||||
<p class="console-plain-text">
|
||||
{#if isBashTool(item)}
|
||||
<AnsiText text={bodyTextAfterToolSummary(item)} />
|
||||
<AnsiText text={toolBodyText(item)} />
|
||||
{:else}
|
||||
{bodyTextAfterToolSummary(item)}
|
||||
{toolBodyText(item)}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
@@ -147,11 +153,10 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.detail}
|
||||
<details class="message-detail">
|
||||
<summary>detail</summary>
|
||||
{#if item.detail && detailOpen}
|
||||
<div class="message-detail" role="region" aria-label={`${toolLabel(item)} detail`}>
|
||||
<p>{item.detail}</p>
|
||||
</details>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
@@ -306,48 +311,46 @@
|
||||
.tool-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.tool-label {
|
||||
flex: 0 0 auto;
|
||||
color: var(--tui-cyan);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-separator {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-suffix {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
overflow: hidden;
|
||||
color: var(--tui-cyan);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-separator,
|
||||
.tool-suffix {
|
||||
.tool-status {
|
||||
flex: 0 0 auto;
|
||||
color: var(--tui-dark-gray);
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-state-error .tool-suffix {
|
||||
.tool-state-error .tool-status {
|
||||
color: var(--tui-red);
|
||||
}
|
||||
|
||||
.tool-state-running .tool-suffix,
|
||||
.tool-state-streaming_args .tool-suffix,
|
||||
.tool-state-pending .tool-suffix {
|
||||
.tool-state-running .tool-status,
|
||||
.tool-state-streaming_args .tool-status,
|
||||
.tool-state-pending .tool-status {
|
||||
color: var(--tui-yellow);
|
||||
}
|
||||
|
||||
.tool-state-done .tool-suffix {
|
||||
.tool-state-done .tool-status {
|
||||
color: var(--tui-dark-gray);
|
||||
}
|
||||
|
||||
.console-line.error-line .tool-status {
|
||||
color: var(--tui-red);
|
||||
}
|
||||
|
||||
.message-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -412,13 +415,47 @@
|
||||
color: var(--code);
|
||||
}
|
||||
|
||||
.tool-detail-button {
|
||||
margin-inline-start: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.08rem 0.35rem;
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 750;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.console-line:hover .tool-detail-button,
|
||||
.tool-detail-button:focus-visible,
|
||||
.tool-detail-button[aria-expanded='true'] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.message-detail {
|
||||
margin-top: 0.35rem;
|
||||
border-left: 2px solid var(--line);
|
||||
padding-left: 0.6rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.message-detail summary {
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
.message-detail p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.tool-detail-button {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -165,6 +165,108 @@ Deno.test("segment rotation retains a live error beside the real SegmentStart hi
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("reload snapshot projects provenance-annotated history entries", () => {
|
||||
const metadata = {
|
||||
entry_id: "history-entry-1",
|
||||
origin: {
|
||||
kind: "model_output",
|
||||
worker: {
|
||||
workspace_id: "workspace-secret",
|
||||
runtime_id: "runtime-secret",
|
||||
worker_id: "worker-secret",
|
||||
},
|
||||
},
|
||||
};
|
||||
const annotated = (item: unknown) => ({ item, metadata });
|
||||
const projection = projectConsole([{
|
||||
eventId: "annotated-reload",
|
||||
event: snapshotEvent("/repo", [
|
||||
{
|
||||
kind: "annotated_segment_start",
|
||||
ts: 1,
|
||||
session_id: "session-1",
|
||||
system_prompt: null,
|
||||
config: {},
|
||||
history: [annotated({
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
content: [{ kind: "text", text: "older committed reply" }],
|
||||
})],
|
||||
},
|
||||
{
|
||||
kind: "annotated_user_input",
|
||||
ts: 2,
|
||||
segments: [{ kind: "text", content: "latest user message" }],
|
||||
history: [annotated({
|
||||
kind: "message",
|
||||
role: "user",
|
||||
content: [{ kind: "text", text: "latest user message" }],
|
||||
})],
|
||||
},
|
||||
{
|
||||
kind: "annotated_assistant_item",
|
||||
ts: 3,
|
||||
entry: annotated({
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
content: [{ kind: "text", text: "latest committed reply" }],
|
||||
}),
|
||||
},
|
||||
{
|
||||
kind: "annotated_assistant_item",
|
||||
ts: 4,
|
||||
entry: annotated({
|
||||
kind: "tool_call",
|
||||
call_id: "annotated-call",
|
||||
name: "Read",
|
||||
arguments: '{"file_path":"/repo/a.md"}',
|
||||
}),
|
||||
},
|
||||
{
|
||||
kind: "annotated_tool_result",
|
||||
ts: 5,
|
||||
entry: annotated({
|
||||
kind: "tool_result",
|
||||
call_id: "annotated-call",
|
||||
summary: "Read 1 line from /repo/a.md",
|
||||
content: "1→content",
|
||||
is_error: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
kind: "annotated_system_item",
|
||||
ts: 6,
|
||||
entry: annotated({
|
||||
kind: "notification",
|
||||
message: "Worker completed",
|
||||
}),
|
||||
},
|
||||
]),
|
||||
}]);
|
||||
|
||||
assertEquals(
|
||||
projection.lines.map((line) =>
|
||||
`${line.kind}:${line.toolCallLabel ?? line.body}`
|
||||
),
|
||||
[
|
||||
"assistant:older committed reply",
|
||||
"user:latest user message",
|
||||
"assistant:latest committed reply",
|
||||
"tool:Read(1 file)",
|
||||
"system:Worker completed",
|
||||
],
|
||||
);
|
||||
const visible = JSON.stringify(projection.lines);
|
||||
assert(
|
||||
!visible.includes("workspace-secret"),
|
||||
"history metadata must not enter Console rows",
|
||||
);
|
||||
assert(
|
||||
!visible.includes("runtime-secret"),
|
||||
"history origin must remain non-visible metadata",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("workerConsoleHref encodes runtime and worker target authority", () => {
|
||||
assert(
|
||||
workerConsoleHref({
|
||||
@@ -313,26 +415,25 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
|
||||
!toolLines[0].streaming,
|
||||
"completed tool call should not remain streaming",
|
||||
);
|
||||
assert(
|
||||
toolLines[0].body.includes("$ pwd"),
|
||||
"Bash command should be summarized",
|
||||
);
|
||||
assertEquals(toolLines[0].toolCallLabel, "Bash($ pwd)");
|
||||
assertEquals(toolLines[0].toolStatus, "done");
|
||||
assert(
|
||||
toolLines[0].body.includes("/repo"),
|
||||
"tool result should be folded into the Call block",
|
||||
);
|
||||
assert(
|
||||
toolLines[0].body.includes("line9"),
|
||||
"Bash result preview should include the ninth output line",
|
||||
"Bash preview should include the ninth output line",
|
||||
);
|
||||
assert(
|
||||
!toolLines[0].body.includes("line10") &&
|
||||
!toolLines[0].body.includes("line12"),
|
||||
"Bash result preview should be capped at ten display lines",
|
||||
toolLines[0].body.includes("… +3 more lines"),
|
||||
"Bash preview should retain its line cap",
|
||||
);
|
||||
assert(
|
||||
toolLines[0].body.includes("… +3 more lines"),
|
||||
"Bash result preview should show omitted output count",
|
||||
toolLines[0].expandedBody?.includes("line12") === true &&
|
||||
!toolLines[0].expandedBody?.includes("more lines"),
|
||||
"Bash detail should show every returned output line",
|
||||
);
|
||||
assert(
|
||||
toolLines[0].detail?.includes("id: call-1"),
|
||||
@@ -421,7 +522,8 @@ Deno.test("projectConsole streams distinct Bash stdout and stderr through termin
|
||||
]);
|
||||
|
||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||
assert(line.body.includes("Bash — failed (exit 7)"), line.body);
|
||||
assertEquals(line.toolCallLabel, "Bash($ long-command)");
|
||||
assertEquals(line.toolStatus, "failed (exit 7)");
|
||||
assert(!line.body.includes("elapsed"), line.body);
|
||||
assert(!line.body.includes("stdout:"), line.body);
|
||||
assert(line.body.includes("ready\n"), line.body);
|
||||
@@ -463,7 +565,8 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
|
||||
|
||||
const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
|
||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||
assert(line.body.includes("Bash — running…"), line.body);
|
||||
assertEquals(line.toolCallLabel, "Bash($ slow)");
|
||||
assertEquals(line.toolStatus, "running…");
|
||||
assert(!line.body.includes("elapsed"), line.body);
|
||||
assert(!line.body.includes("stdout:"), line.body);
|
||||
assert(line.body.includes("[… earlier stdout omitted]\ntail\n"), line.body);
|
||||
@@ -474,7 +577,7 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
|
||||
assertEquals(line.streaming, true);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole caps default tool request and result previews", () => {
|
||||
Deno.test("projectConsole caps default preview but keeps complete detail body", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
eventId: "70",
|
||||
@@ -508,19 +611,100 @@ Deno.test("projectConsole caps default tool request and result previews", () =>
|
||||
|
||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||
assertEquals(line.title, "Call · CustomTool");
|
||||
assertEquals(line.body.split("\n").length, 7);
|
||||
assert(line.body.includes("CustomTool — done"), "tool state should be shown");
|
||||
assertEquals(line.toolCallLabel, 'CustomTool("first":"one","second":"two","third":"three","fourth":"four")');
|
||||
assertEquals(line.toolStatus, "done");
|
||||
assertEquals(line.body.split("\n").length, 3);
|
||||
assert(
|
||||
line.body.includes('"first": "one"'),
|
||||
"request preview should be shown",
|
||||
line.body.includes("out1") && line.body.includes("… +3 more lines"),
|
||||
"normal display should retain the capped response preview",
|
||||
);
|
||||
assert(!line.body.includes("first"), "request arguments should stay in the Call signature and detail");
|
||||
assert(
|
||||
line.detail?.includes("arguments:\nfirst: one") === true &&
|
||||
line.detail?.includes("fourth: four") === true,
|
||||
"detail metadata should render complete request arguments as YAML",
|
||||
);
|
||||
assert(
|
||||
line.expandedBody?.includes("out5") === true &&
|
||||
!line.expandedBody?.includes("more lines"),
|
||||
"detail body should contain the complete result",
|
||||
);
|
||||
assert(line.body.includes("out1"), "result preview should be shown");
|
||||
assert(!line.body.includes("third"), "request preview should be capped");
|
||||
assert(!line.body.includes("out3"), "result preview should be capped");
|
||||
assert(line.body.includes("… +"), "overflow marker should be shown");
|
||||
});
|
||||
|
||||
Deno.test("projectConsole shows Grep query and caps result preview to five entries", () => {
|
||||
Deno.test("projectConsole renders JSON tool responses as YAML", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
eventId: "json-call",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "json-tool",
|
||||
name: "CustomTool",
|
||||
arguments: "{}",
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "json-result",
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: {
|
||||
id: "json-tool",
|
||||
summary: "json completed",
|
||||
output: JSON.stringify({
|
||||
status: "ok",
|
||||
items: [{ id: 1 }, { id: 2 }],
|
||||
}),
|
||||
is_error: false,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "invalid-json-call",
|
||||
event: {
|
||||
event: "tool_call_done",
|
||||
data: {
|
||||
id: "invalid-json-tool",
|
||||
name: "CustomTool",
|
||||
arguments: "{}",
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
{
|
||||
eventId: "invalid-json-result",
|
||||
event: {
|
||||
event: "tool_result",
|
||||
data: {
|
||||
id: "invalid-json-tool",
|
||||
summary: "invalid json",
|
||||
output: '{"status": broken}',
|
||||
is_error: false,
|
||||
},
|
||||
} satisfies Event,
|
||||
},
|
||||
]);
|
||||
|
||||
const toolLines = projection.lines.filter((line) => line.kind === "tool");
|
||||
const jsonLine = toolLines.find((line) => line.id.includes("json-tool"));
|
||||
const invalidLine = toolLines.find((line) => line.id.includes("invalid-json-tool"));
|
||||
assert(jsonLine, "JSON tool line should be projected");
|
||||
assert(invalidLine, "invalid JSON tool line should be projected");
|
||||
assert(
|
||||
jsonLine.expandedBody?.includes("status: ok") === true &&
|
||||
jsonLine.expandedBody?.includes(" - id: 2") === true,
|
||||
"detail body should serialize parsed JSON as YAML",
|
||||
);
|
||||
assert(
|
||||
jsonLine.body.includes("more lines"),
|
||||
"normal preview should cap the pretty-printed JSON",
|
||||
);
|
||||
assert(
|
||||
invalidLine.expandedBody?.includes('{"status": broken}') === true,
|
||||
"invalid JSON-looking output should remain unchanged",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("projectConsole caps Grep preview but keeps complete detail body", () => {
|
||||
const projection = projectConsole([
|
||||
{
|
||||
eventId: "72",
|
||||
@@ -549,17 +733,19 @@ Deno.test("projectConsole shows Grep query and caps result preview to five entri
|
||||
|
||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||
assertEquals(line.title, "Call · Grep");
|
||||
assert(
|
||||
line.body.includes("Grep — 6 matches"),
|
||||
"Grep summary should be shown",
|
||||
);
|
||||
assert(line.body.includes("query: needle"), "Grep query should be shown");
|
||||
assertEquals(line.toolCallLabel, "Grep(needle)");
|
||||
assertEquals(line.toolStatus, "done");
|
||||
assert(line.body.includes("hit1"), "first result should be shown");
|
||||
assert(line.body.includes("hit5"), "fifth result should be shown");
|
||||
assert(!line.body.includes("hit6"), "sixth result should be capped");
|
||||
assert(!line.body.includes("hit6"), "normal preview should retain its result cap");
|
||||
assert(
|
||||
line.body.includes("… +1 more results"),
|
||||
"overflow marker should be shown",
|
||||
"preview should show the omitted result count",
|
||||
);
|
||||
assert(
|
||||
line.expandedBody?.includes("hit6") === true &&
|
||||
!line.expandedBody?.includes("more results"),
|
||||
"detail body should show every Grep result",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -594,17 +780,15 @@ Deno.test("projectConsole keeps Grep error detail in the body", () => {
|
||||
|
||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||
assertEquals(line.title, "Call · Grep");
|
||||
assert(
|
||||
line.body.includes("Grep — Failed"),
|
||||
"error suffix should stay short",
|
||||
);
|
||||
assertEquals(line.toolCallLabel, "Grep(needle)");
|
||||
assertEquals(line.toolStatus, "error");
|
||||
assert(
|
||||
line.body.includes(message),
|
||||
"error detail should remain visible in the body",
|
||||
);
|
||||
assert(
|
||||
!line.body.includes(`Grep — ${message}`),
|
||||
"error detail should not be repeated in the suffix",
|
||||
!line.toolCallLabel?.includes(message),
|
||||
"error detail should not be repeated in the Call signature",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -879,9 +1063,10 @@ Deno.test("projectConsole keeps streaming tool call updates in the same Call blo
|
||||
assertEquals(toolLines.length, 1);
|
||||
assertEquals(toolLines[0].title, "Call · Read");
|
||||
assert(toolLines[0].streaming, "streaming tool call should remain streaming");
|
||||
assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
|
||||
assertEquals(toolLines[0].toolStatus, "reading…");
|
||||
assert(
|
||||
toolLines[0].body.includes("/tmp/a.md") &&
|
||||
toolLines[0].body.includes("Read — reading"),
|
||||
toolLines[0].body.includes("/tmp/a.md"),
|
||||
"Read call should render aggregate progress and path without content",
|
||||
);
|
||||
});
|
||||
@@ -1018,10 +1203,8 @@ Deno.test("projectConsole aggregates Read calls without showing file content", (
|
||||
const toolLines = projection.lines.filter((line) => line.kind === "tool");
|
||||
assertEquals(toolLines.length, 1);
|
||||
assertEquals(toolLines[0].title, "Call · Read");
|
||||
assert(
|
||||
toolLines[0].body.includes("Read — 2 files read"),
|
||||
"aggregate count should be shown",
|
||||
);
|
||||
assertEquals(toolLines[0].toolCallLabel, "Read(2 files)");
|
||||
assertEquals(toolLines[0].toolStatus, "done");
|
||||
assert(
|
||||
toolLines[0].body.includes("/tmp/a.md"),
|
||||
"first path should be listed",
|
||||
@@ -1073,7 +1256,9 @@ Deno.test("projectConsole renders Edit calls with structured diff lines", () =>
|
||||
|
||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||
assertEquals(line.title, "Call · Edit");
|
||||
assert(line.body.includes("diff: -1 +2"), "diff summary should be shown");
|
||||
assertEquals(line.toolCallLabel, "Edit(/tmp/a.md)");
|
||||
assertEquals(line.toolStatus, "done");
|
||||
assertEquals(line.body, "ok");
|
||||
assertEquals(line.diff?.map((row) => row.kind), [
|
||||
"context",
|
||||
"remove",
|
||||
@@ -1242,13 +1427,13 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
|
||||
assertEquals(projection.status, "running");
|
||||
assertEquals(
|
||||
projection.lines.map((line) =>
|
||||
`${line.kind}:${line.body}:${line.streaming}`
|
||||
`${line.kind}:${line.toolCallLabel ? `${line.toolCallLabel}\n${line.body}` : line.body}:${line.streaming}`
|
||||
),
|
||||
[
|
||||
"user:seed user:false",
|
||||
"user:new user:false",
|
||||
"assistant:assistant reply:false",
|
||||
"tool:Read — 1 file read\n /tmp/a.md:false",
|
||||
"tool:Read(1 file)\n /tmp/a.md:false",
|
||||
"status:Compacting…:true",
|
||||
"in_flight:partial:true",
|
||||
],
|
||||
@@ -1476,25 +1661,26 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
|
||||
},
|
||||
]);
|
||||
|
||||
const bodies = projection.lines.filter((line) => line.kind === "tool").map((
|
||||
line,
|
||||
) => line.body);
|
||||
assertEquals(bodies[0], "Read — 1 file read\n src/main.rs");
|
||||
const toolLines = projection.lines.filter((line) => line.kind === "tool");
|
||||
const bodies = toolLines.map((line) => line.body);
|
||||
assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
|
||||
assertEquals(bodies[0], " src/main.rs");
|
||||
assert(
|
||||
projection.lines[0].detail?.includes("from src/main.rs"),
|
||||
"Read summary detail path should be relative",
|
||||
);
|
||||
assert(
|
||||
bodies.some((body) =>
|
||||
body.includes("Write — out.txt") && body.includes("Wrote out.txt")
|
||||
toolLines.some((line) =>
|
||||
line.toolCallLabel === "Write(out.txt)" && line.body.includes("Wrote out.txt")
|
||||
),
|
||||
"Write header and known result path should be relative",
|
||||
"Write signature and known result path should be relative",
|
||||
);
|
||||
assert(
|
||||
bodies.some((body) =>
|
||||
body.includes("Edit — src/main.rs") && body.includes("Edited src/main.rs")
|
||||
toolLines.some((line) =>
|
||||
line.toolCallLabel === "Edit(src/main.rs)" &&
|
||||
line.body.includes("Edited src/main.rs")
|
||||
),
|
||||
"Edit header and known result path should be relative",
|
||||
"Edit signature and known result path should be relative",
|
||||
);
|
||||
assert(
|
||||
bodies.some((body) =>
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
InternalWorkerSnapshot,
|
||||
Segment,
|
||||
} from "$lib/generated/protocol";
|
||||
import { stringify as stringifyYaml } from "yaml";
|
||||
import { workspaceRoute } from "$lib/workspace/api/http";
|
||||
import {
|
||||
applyRunActivityEvent,
|
||||
@@ -86,6 +87,9 @@ export type ConsoleLine = {
|
||||
kind: ConsoleLineKind;
|
||||
title: string;
|
||||
body: string;
|
||||
expandedBody?: string;
|
||||
toolCallLabel?: string;
|
||||
toolStatus?: string;
|
||||
detail?: string;
|
||||
compaction?: ConsoleCompaction;
|
||||
diff?: ConsoleDiffLine[];
|
||||
@@ -1376,7 +1380,10 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
||||
title: item.title.startsWith("Call · Tool result")
|
||||
? item.title
|
||||
: `Call · ${toolCall.name}`,
|
||||
body: renderToolCall(toolCall),
|
||||
body: renderToolResponse(toolCall),
|
||||
expandedBody: renderToolResponse(toolCall, true),
|
||||
toolCallLabel: toolCallSignature(toolCall),
|
||||
toolStatus: toolCallStatus(toolCall),
|
||||
detail: toolCallDetail(toolCall),
|
||||
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
||||
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
|
||||
@@ -1384,7 +1391,7 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
||||
};
|
||||
}
|
||||
|
||||
function renderToolCall(toolCall: ToolCallView): string {
|
||||
function renderToolResponse(toolCall: ToolCallView, expanded = false): string {
|
||||
switch (toolCall.name) {
|
||||
case "Read":
|
||||
return renderReadTool(toolCall);
|
||||
@@ -1395,14 +1402,54 @@ function renderToolCall(toolCall: ToolCallView): string {
|
||||
case "Glob":
|
||||
return renderSearchTool(toolCall);
|
||||
case "Grep":
|
||||
return renderGrepTool(toolCall);
|
||||
return renderGrepTool(toolCall, expanded);
|
||||
case "Bash":
|
||||
return renderBashTool(toolCall);
|
||||
return renderBashTool(toolCall, expanded);
|
||||
default:
|
||||
return renderDefaultTool(toolCall);
|
||||
return renderDefaultTool(toolCall, expanded);
|
||||
}
|
||||
}
|
||||
|
||||
function toolCallSignature(toolCall: ToolCallView): string {
|
||||
const args = parsedArgs(toolCall);
|
||||
switch (toolCall.name) {
|
||||
case "Read":
|
||||
return `Read(${readPath(toolCall)})`;
|
||||
case "Write":
|
||||
case "Edit": {
|
||||
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
|
||||
return `${toolCall.name}(${path})`;
|
||||
}
|
||||
case "Glob":
|
||||
return `Glob(${stringField(args, "pattern") ?? genericCallArguments(toolCall)})`;
|
||||
case "Grep":
|
||||
return `Grep(${stringField(args, "pattern") ?? genericCallArguments(toolCall)})`;
|
||||
case "Bash": {
|
||||
const command = stringField(args, "command");
|
||||
return `Bash(${command ? `$ ${singleLine(command)}` : genericCallArguments(toolCall)})`;
|
||||
}
|
||||
default:
|
||||
return `${toolCall.name}(${genericCallArguments(toolCall)})`;
|
||||
}
|
||||
}
|
||||
|
||||
function genericCallArguments(toolCall: ToolCallView): string {
|
||||
const raw = toolCall.arguments ?? toolCall.argsStream;
|
||||
if (!raw.trim()) return "";
|
||||
const parsed = parseJson(raw);
|
||||
if (parsed === undefined) return singleLine(raw);
|
||||
const serialized = JSON.stringify(parsed) ?? "null";
|
||||
return isRecord(parsed) ? serialized.slice(1, -1) : serialized;
|
||||
}
|
||||
|
||||
function singleLine(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function toolCallStatus(toolCall: ToolCallView): string {
|
||||
return toolCall.name === "Bash" ? commandStateSuffix(toolCall) : stateSuffix(toolCall.state);
|
||||
}
|
||||
|
||||
function aggregateReadToolLines(lines: ConsoleLine[]): ConsoleLine[] {
|
||||
const result: ConsoleLine[] = [];
|
||||
let index = 0;
|
||||
@@ -1434,9 +1481,6 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
||||
const paths = calls.map(readPath);
|
||||
const visiblePaths = inProgress ? paths.slice(-3) : paths;
|
||||
const body = compactLines([
|
||||
inProgress
|
||||
? `Read — reading (${count} file${plural(count)}…)`
|
||||
: `Read — ${count} file${plural(count)} read`,
|
||||
visiblePaths.map((path) => ` ${path}`).join("\n"),
|
||||
inProgress && paths.length > visiblePaths.length
|
||||
? ` … (${paths.length - visiblePaths.length} earlier)`
|
||||
@@ -1447,6 +1491,8 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
||||
kind: "tool",
|
||||
title: "Call · Read",
|
||||
body,
|
||||
toolCallLabel: `Read(${count} file${plural(count)})`,
|
||||
toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done",
|
||||
detail: calls.map(readDetail).join("\n\n"),
|
||||
eventId: group.at(-1)?.eventId,
|
||||
source: "event",
|
||||
@@ -1483,32 +1529,16 @@ function readDetail(toolCall: ToolCallView): string {
|
||||
]);
|
||||
}
|
||||
|
||||
function renderReadTool(toolCall: ToolCallView): string {
|
||||
return `Read — ${readPath(toolCall)} (${stateSuffix(toolCall.state)})`;
|
||||
function renderReadTool(_toolCall: ToolCallView): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function renderWriteTool(toolCall: ToolCallView): string {
|
||||
const args = parsedArgs(toolCall);
|
||||
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
|
||||
const content = stringField(args, "content");
|
||||
return compactLines([
|
||||
`Write — ${path} (${stateSuffix(toolCall.state)})`,
|
||||
cappedSection(content, 5),
|
||||
knownToolResultText(toolCall),
|
||||
]);
|
||||
return knownToolResultText(toolCall) ?? "";
|
||||
}
|
||||
|
||||
function renderEditTool(toolCall: ToolCallView): string {
|
||||
const args = parsedArgs(toolCall);
|
||||
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
|
||||
const diff = editDiff(toolCall) ?? [];
|
||||
const removes = diff.filter((line) => line.kind === "remove").length;
|
||||
const adds = diff.filter((line) => line.kind === "add").length;
|
||||
return compactLines([
|
||||
`Edit — ${path} (${stateSuffix(toolCall.state)})`,
|
||||
diff.length > 0 ? `diff: -${removes} +${adds}` : undefined,
|
||||
knownToolResultText(toolCall),
|
||||
]);
|
||||
return knownToolResultText(toolCall) ?? "";
|
||||
}
|
||||
|
||||
function editDiff(toolCall: ToolCallView): ConsoleDiffLine[] | undefined {
|
||||
@@ -1591,52 +1621,20 @@ function lcsTable(oldLines: string[], newLines: string[]): number[][] {
|
||||
}
|
||||
|
||||
function renderSearchTool(toolCall: ToolCallView): string {
|
||||
const summary = toolCall.summary?.trim();
|
||||
return compactLines([
|
||||
`${toolCall.name} — ${toolHeaderSuffix(toolCall, summary)}`,
|
||||
knownToolResultText(toolCall),
|
||||
]);
|
||||
return knownToolResultText(toolCall) ?? "";
|
||||
}
|
||||
|
||||
function renderGrepTool(toolCall: ToolCallView): string {
|
||||
const summary = toolCall.summary?.trim();
|
||||
return compactLines([
|
||||
`Grep — ${toolHeaderSuffix(toolCall, summary)}`,
|
||||
grepQueryText(toolCall),
|
||||
cappedResultSection(knownToolResultText(toolCall), 5),
|
||||
]);
|
||||
function renderGrepTool(toolCall: ToolCallView, expanded: boolean): string {
|
||||
const result = knownToolResultText(toolCall);
|
||||
return expanded ? result ?? "" : cappedResultSection(result, 5) ?? "";
|
||||
}
|
||||
|
||||
function toolHeaderSuffix(
|
||||
toolCall: ToolCallView,
|
||||
summary?: string,
|
||||
): string {
|
||||
if (toolCall.state === "error") {
|
||||
return "Failed";
|
||||
function renderBashTool(toolCall: ToolCallView, expanded: boolean): string {
|
||||
if (["done", "error"].includes(toolCall.state)) {
|
||||
const result = resultText(toolCall);
|
||||
return expanded ? result ?? "" : cappedDisplaySection(result, 10) ?? "";
|
||||
}
|
||||
return summary ? firstLine(summary) : stateSuffix(toolCall.state);
|
||||
}
|
||||
|
||||
function grepQueryText(toolCall: ToolCallView): string | undefined {
|
||||
const args = parsedArgs(toolCall);
|
||||
const pattern = stringField(args, "pattern");
|
||||
if (pattern) {
|
||||
return `query: ${pattern}`;
|
||||
}
|
||||
const renderedArgs = argsText(toolCall);
|
||||
return renderedArgs ? `query:\n${renderedArgs}` : undefined;
|
||||
}
|
||||
|
||||
function renderBashTool(toolCall: ToolCallView): string {
|
||||
const args = parsedArgs(toolCall);
|
||||
const command = stringField(args, "command");
|
||||
return compactLines([
|
||||
`Bash — ${commandStateSuffix(toolCall)}`,
|
||||
command ? `$ ${command}` : argsText(toolCall),
|
||||
["done", "error"].includes(toolCall.state)
|
||||
? cappedDisplaySection(resultText(toolCall), 10)
|
||||
: renderLiveCommandOutput(toolCall.command),
|
||||
]);
|
||||
return renderLiveCommandOutput(toolCall.command) ?? "";
|
||||
}
|
||||
|
||||
function commandStateSuffix(toolCall: ToolCallView): string {
|
||||
@@ -1690,12 +1688,9 @@ function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined
|
||||
]);
|
||||
}
|
||||
|
||||
function renderDefaultTool(toolCall: ToolCallView): string {
|
||||
return compactLines([
|
||||
`${toolCall.name} — ${stateSuffix(toolCall.state)}`,
|
||||
cappedDisplaySection(argsText(toolCall), 3),
|
||||
cappedDisplaySection(resultText(toolCall), 3),
|
||||
]);
|
||||
function renderDefaultTool(toolCall: ToolCallView, expanded: boolean): string {
|
||||
const result = resultText(toolCall);
|
||||
return expanded ? result ?? "" : cappedDisplaySection(result, 3) ?? "";
|
||||
}
|
||||
|
||||
function toolCallDetail(toolCall: ToolCallView): string {
|
||||
@@ -1713,10 +1708,30 @@ function toolCallDetail(toolCall: ToolCallView): string {
|
||||
}
|
||||
|
||||
function resultText(toolCall: ToolCallView): string | undefined {
|
||||
if (toolCall.output) {
|
||||
return toolCall.output;
|
||||
const text = toolCall.output || toolCall.summary;
|
||||
return text ? formatJsonResponseAsYaml(text) : undefined;
|
||||
}
|
||||
|
||||
function formatJsonResponseAsYaml(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (
|
||||
!(
|
||||
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
||||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
||||
)
|
||||
) {
|
||||
return text;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
if (parsed === null || typeof parsed !== "object") {
|
||||
return text;
|
||||
}
|
||||
return stringifyYaml(parsed).trimEnd();
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
return toolCall.summary;
|
||||
}
|
||||
|
||||
function knownToolResultText(toolCall: ToolCallView): string | undefined {
|
||||
@@ -1803,7 +1818,7 @@ function argsText(toolCall: ToolCallView): string {
|
||||
return "";
|
||||
}
|
||||
const parsed = parseJson(raw);
|
||||
return parsed === undefined ? raw : jsonPreview(parsed);
|
||||
return parsed === undefined ? raw : stringifyYaml(parsed).trimEnd();
|
||||
}
|
||||
|
||||
function parsedArgs(
|
||||
@@ -1830,21 +1845,6 @@ function compactLines(lines: Array<string | undefined | null | false>): string {
|
||||
return lines.filter((line): line is string => Boolean(line)).join("\n");
|
||||
}
|
||||
|
||||
function cappedSection(
|
||||
value: string | undefined,
|
||||
cap: number,
|
||||
): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const lines = value.split(/\r?\n/);
|
||||
const shown = lines.slice(0, cap);
|
||||
if (lines.length > cap) {
|
||||
shown.push(`… +${lines.length - cap} more lines`);
|
||||
}
|
||||
return shown.join("\n");
|
||||
}
|
||||
|
||||
function cappedDisplaySection(
|
||||
value: string | undefined,
|
||||
maxLines: number,
|
||||
@@ -1960,24 +1960,34 @@ function applyLogEntry(
|
||||
applyLoggedItem(projection, `${eventId}-history-${index}`, item)
|
||||
);
|
||||
break;
|
||||
case "user_input":
|
||||
projection.lines.push(
|
||||
line(
|
||||
eventId,
|
||||
"user",
|
||||
"User",
|
||||
segmentsToText(arrayField(entry, "segments") as Segment[]),
|
||||
),
|
||||
case "annotated_segment_start":
|
||||
arrayField(entry, "history").forEach((historyEntry, index) =>
|
||||
applyLoggedHistoryEntry(
|
||||
projection,
|
||||
`${eventId}-history-${index}`,
|
||||
historyEntry,
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "user_input":
|
||||
case "annotated_user_input":
|
||||
applyLoggedUserInput(projection, eventId, entry);
|
||||
break;
|
||||
case "system_item":
|
||||
projection.lines.push(systemItemLine(eventId, entry["item"]));
|
||||
applyTaskSystemItem(projection, entry["item"]);
|
||||
break;
|
||||
case "annotated_system_item":
|
||||
applyLoggedSystemEntry(projection, eventId, entry["entry"]);
|
||||
break;
|
||||
case "assistant_item":
|
||||
case "tool_result":
|
||||
applyLoggedItem(projection, eventId, entry["item"]);
|
||||
break;
|
||||
case "annotated_assistant_item":
|
||||
case "annotated_tool_result":
|
||||
applyLoggedHistoryEntry(projection, eventId, entry["entry"]);
|
||||
break;
|
||||
case "run_errored":
|
||||
projection.lines.push(
|
||||
line(
|
||||
@@ -2056,6 +2066,52 @@ function compactMessageForState(
|
||||
}
|
||||
}
|
||||
|
||||
function applyLoggedUserInput(
|
||||
projection: ConsoleProjection,
|
||||
eventId: string,
|
||||
entry: Record<string, unknown>,
|
||||
): void {
|
||||
let body = segmentsToText(arrayField(entry, "segments") as Segment[]);
|
||||
if (!body && stringField(entry, "kind") === "annotated_user_input") {
|
||||
body = loggedUserText(arrayField(entry, "history"));
|
||||
}
|
||||
projection.lines.push(line(eventId, "user", "User", body));
|
||||
}
|
||||
|
||||
function loggedUserText(history: unknown[]): string {
|
||||
for (const historyEntry of history) {
|
||||
if (!isRecord(historyEntry) || !isRecord(historyEntry["item"])) continue;
|
||||
const item = historyEntry["item"];
|
||||
if (
|
||||
stringField(item, "kind") !== "message" ||
|
||||
stringField(item, "role") !== "user"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return loggedContentText(arrayField(item, "content"));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function applyLoggedHistoryEntry(
|
||||
projection: ConsoleProjection,
|
||||
eventId: string,
|
||||
historyEntry: unknown,
|
||||
): void {
|
||||
if (!isRecord(historyEntry)) return;
|
||||
applyLoggedItem(projection, eventId, historyEntry["item"]);
|
||||
}
|
||||
|
||||
function applyLoggedSystemEntry(
|
||||
projection: ConsoleProjection,
|
||||
eventId: string,
|
||||
historyEntry: unknown,
|
||||
): void {
|
||||
if (!isRecord(historyEntry)) return;
|
||||
projection.lines.push(systemItemLine(eventId, historyEntry["item"]));
|
||||
applyTaskSystemItem(projection, historyEntry["item"]);
|
||||
}
|
||||
|
||||
function applyLoggedItem(
|
||||
projection: ConsoleProjection,
|
||||
eventId: string,
|
||||
|
||||
@@ -251,7 +251,8 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
ticketDetailLoad.includes("/repositories") &&
|
||||
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||
ticketDetailPage.includes("async function queueTicket") &&
|
||||
ticketDetailPage.includes("`${ticketPath}/queue`") &&
|
||||
ticketDetailPage.includes("const path = ticketPath") &&
|
||||
ticketDetailPage.includes("`${path}/queue`") &&
|
||||
!ticketDetailPage.includes("/merge-request/merge") &&
|
||||
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
||||
ticketDetailPage.includes("mergeRequest.review_status") &&
|
||||
@@ -402,7 +403,7 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
|
||||
consoleLine.includes("item.kind === 'tool'") &&
|
||||
consoleLine.includes("{#if isBashTool(item)}") &&
|
||||
consoleLine.includes(
|
||||
"<AnsiText text={bodyTextAfterToolSummary(item)} />",
|
||||
"<AnsiText text={toolBodyText(item)} />",
|
||||
) &&
|
||||
consoleLine.includes(
|
||||
".console-line.tool-bash .console-plain-text",
|
||||
@@ -419,6 +420,30 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console expands uncapped tool body from the hover detail action", async () => {
|
||||
const consoleLine = await Deno.readTextFile(
|
||||
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
||||
);
|
||||
|
||||
assert(
|
||||
consoleLine.includes(
|
||||
"return detailOpen ? (line.expandedBody ?? line.body) : line.body",
|
||||
) &&
|
||||
consoleLine.includes("line.toolCallLabel ?? line.toolCall?.name") &&
|
||||
consoleLine.includes('class={`tool-status') &&
|
||||
consoleLine.includes('class="tool-detail-button"') &&
|
||||
consoleLine.includes("aria-expanded={detailOpen}") &&
|
||||
consoleLine.includes("detailOpen = !detailOpen") &&
|
||||
consoleLine.includes("item.detail && detailOpen") &&
|
||||
consoleLine.includes('role="region"') &&
|
||||
consoleLine.includes(".console-line:hover .tool-detail-button") &&
|
||||
consoleLine.includes(".tool-detail-button:focus-visible") &&
|
||||
consoleLine.includes("@media (hover: none)") &&
|
||||
!consoleLine.includes('<details class="message-detail">'),
|
||||
"Normal tool display should keep its preview while detail reveals the uncapped body and existing metadata",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker Console renders Edit diffs without preformatted template gaps", async () => {
|
||||
const consoleLine = await Deno.readTextFile(
|
||||
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
const initialData = untrack(() => data);
|
||||
const loadedTicket = initialData.ticket.data;
|
||||
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
||||
const loadedRepositories = initialData.repositories.data;
|
||||
const loadedRepositories = $derived(data.repositories.data);
|
||||
|
||||
type QueueOutcome = {
|
||||
requested_ticket: string;
|
||||
@@ -62,6 +62,8 @@
|
||||
let manualRuntimeId = $state("");
|
||||
let manualWorkerId = $state("");
|
||||
let cancellationReason = $state("");
|
||||
let routeTicketSnapshot = `${initialData.ticketId}:${loadedTicket.item_revision}`;
|
||||
let routeGeneration = 0;
|
||||
const coderAssignment = $derived(
|
||||
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
|
||||
);
|
||||
@@ -95,13 +97,43 @@
|
||||
|
||||
function applyTicket(updatedTicket: TicketDetail): void {
|
||||
ticket = updatedTicket;
|
||||
editTitle = ticket.title;
|
||||
editBody = ticket.body;
|
||||
repositoryId = ticket.repository_id ?? "";
|
||||
refSelector = ticket.ref_selector ?? "";
|
||||
nextState = ticket.state;
|
||||
editTitle = updatedTicket.title;
|
||||
editBody = updatedTicket.body;
|
||||
repositoryId = updatedTicket.repository_id ?? "";
|
||||
refSelector = updatedTicket.ref_selector ?? "";
|
||||
nextState = updatedTicket.state;
|
||||
}
|
||||
|
||||
function resetTicketView(updatedTicket: TicketDetail): void {
|
||||
applyTicket(updatedTicket);
|
||||
editing = false;
|
||||
transitionReason = "";
|
||||
threadRole = "comment";
|
||||
threadBody = "";
|
||||
resolution = "";
|
||||
busy = null;
|
||||
errorMessage = null;
|
||||
queueMessage = null;
|
||||
readyOperationKey = null;
|
||||
manualRuntimeId = "";
|
||||
manualWorkerId = "";
|
||||
cancellationReason = "";
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const incomingTicketId = data.ticketId;
|
||||
const incomingTicket = data.ticket.data;
|
||||
if (!incomingTicket) return;
|
||||
const incomingSnapshot = `${incomingTicketId}:${incomingTicket.item_revision}`;
|
||||
|
||||
untrack(() => {
|
||||
if (incomingSnapshot === routeTicketSnapshot) return;
|
||||
routeTicketSnapshot = incomingSnapshot;
|
||||
routeGeneration += 1;
|
||||
resetTicketView(incomingTicket);
|
||||
});
|
||||
});
|
||||
|
||||
async function mutate(
|
||||
action: string,
|
||||
suffix: string,
|
||||
@@ -109,40 +141,51 @@
|
||||
method = "POST",
|
||||
): Promise<boolean> {
|
||||
if (busy) return false;
|
||||
const generation = routeGeneration;
|
||||
const path = `${ticketPath}${suffix}`;
|
||||
busy = action;
|
||||
errorMessage = null;
|
||||
try {
|
||||
const path = `${ticketPath}${suffix}`;
|
||||
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
|
||||
method,
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
if (generation !== routeGeneration) return false;
|
||||
applyTicket(response);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (generation === routeGeneration) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
busy = null;
|
||||
if (generation === routeGeneration) busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function queueTicket(): Promise<void> {
|
||||
if (busy) return;
|
||||
const generation = routeGeneration;
|
||||
const path = ticketPath;
|
||||
busy = "queue";
|
||||
errorMessage = null;
|
||||
queueMessage = null;
|
||||
try {
|
||||
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
|
||||
`${ticketPath}/queue`,
|
||||
`${path}/queue`,
|
||||
{ method: "POST", body: JSON.stringify({}) },
|
||||
);
|
||||
if (generation !== routeGeneration) return;
|
||||
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
|
||||
if (generation !== routeGeneration) return;
|
||||
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
|
||||
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
|
||||
applyTicket(updatedTicket);
|
||||
} catch (error) {
|
||||
if (generation === routeGeneration) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
} finally {
|
||||
busy = null;
|
||||
if (generation === routeGeneration) busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,11 +195,13 @@
|
||||
principal: Record<string, string>,
|
||||
): Promise<void> {
|
||||
if (busy) return;
|
||||
const generation = routeGeneration;
|
||||
const path = ticketPath;
|
||||
busy = action;
|
||||
errorMessage = null;
|
||||
try {
|
||||
await workspaceApiJsonWithBody(
|
||||
`${ticketPath}/assignments/${role}`,
|
||||
`${path}/assignments/${role}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
@@ -166,11 +211,16 @@
|
||||
}),
|
||||
},
|
||||
);
|
||||
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
|
||||
if (generation !== routeGeneration) return;
|
||||
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
|
||||
if (generation !== routeGeneration) return;
|
||||
applyTicket(updatedTicket);
|
||||
} catch (error) {
|
||||
if (generation === routeGeneration) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
} finally {
|
||||
busy = null;
|
||||
if (generation === routeGeneration) busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { assert, assertStringIncludes } from "jsr:@std/assert";
|
||||
|
||||
const pageSource = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
Deno.test("ticket detail synchronizes reused route data", () => {
|
||||
const effectStart = pageSource.indexOf("$effect(() => {");
|
||||
assert(effectStart >= 0, "ticket detail must react to reused route props");
|
||||
|
||||
const effectSource = pageSource.slice(effectStart);
|
||||
for (
|
||||
const token of [
|
||||
"data.ticketId",
|
||||
"data.ticket.data",
|
||||
"incomingTicket.item_revision",
|
||||
"routeGeneration += 1",
|
||||
"resetTicketView(incomingTicket)",
|
||||
]
|
||||
) {
|
||||
assertStringIncludes(effectSource, token);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("ticket detail fences stale mutation responses", () => {
|
||||
for (
|
||||
const operation of [
|
||||
"async function mutate(",
|
||||
"async function queueTicket(",
|
||||
"async function mutateAssignment(",
|
||||
]
|
||||
) {
|
||||
const operationStart = pageSource.indexOf(operation);
|
||||
assert(operationStart >= 0, `missing ${operation}`);
|
||||
const nextOperation = pageSource.indexOf(
|
||||
"\n async function ",
|
||||
operationStart + 1,
|
||||
);
|
||||
const operationSource = pageSource.slice(
|
||||
operationStart,
|
||||
nextOperation === -1 ? undefined : nextOperation,
|
||||
);
|
||||
assertStringIncludes(operationSource, "const generation = routeGeneration");
|
||||
assertStringIncludes(operationSource, "generation !== routeGeneration");
|
||||
assertStringIncludes(operationSource, "generation === routeGeneration");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user