Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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"
|
name = "client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
"manifest",
|
"manifest",
|
||||||
"protocol",
|
"protocol",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
manifest = { workspace = true }
|
manifest = { workspace = true }
|
||||||
ticket = { 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 serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -9,9 +10,11 @@ pub struct BackendAuthTarget {
|
|||||||
|
|
||||||
impl BackendAuthTarget {
|
impl BackendAuthTarget {
|
||||||
pub fn new(base_url: impl Into<String>) -> Self {
|
pub fn new(base_url: impl Into<String>) -> Self {
|
||||||
Self {
|
let base_url = base_url.into();
|
||||||
base_url: base_url.into(),
|
let base_url = BackendOrigin::parse(&base_url)
|
||||||
}
|
.map(|origin| origin.to_string())
|
||||||
|
.unwrap_or(base_url);
|
||||||
|
Self { base_url }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_url(&self, path: &str) -> String {
|
fn api_url(&self, path: &str) -> String {
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
use crate::{BackendApiClient, BackendApiClientError};
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use protocol::stream::{decode_event, encode_method};
|
use protocol::stream::{decode_event, encode_method};
|
||||||
use protocol::{ErrorCode, Event, Method};
|
use protocol::{ErrorCode, Event, Method};
|
||||||
|
use reqwest::Method as HttpMethod;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_tungstenite::connect_async;
|
use tokio_tungstenite::connect_async;
|
||||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
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 workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
|
||||||
pub use workspace_api::{
|
pub use workspace_api::{
|
||||||
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
|
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
|
||||||
@@ -113,6 +118,7 @@ pub struct BackendRuntimeClient {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BackendRuntimeClientError {
|
pub enum BackendRuntimeClientError {
|
||||||
InvalidTarget(String),
|
InvalidTarget(String),
|
||||||
|
Api(BackendApiClientError),
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +126,7 @@ impl fmt::Display for BackendRuntimeClientError {
|
|||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::InvalidTarget(message) => f.write_str(message),
|
Self::InvalidTarget(message) => f.write_str(message),
|
||||||
|
Self::Api(error) => write!(f, "{error}"),
|
||||||
Self::Http(error) => write!(f, "{error}"),
|
Self::Http(error) => write!(f, "{error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +134,12 @@ impl fmt::Display for BackendRuntimeClientError {
|
|||||||
|
|
||||||
impl std::error::Error for BackendRuntimeClientError {}
|
impl std::error::Error for BackendRuntimeClientError {}
|
||||||
|
|
||||||
|
impl From<BackendApiClientError> for BackendRuntimeClientError {
|
||||||
|
fn from(error: BackendApiClientError) -> Self {
|
||||||
|
Self::Api(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<reqwest::Error> for BackendRuntimeClientError {
|
impl From<reqwest::Error> for BackendRuntimeClientError {
|
||||||
fn from(error: reqwest::Error) -> Self {
|
fn from(error: reqwest::Error) -> Self {
|
||||||
Self::Http(error)
|
Self::Http(error)
|
||||||
@@ -137,7 +150,7 @@ pub async fn list_backend_workers(
|
|||||||
target: &BackendRuntimeListTarget,
|
target: &BackendRuntimeListTarget,
|
||||||
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
|
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
|
||||||
validate_list_target(target)?;
|
validate_list_target(target)?;
|
||||||
let http = reqwest::Client::new();
|
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
if let Some(runtime_id) = target.runtime_id.as_deref() {
|
if let Some(runtime_id) = target.runtime_id.as_deref() {
|
||||||
let path = backend_runtime_workers_path(
|
let path = backend_runtime_workers_path(
|
||||||
target
|
target
|
||||||
@@ -146,12 +159,9 @@ pub async fn list_backend_workers(
|
|||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
runtime_id,
|
runtime_id,
|
||||||
);
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let response = api.request(HttpMethod::GET, &path)?.send().await?;
|
||||||
return Ok(http
|
api.check_status(response.status())?;
|
||||||
.get(url)
|
return Ok(response
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||||
.await?);
|
.await?);
|
||||||
}
|
}
|
||||||
@@ -162,12 +172,9 @@ pub async fn list_backend_workers(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
);
|
);
|
||||||
let runtime_url = join_base_and_path(&target.base_url, &runtime_path);
|
let response = api.request(HttpMethod::GET, &runtime_path)?.send().await?;
|
||||||
let runtimes = http
|
api.check_status(response.status())?;
|
||||||
.get(runtime_url)
|
let runtimes = response
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.json::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
|
.json::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -181,29 +188,43 @@ pub async fn list_backend_workers(
|
|||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
&runtime.runtime_id,
|
&runtime.runtime_id,
|
||||||
);
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let response = match api.request(HttpMethod::GET, &path)?.send().await {
|
||||||
match http
|
Ok(response) => response,
|
||||||
.get(url)
|
Err(error) => {
|
||||||
.send()
|
diagnostics.push(BackendDiagnostic {
|
||||||
.await
|
code: "runtime_worker_list_failed".to_string(),
|
||||||
.and_then(|response| response.error_for_status())
|
severity: BackendDiagnosticSeverity::Error,
|
||||||
{
|
message: format!(
|
||||||
Ok(response) => {
|
"failed to list workers for runtime {}: {error}",
|
||||||
let response = response
|
runtime.runtime_id
|
||||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
),
|
||||||
.await?;
|
});
|
||||||
diagnostics.extend(response.diagnostics);
|
continue;
|
||||||
items.extend(response.items);
|
|
||||||
}
|
}
|
||||||
Err(error) => diagnostics.push(BackendDiagnostic {
|
};
|
||||||
|
if matches!(
|
||||||
|
response.status(),
|
||||||
|
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
|
||||||
|
) {
|
||||||
|
api.check_status(response.status())?;
|
||||||
|
}
|
||||||
|
if !response.status().is_success() {
|
||||||
|
diagnostics.push(BackendDiagnostic {
|
||||||
code: "runtime_worker_list_failed".to_string(),
|
code: "runtime_worker_list_failed".to_string(),
|
||||||
severity: BackendDiagnosticSeverity::Error,
|
severity: BackendDiagnosticSeverity::Error,
|
||||||
message: format!(
|
message: format!(
|
||||||
"failed to list workers for runtime {}: {error}",
|
"failed to list workers for runtime {}: Backend returned HTTP {}",
|
||||||
runtime.runtime_id
|
runtime.runtime_id,
|
||||||
|
response.status().as_u16()
|
||||||
),
|
),
|
||||||
}),
|
});
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
let response = response
|
||||||
|
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||||
|
.await?;
|
||||||
|
diagnostics.extend(response.diagnostics);
|
||||||
|
items.extend(response.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(BackendRuntimeListResponse {
|
Ok(BackendRuntimeListResponse {
|
||||||
@@ -224,7 +245,7 @@ pub async fn list_backend_stopped_workers(
|
|||||||
"stopped worker listing requires a runtime id".to_string(),
|
"stopped worker listing requires a runtime id".to_string(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let http = reqwest::Client::new();
|
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let path = backend_runtime_workers_path(
|
let path = backend_runtime_workers_path(
|
||||||
target
|
target
|
||||||
.workspace_id
|
.workspace_id
|
||||||
@@ -232,12 +253,12 @@ pub async fn list_backend_stopped_workers(
|
|||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
runtime_id,
|
runtime_id,
|
||||||
);
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped"));
|
let response = api
|
||||||
Ok(http
|
.request(HttpMethod::GET, &format!("{path}?status=stopped"))?
|
||||||
.get(url)
|
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?;
|
||||||
.error_for_status()?
|
api.check_status(response.status())?;
|
||||||
|
Ok(response
|
||||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
@@ -246,33 +267,33 @@ pub async fn restore_backend_worker(
|
|||||||
target: &BackendRuntimeTarget,
|
target: &BackendRuntimeTarget,
|
||||||
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
||||||
validate_target(target)?;
|
validate_target(target)?;
|
||||||
let http = reqwest::Client::new();
|
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let path = backend_runtime_worker_restore_path(
|
let path = backend_runtime_worker_restore_path(
|
||||||
&target.workspace_id,
|
&target.workspace_id,
|
||||||
&target.runtime_id,
|
&target.runtime_id,
|
||||||
&target.worker_id,
|
&target.worker_id,
|
||||||
);
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let response = api
|
||||||
Ok(http
|
.request(HttpMethod::POST, &path)?
|
||||||
.post(url)
|
|
||||||
.json(&serde_json::json!({}))
|
.json(&serde_json::json!({}))
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?;
|
||||||
.error_for_status()?
|
api.check_status(response.status())?;
|
||||||
.json::<BackendWorkerRestoreResponse>()
|
Ok(response.json::<BackendWorkerRestoreResponse>().await?)
|
||||||
.await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BackendRuntimeClient {
|
impl BackendRuntimeClient {
|
||||||
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
|
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
|
||||||
validate_target(&target)?;
|
validate_target(&target)?;
|
||||||
|
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let (event_tx, rx) = mpsc::unbounded_channel();
|
let (event_tx, rx) = mpsc::unbounded_channel();
|
||||||
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
let protocol_target = target.clone();
|
let protocol_target = target.clone();
|
||||||
let protocol_event_tx = event_tx.clone();
|
let protocol_event_tx = event_tx.clone();
|
||||||
let protocol_task = tokio::spawn(async move {
|
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 {
|
Ok(Self {
|
||||||
@@ -317,11 +338,21 @@ impl Drop for BackendRuntimeClient {
|
|||||||
|
|
||||||
async fn run_worker_protocol_transport(
|
async fn run_worker_protocol_transport(
|
||||||
target: BackendRuntimeTarget,
|
target: BackendRuntimeTarget,
|
||||||
|
api: BackendApiClient,
|
||||||
mut commands: mpsc::UnboundedReceiver<Method>,
|
mut commands: mpsc::UnboundedReceiver<Method>,
|
||||||
tx: mpsc::UnboundedSender<Event>,
|
tx: mpsc::UnboundedSender<Event>,
|
||||||
) {
|
) {
|
||||||
let url = protocol_ws_url(&target);
|
let request = match protocol_ws_request(&target, &api) {
|
||||||
match connect_async(&url).await {
|
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, _)) => {
|
Ok((ws, _)) => {
|
||||||
let (mut sink, mut stream) = ws.split();
|
let (mut sink, mut stream) = ws.split();
|
||||||
loop {
|
loop {
|
||||||
@@ -387,10 +418,8 @@ async fn run_worker_protocol_transport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
let message = protocol_connect_error_message(&target, &api, &error);
|
||||||
"Backend protocol WebSocket connect failed for {}: {error}",
|
let _ = tx.send(diagnostic_event(message));
|
||||||
target.display_label()
|
|
||||||
)));
|
|
||||||
while commands.recv().await.is_some() {
|
while commands.recv().await.is_some() {
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
let _ = tx.send(diagnostic_event(format!(
|
||||||
"Backend protocol command was not sent because command stream is unavailable for {}",
|
"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 {
|
fn diagnostic_event(message: impl Into<String>) -> Event {
|
||||||
Event::Error {
|
Event::Error {
|
||||||
code: ErrorCode::Internal,
|
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 {
|
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
|
||||||
let path = format!(
|
let path = format!(
|
||||||
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
|
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
|
||||||
@@ -557,6 +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]
|
#[test]
|
||||||
fn backend_worker_summary_decodes_current_occupied_workdir_contract() {
|
fn backend_worker_summary_decodes_current_occupied_workdir_contract() {
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use crate::{BackendApiClient, BackendApiClientError};
|
||||||
|
use reqwest::Method;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
||||||
@@ -70,7 +72,7 @@ impl BackendWorkspaceCatalogTarget {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BackendWorkspaceClientError {
|
pub enum BackendWorkspaceClientError {
|
||||||
InvalidTarget(String),
|
InvalidTarget(String),
|
||||||
RequestFailed { status: u16, message: String },
|
Api(BackendApiClientError),
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,9 +80,7 @@ impl fmt::Display for BackendWorkspaceClientError {
|
|||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::InvalidTarget(message) => f.write_str(message),
|
Self::InvalidTarget(message) => f.write_str(message),
|
||||||
Self::RequestFailed { status, message } => {
|
Self::Api(error) => write!(f, "{error}"),
|
||||||
write!(f, "Backend request failed with HTTP {status}: {message}")
|
|
||||||
}
|
|
||||||
Self::Http(error) => write!(f, "{error}"),
|
Self::Http(error) => write!(f, "{error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,6 +88,12 @@ impl fmt::Display for BackendWorkspaceClientError {
|
|||||||
|
|
||||||
impl std::error::Error for BackendWorkspaceClientError {}
|
impl std::error::Error for BackendWorkspaceClientError {}
|
||||||
|
|
||||||
|
impl From<BackendApiClientError> for BackendWorkspaceClientError {
|
||||||
|
fn from(error: BackendApiClientError) -> Self {
|
||||||
|
Self::Api(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<reqwest::Error> for BackendWorkspaceClientError {
|
impl From<reqwest::Error> for BackendWorkspaceClientError {
|
||||||
fn from(error: reqwest::Error) -> Self {
|
fn from(error: reqwest::Error) -> Self {
|
||||||
Self::Http(error)
|
Self::Http(error)
|
||||||
@@ -97,13 +103,21 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
|
|||||||
pub async fn list_backend_workspaces(
|
pub async fn list_backend_workspaces(
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||||
validate_target(target)?;
|
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let url = format!(
|
list_backend_workspaces_with_client(&client).await
|
||||||
"{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}",
|
}
|
||||||
target.base_url.trim_end_matches('/')
|
|
||||||
);
|
async fn list_backend_workspaces_with_client(
|
||||||
let response = reqwest::Client::new().get(url).send().await?;
|
client: &BackendApiClient,
|
||||||
let response = require_success(response).await?;
|
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||||
|
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?)
|
Ok(response.json::<Vec<BackendWorkspace>>().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,42 +125,50 @@ pub async fn create_backend_workspace(
|
|||||||
target: &BackendWorkspaceCatalogTarget,
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
request: &CreateBackendWorkspaceRequest,
|
request: &CreateBackendWorkspaceRequest,
|
||||||
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
|
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
|
||||||
validate_target(target)?;
|
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/'));
|
let response = client
|
||||||
let response = reqwest::Client::new()
|
.request(Method::POST, "/api/workspaces")?
|
||||||
.post(url)
|
|
||||||
.json(request)
|
.json(request)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
let response = require_success(response).await?;
|
client.check_status(response.status())?;
|
||||||
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
|
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn require_success(
|
|
||||||
response: reqwest::Response,
|
|
||||||
) -> Result<reqwest::Response, BackendWorkspaceClientError> {
|
|
||||||
if response.status().is_success() {
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
let message = response.text().await.unwrap_or_default();
|
|
||||||
Err(BackendWorkspaceClientError::RequestFailed { status, message })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_target(
|
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
|
||||||
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
|
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Backend API base URL must start with http:// or https://".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn workspace_catalog_request_uses_shared_bearer_client() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut request = vec![0; 4096];
|
||||||
|
let read = stream.read(&mut request).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
||||||
|
assert!(request.starts_with("get /api/workspaces?limit=200 "));
|
||||||
|
assert!(request.contains("authorization: bearer catalog-secret\r\n"));
|
||||||
|
stream
|
||||||
|
.write_all(
|
||||||
|
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let client =
|
||||||
|
BackendApiClient::from_access_token_for_test(&base_url, "catalog-secret").unwrap();
|
||||||
|
assert!(
|
||||||
|
list_backend_workspaces_with_client(&client)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
handle.join().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_request_keeps_operation_key_for_exact_retry() {
|
fn create_request_keeps_operation_key_for_exact_retry() {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
//!
|
//!
|
||||||
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
||||||
|
|
||||||
pub mod backend_auth;
|
pub mod backend_api;
|
||||||
|
mod backend_auth;
|
||||||
pub mod backend_runtime;
|
pub mod backend_runtime;
|
||||||
pub mod backend_workspace;
|
pub mod backend_workspace;
|
||||||
pub mod runtime_command;
|
pub mod runtime_command;
|
||||||
@@ -18,6 +19,10 @@ pub mod ticket_role;
|
|||||||
mod worker_client;
|
mod worker_client;
|
||||||
mod workspace_product;
|
mod workspace_product;
|
||||||
|
|
||||||
|
pub use backend_api::{
|
||||||
|
BackendApiClient, BackendApiClientError, BackendOrigin, backend_token_file_path,
|
||||||
|
save_backend_token,
|
||||||
|
};
|
||||||
pub use backend_auth::{
|
pub use backend_auth::{
|
||||||
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
||||||
poll_device_login, start_device_login, wait_for_device_login,
|
poll_device_login, start_device_login, wait_for_device_login,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
|
use crate::{
|
||||||
|
BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget,
|
||||||
|
BackendRuntimeTarget, WorkerRuntimeCommand,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum TargetKind {
|
pub enum TargetKind {
|
||||||
@@ -62,11 +65,19 @@ pub struct BackendTarget {
|
|||||||
|
|
||||||
impl BackendTarget {
|
impl BackendTarget {
|
||||||
pub fn new(base_url: impl Into<String>, workspace_id: Option<impl Into<String>>) -> Self {
|
pub fn new(base_url: impl Into<String>, workspace_id: Option<impl Into<String>>) -> Self {
|
||||||
|
let base_url = base_url.into();
|
||||||
|
let base_url = BackendOrigin::parse(&base_url)
|
||||||
|
.map(|origin| origin.to_string())
|
||||||
|
.unwrap_or(base_url);
|
||||||
Self {
|
Self {
|
||||||
base_url: base_url.into(),
|
base_url,
|
||||||
workspace_id: workspace_id.map(Into::into),
|
workspace_id: workspace_id.map(Into::into),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn authenticated_client(&self) -> Result<BackendApiClient, BackendApiClientError> {
|
||||||
|
BackendApiClient::from_stored_token(&self.base_url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use workspace_api::{
|
|||||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::BackendWorkspaceClientError;
|
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||||
|
|
||||||
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
||||||
|
|
||||||
@@ -47,9 +47,9 @@ struct BackendWorkspaceOrchestratorResponse {
|
|||||||
/// Construction requires both the selected Backend URL and Workspace identity.
|
/// Construction requires both the selected Backend URL and Workspace identity.
|
||||||
/// Callers should derive these once from `Target::resolve()` and must not retry
|
/// Callers should derive these once from `Target::resolve()` and must not retry
|
||||||
/// failed requests against repository-local state.
|
/// failed requests against repository-local state.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BackendWorkspaceProductClient {
|
pub struct BackendWorkspaceProductClient {
|
||||||
base_url: String,
|
api: BackendApiClient,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,22 +58,32 @@ impl BackendWorkspaceProductClient {
|
|||||||
base_url: impl Into<String>,
|
base_url: impl Into<String>,
|
||||||
workspace_id: impl Into<String>,
|
workspace_id: impl Into<String>,
|
||||||
) -> Result<Self, BackendWorkspaceClientError> {
|
) -> Result<Self, BackendWorkspaceClientError> {
|
||||||
let base_url = base_url.into().trim_end_matches('/').to_string();
|
let base_url = base_url.into();
|
||||||
if base_url.is_empty() {
|
let api = BackendApiClient::from_stored_token(&base_url)?;
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Backend base URL must not be empty".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let workspace_id = workspace_id.into();
|
let workspace_id = workspace_id.into();
|
||||||
if workspace_id.trim().is_empty() {
|
if workspace_id.trim().is_empty() {
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||||
"Backend Workspace identity must not be empty".into(),
|
"Backend Workspace identity must not be empty".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self { api, workspace_id })
|
||||||
base_url,
|
}
|
||||||
workspace_id,
|
|
||||||
})
|
#[cfg(test)]
|
||||||
|
fn new_with_access_token(
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
workspace_id: impl Into<String>,
|
||||||
|
access_token: &str,
|
||||||
|
) -> Result<Self, BackendWorkspaceClientError> {
|
||||||
|
let base_url = base_url.into();
|
||||||
|
let api = BackendApiClient::from_access_token_for_test(&base_url, access_token)?;
|
||||||
|
let workspace_id = workspace_id.into();
|
||||||
|
if workspace_id.trim().is_empty() {
|
||||||
|
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||||
|
"Backend Workspace identity must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self { api, workspace_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn workspace_id(&self) -> &str {
|
pub fn workspace_id(&self) -> &str {
|
||||||
@@ -316,7 +326,7 @@ impl BackendWorkspaceProductClient {
|
|||||||
body: Option<&B>,
|
body: Option<&B>,
|
||||||
) -> Result<R, BackendWorkspaceClientError> {
|
) -> Result<R, BackendWorkspaceClientError> {
|
||||||
let response = self.request(method, path, body)?.send()?;
|
let response = self.request(method, path, body)?.send()?;
|
||||||
let response = ensure_success(response)?;
|
self.api.check_status(response.status())?;
|
||||||
response.json().map_err(BackendWorkspaceClientError::Http)
|
response.json().map_err(BackendWorkspaceClientError::Http)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +336,8 @@ impl BackendWorkspaceProductClient {
|
|||||||
path: &str,
|
path: &str,
|
||||||
body: Option<&B>,
|
body: Option<&B>,
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
) -> Result<(), BackendWorkspaceClientError> {
|
||||||
ensure_success(self.request(method, path, body)?.send()?)?;
|
let response = self.request(method, path, body)?.send()?;
|
||||||
|
self.api.check_status(response.status())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,14 +347,12 @@ impl BackendWorkspaceProductClient {
|
|||||||
path: &str,
|
path: &str,
|
||||||
body: Option<&B>,
|
body: Option<&B>,
|
||||||
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
|
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
|
||||||
let client = reqwest::blocking::Client::builder().build()?;
|
let path = format!(
|
||||||
let url = format!(
|
"/api/w/{}/{}",
|
||||||
"{}/api/w/{}/{}",
|
|
||||||
self.base_url,
|
|
||||||
encode_path_segment(&self.workspace_id),
|
encode_path_segment(&self.workspace_id),
|
||||||
path.trim_start_matches('/')
|
path.trim_start_matches('/')
|
||||||
);
|
);
|
||||||
let request = client.request(method, url);
|
let request = self.api.blocking_request(method, &path)?;
|
||||||
Ok(match body {
|
Ok(match body {
|
||||||
Some(body) => request.json(body),
|
Some(body) => request.json(body),
|
||||||
None => request,
|
None => request,
|
||||||
@@ -588,19 +597,6 @@ fn ticket_client_error(error: BackendWorkspaceClientError) -> TicketError {
|
|||||||
TicketError::Sqlite(format!("Backend request failed: {error}"))
|
TicketError::Sqlite(format!("Backend request failed: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_success(
|
|
||||||
response: reqwest::blocking::Response,
|
|
||||||
) -> Result<reqwest::blocking::Response, BackendWorkspaceClientError> {
|
|
||||||
if response.status().is_success() {
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
let message = response
|
|
||||||
.text()
|
|
||||||
.unwrap_or_else(|_| "Backend request failed".to_string());
|
|
||||||
Err(BackendWorkspaceClientError::RequestFailed { status, message })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ticket_reference(id: &TicketIdOrSlug) -> String {
|
fn ticket_reference(id: &TicketIdOrSlug) -> String {
|
||||||
match id {
|
match id {
|
||||||
TicketIdOrSlug::Id(id) => id.to_string(),
|
TicketIdOrSlug::Id(id) => id.to_string(),
|
||||||
@@ -698,24 +694,32 @@ mod tests {
|
|||||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
fn objective_list_uses_workspace_scoped_backend_route() {
|
||||||
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
let (base_url, request, handle) = one_response_server("200 OK", body);
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let response = client.list_objectives(1_000).unwrap();
|
let response = client.list_objectives(1_000).unwrap();
|
||||||
|
|
||||||
assert!(response.items.is_empty());
|
assert!(response.items.is_empty());
|
||||||
assert!(
|
let request = request.recv().unwrap();
|
||||||
request
|
assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 "));
|
||||||
.recv()
|
assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
|
||||||
.unwrap()
|
|
||||||
.starts_with("GET /api/w/workspace-a/objectives?limit=1000 ")
|
|
||||||
);
|
|
||||||
handle.join().unwrap();
|
handle.join().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_mutation_failure_is_returned_without_local_fallback() {
|
fn backend_mutation_failure_is_returned_without_local_fallback() {
|
||||||
let (base_url, request, handle) = one_response_server("403 Forbidden", "denied");
|
let (base_url, request, handle) =
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
one_response_server("403 Forbidden", "test-backend-token");
|
||||||
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let error = client
|
let error = client
|
||||||
.create_objective(&ObjectiveCreateRequest {
|
.create_objective(&ObjectiveCreateRequest {
|
||||||
@@ -727,6 +731,7 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(error.to_string().contains("403"));
|
assert!(error.to_string().contains("403"));
|
||||||
|
assert!(!error.to_string().contains("test-backend-token"));
|
||||||
assert!(
|
assert!(
|
||||||
request
|
request
|
||||||
.recv()
|
.recv()
|
||||||
@@ -739,7 +744,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ticket_relation_query_uses_workspace_scoped_backend_route() {
|
fn ticket_relation_query_uses_workspace_scoped_backend_route() {
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let relations = client
|
let relations = client
|
||||||
.query_ticket_relations(
|
.query_ticket_relations(
|
||||||
@@ -758,7 +768,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
|
fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap();
|
let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap();
|
||||||
|
|
||||||
@@ -784,7 +799,12 @@ mod tests {
|
|||||||
r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#,
|
r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let status = client.launch_ticket_intake("T-1").unwrap();
|
let status = client.launch_ticket_intake("T-1").unwrap();
|
||||||
|
|
||||||
@@ -806,7 +826,12 @@ mod tests {
|
|||||||
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
|
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
|
||||||
let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#;
|
let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#;
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
let (base_url, request, handle) = one_response_server("200 OK", body);
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let status = client.start_workspace_orchestrator().unwrap();
|
let status = client.start_workspace_orchestrator().unwrap();
|
||||||
|
|
||||||
@@ -822,7 +847,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn product_client_requires_workspace_identity() {
|
fn product_client_requires_workspace_identity() {
|
||||||
let error = BackendWorkspaceProductClient::new("http://127.0.0.1:8787", "").unwrap_err();
|
let error = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
"http://127.0.0.1:8787",
|
||||||
|
"",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
assert!(error.to_string().contains("Workspace identity"));
|
assert!(error.to_string().contains("Workspace identity"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -279,4 +279,58 @@ mod tests {
|
|||||||
assert_eq!(grep.matched_files, 2);
|
assert_eq!(grep.matched_files, 2);
|
||||||
assert!(!grep.output.contains("c.txt"));
|
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 std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::FsAccessPolicy;
|
use crate::FsAccessPolicy;
|
||||||
@@ -57,20 +59,11 @@ impl GrepReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
GrepOutputMode::Content => {
|
GrepOutputMode::Content => {
|
||||||
for line in &self.lines {
|
output.push_str(&render_content_lines(
|
||||||
let separator = if line.is_match { ':' } else { '-' };
|
root,
|
||||||
let path = logical_display(root, &line.path);
|
&self.lines,
|
||||||
if self.show_line_numbers
|
self.show_line_numbers,
|
||||||
&& let Some(number) = line.line_number
|
));
|
||||||
{
|
|
||||||
output.push_str(&format!(
|
|
||||||
"{path}{separator}{number}{separator}{}\n",
|
|
||||||
line.text
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
output.push_str(&format!("{path}{separator}{}\n", line.text));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GrepResult {
|
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 {
|
fn logical_display(root: &Path, path: &Path) -> String {
|
||||||
path.strip_prefix(root)
|
path.strip_prefix(root)
|
||||||
.unwrap_or(path)
|
.unwrap_or(path)
|
||||||
|
|||||||
@@ -946,7 +946,7 @@ fn apply_role_profile(
|
|||||||
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
|
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
|
||||||
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
|
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
|
||||||
value["feature"]["worker"] = serde_json::json!({
|
value["feature"]["worker"] = serde_json::json!({
|
||||||
"enabled": matches!(slug, "companion" | "orchestrator"),
|
"enabled": slug == "orchestrator",
|
||||||
"direct_spawn": slug != "orchestrator"
|
"direct_spawn": slug != "orchestrator"
|
||||||
});
|
});
|
||||||
value["feature"]["manage_workdir"] = serde_json::json!({
|
value["feature"]["manage_workdir"] = serde_json::json!({
|
||||||
@@ -1408,7 +1408,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 tmp = TempDir::new().unwrap();
|
||||||
let resolved = ProfileResolver::new()
|
let resolved = ProfileResolver::new()
|
||||||
.with_workspace_base(tmp.path())
|
.with_workspace_base(tmp.path())
|
||||||
@@ -1419,6 +1434,8 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(resolved.manifest.feature.manage_workdir.enabled);
|
assert!(resolved.manifest.feature.manage_workdir.enabled);
|
||||||
|
assert!(resolved.manifest.feature.sub_worker.enabled);
|
||||||
|
assert!(!resolved.manifest.feature.worker.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1546,7 +1546,7 @@ fn model_ticket_reference(
|
|||||||
match ticket.meta.resource_key {
|
match ticket.meta.resource_key {
|
||||||
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
|
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
|
||||||
Some(_) => Err(ToolError::ExecutionFailed(format!(
|
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),
|
None => Ok(ticket.meta.id),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
|
|||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = schemars::schema_for!(GrepParams);
|
let schema = schemars::schema_for!(GrepParams);
|
||||||
let meta = ToolMeta::new("Grep")
|
let meta = ToolMeta::new("Grep")
|
||||||
.description("Search Workdir file contents with a regex. 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"));
|
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
|
||||||
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
|
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
|
|||||||
@@ -998,14 +998,6 @@ where
|
|||||||
|
|
||||||
if feature_config.sub_worker.enabled {
|
if feature_config.sub_worker.enabled {
|
||||||
worker.register_worker_orchestration_instruction();
|
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();
|
let host_worker_observation_provider = worker.worker_observation_provider();
|
||||||
|
|||||||
@@ -209,9 +209,7 @@ impl WorkspaceHttpObjectiveBackend {
|
|||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.filter(|key| is_canonical_resource_key(key, "T-"))
|
.filter(|key| is_canonical_resource_key(key, "T-"))
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| ToolError::ExecutionFailed("required T- key is unavailable".to_string()))
|
||||||
ToolError::ExecutionFailed("required T- human key is unavailable".to_string())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn objective_url(&self, id: &str) -> 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> {
|
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
|
||||||
if !is_canonical_resource_key(&response.resource_key, "O-") {
|
if !is_canonical_resource_key(&response.resource_key, "O-") {
|
||||||
return Err(ToolError::ExecutionFailed(
|
return Err(ToolError::ExecutionFailed(
|
||||||
"required O- human key is unavailable".to_string(),
|
"required O- key is unavailable".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let projected = serde_json::json!({
|
let projected = serde_json::json!({
|
||||||
@@ -712,7 +710,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[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 listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
let server = thread::spawn(move || {
|
let server = thread::spawn(move || {
|
||||||
@@ -764,7 +762,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[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 listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
let server = thread::spawn(move || {
|
let server = thread::spawn(move || {
|
||||||
@@ -833,7 +831,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn objective_output_rejects_noncanonical_human_keys() {
|
fn objective_output_rejects_noncanonical_keys() {
|
||||||
let response = ObjectiveDetail {
|
let response = ObjectiveDetail {
|
||||||
resource_key: "O-internal".to_string(),
|
resource_key: "O-internal".to_string(),
|
||||||
title: "Objective".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> {
|
fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, String> {
|
||||||
let item = object(value, "Ticket query item")?;
|
let item = object(value, "Ticket query item")?;
|
||||||
Ok(ModelTicketQueryItem {
|
Ok(ModelTicketQueryItem {
|
||||||
ticket: human_ref(item, "resource_key", "T-")?,
|
ticket: resource_ref(item, "resource_key", "T-")?,
|
||||||
title: string_field(item, "title")?,
|
title: string_field(item, "title")?,
|
||||||
state: string_field(item, "state")?,
|
state: string_field(item, "state")?,
|
||||||
readiness: optional_string(item, "readiness")?,
|
readiness: optional_string(item, "readiness")?,
|
||||||
@@ -245,7 +245,7 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
|
|||||||
.transpose()?,
|
.transpose()?,
|
||||||
linked_objectives: string_array(item, "linked_objective_keys")?
|
linked_objectives: string_array(item, "linked_objective_keys")?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|key| validate_human_ref(key, "O-"))
|
.map(|key| validate_resource_ref(key, "O-"))
|
||||||
.collect::<Result<Vec<_>, _>>()?,
|
.collect::<Result<Vec<_>, _>>()?,
|
||||||
relation_count: usize_field(item, "relation_count")?,
|
relation_count: usize_field(item, "relation_count")?,
|
||||||
blocker_count: usize_field(item, "blocker_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<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
Ok(ModelTicketDetail {
|
Ok(ModelTicketDetail {
|
||||||
ticket: human_ref(root, "resource_key", "T-")?,
|
ticket: resource_ref(root, "resource_key", "T-")?,
|
||||||
title: string_field(root, "title")?,
|
title: string_field(root, "title")?,
|
||||||
body: string_field(root, "body")?,
|
body: string_field(root, "body")?,
|
||||||
state: string_field(root, "state")?,
|
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 item = object(value, "Objective query item")?;
|
||||||
let linked_tickets = string_array(item, "linked_ticket_keys")?
|
let linked_tickets = string_array(item, "linked_ticket_keys")?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|key| validate_human_ref(key, "T-"))
|
.map(|key| validate_resource_ref(key, "T-"))
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
Ok(ModelObjectiveQueryItem {
|
Ok(ModelObjectiveQueryItem {
|
||||||
objective: human_ref(item, "resource_key", "O-")?,
|
objective: resource_ref(item, "resource_key", "O-")?,
|
||||||
title: string_field(item, "title")?,
|
title: string_field(item, "title")?,
|
||||||
summary: optional_string(item, "snippet")?,
|
summary: optional_string(item, "snippet")?,
|
||||||
state: string_field(item, "state")?,
|
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> {
|
pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDetail, String> {
|
||||||
let root = object(&value, "Objective detail response")?;
|
let root = object(&value, "Objective detail response")?;
|
||||||
Ok(ModelObjectiveDetail {
|
Ok(ModelObjectiveDetail {
|
||||||
objective: human_ref(root, "resource_key", "O-")?,
|
objective: resource_ref(root, "resource_key", "O-")?,
|
||||||
title: string_field(root, "title")?,
|
title: string_field(root, "title")?,
|
||||||
body: string_field(root, "body")?,
|
body: string_field(root, "body")?,
|
||||||
state: string_field(root, "state")?,
|
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> {
|
fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
|
||||||
let worker = object(value, "Worker summary")?;
|
let worker = object(value, "Worker summary")?;
|
||||||
Ok(ModelWorkerSummary {
|
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,
|
kind_key: &str,
|
||||||
) -> Result<ModelRelation, String> {
|
) -> Result<ModelRelation, String> {
|
||||||
let relation = object(value, "Ticket relation")?;
|
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 {
|
Ok(ModelRelation {
|
||||||
ticket: human_ref(relation, ticket_key, "T-")?,
|
ticket: resource_ref(relation, ticket_key, "T-")?,
|
||||||
kind,
|
kind: string_field(relation, kind_key)?,
|
||||||
note,
|
note: optional_string(relation, "note")?,
|
||||||
created_at,
|
created_at: optional_string(relation, "at")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
|
fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
|
||||||
let blocker = object(value, "Ticket blocker")?;
|
let blocker = object(value, "Ticket blocker")?;
|
||||||
Ok(ModelBlocker {
|
Ok(ModelBlocker {
|
||||||
ticket: human_ref(blocker, "blocking_resource_key", "T-")?,
|
ticket: resource_ref(blocker, "blocking_resource_key", "T-")?,
|
||||||
kind: string_field(blocker, "relation_kind")?,
|
kind: string_field(blocker, "relation_kind")?,
|
||||||
state: optional_string(blocker, "blocking_state")?,
|
state: optional_string(blocker, "blocking_state")?,
|
||||||
resolved: bool_field(blocker, "resolved")?,
|
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> {
|
fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, String> {
|
||||||
let summary = object(value, "Objective summary")?;
|
let summary = object(value, "Objective summary")?;
|
||||||
Ok(ModelObjectiveSummary {
|
Ok(ModelObjectiveSummary {
|
||||||
objective: human_ref(summary, "resource_key", "O-")?,
|
objective: resource_ref(summary, "resource_key", "O-")?,
|
||||||
title: string_field(summary, "title")?,
|
title: string_field(summary, "title")?,
|
||||||
state: string_field(summary, "state")?,
|
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> {
|
fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
|
||||||
let summary = object(value, "Ticket summary")?;
|
let summary = object(value, "Ticket summary")?;
|
||||||
Ok(ModelTicketSummary {
|
Ok(ModelTicketSummary {
|
||||||
ticket: human_ref(summary, "resource_key", "T-")?,
|
ticket: resource_ref(summary, "resource_key", "T-")?,
|
||||||
title: string_field(summary, "title")?,
|
title: string_field(summary, "title")?,
|
||||||
state: string_field(summary, "state")?,
|
state: string_field(summary, "state")?,
|
||||||
})
|
})
|
||||||
@@ -491,9 +475,7 @@ fn project_assignment(
|
|||||||
let principal = match kind.as_str() {
|
let principal = match kind.as_str() {
|
||||||
"worker" => current_coder
|
"worker" => current_coder
|
||||||
.map(|coder| coder.worker.clone())
|
.map(|coder| coder.worker.clone())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| "Worker assignment is missing a Workspace key projection".to_string())?,
|
||||||
"Worker assignment is missing a Workspace human key projection".to_string()
|
|
||||||
})?,
|
|
||||||
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
|
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
|
||||||
"user" => "user".to_string(),
|
"user" => "user".to_string(),
|
||||||
other => format!("source:{other}"),
|
other => format!("source:{other}"),
|
||||||
@@ -645,23 +627,23 @@ fn string_array(object: &Map<String, Value>, key: &str) -> Result<Vec<String>, S
|
|||||||
.collect()
|
.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
|
let value = object
|
||||||
.get(key)
|
.get(key)
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.ok_or_else(|| format!("required {prefix} human key is unavailable"))?;
|
.ok_or_else(|| format!("required {prefix} key is unavailable"))?;
|
||||||
validate_human_ref(value, prefix)
|
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| {
|
let valid = value.strip_prefix(prefix).is_some_and(|sequence| {
|
||||||
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
});
|
});
|
||||||
if valid {
|
if valid {
|
||||||
Ok(value)
|
Ok(value)
|
||||||
} else {
|
} 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;
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn objective_projection_exposes_only_human_resource_references() {
|
fn objective_projection_exposes_only_resource_references() {
|
||||||
let projected = project_objective_detail(json!({
|
let projected = project_objective_detail(json!({
|
||||||
"id": "00001M10HW6BV",
|
"id": "00001M10HW6BV",
|
||||||
"resource_key": "O-543",
|
"resource_key": "O-543",
|
||||||
@@ -779,9 +761,98 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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-")] {
|
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,19 +306,32 @@ struct BackendTicketService {
|
|||||||
backend: TicketToolBackend,
|
backend: TicketToolBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct WorkspaceTicketService {
|
||||||
|
backend: WorkspaceHttpTicketBackend,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ticket_handoff_from_record(ticket: Ticket) -> Result<TicketHandoff, TicketError> {
|
||||||
|
let resource_key = ticket
|
||||||
|
.meta
|
||||||
|
.resource_key
|
||||||
|
.filter(|key| is_canonical_ticket_resource_key(key))
|
||||||
|
.ok_or_else(|| TicketError::Conflict("ticket resource key is unavailable".into()))?;
|
||||||
|
Ok(TicketHandoff {
|
||||||
|
id: ticket.meta.id,
|
||||||
|
resource_key,
|
||||||
|
workflow_state: ticket.meta.workflow_state,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
impl TicketService for BackendTicketService {
|
impl TicketService for BackendTicketService {
|
||||||
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||||
let ticket = self.backend.show(ticket_ref.into())?;
|
ticket_handoff_from_record(self.backend.show(ticket_ref.into())?)
|
||||||
let resource_key = ticket
|
}
|
||||||
.meta
|
}
|
||||||
.resource_key
|
|
||||||
.filter(|key| is_canonical_ticket_resource_key(key))
|
impl TicketService for WorkspaceTicketService {
|
||||||
.ok_or_else(|| TicketError::Conflict("ticket resource key is unavailable".into()))?;
|
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
|
||||||
Ok(TicketHandoff {
|
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
|
||||||
id: ticket.meta.id,
|
|
||||||
resource_key,
|
|
||||||
workflow_state: ticket.meta.workflow_state,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,9 +653,14 @@ impl FeatureModule for TicketFeature {
|
|||||||
let Some(backend) = self.tool_backend(context) else {
|
let Some(backend) = self.tool_backend(context) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let ticket_service: Arc<dyn TicketService> = Arc::new(BackendTicketService {
|
let ticket_service: Arc<dyn TicketService> = match &self.backend {
|
||||||
backend: backend.clone(),
|
TicketFeatureBackend::WorkspaceClient(client) => Arc::new(WorkspaceTicketService {
|
||||||
});
|
backend: WorkspaceHttpTicketBackend::new(client.clone()),
|
||||||
|
}),
|
||||||
|
TicketFeatureBackend::Local { .. } => Arc::new(BackendTicketService {
|
||||||
|
backend: backend.clone(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
context.services().provide(
|
context.services().provide(
|
||||||
ServiceDeclaration::new(
|
ServiceDeclaration::new(
|
||||||
ServiceId::builtin(TICKET_SERVICE_ID),
|
ServiceId::builtin(TICKET_SERVICE_ID),
|
||||||
@@ -714,6 +732,26 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
Self::invoke_client(client, workspace_id, operation)
|
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 {
|
fn ticket_path(id: &TicketIdOrSlug) -> String {
|
||||||
let value = match id {
|
let value = match id {
|
||||||
TicketIdOrSlug::Id(value)
|
TicketIdOrSlug::Id(value)
|
||||||
@@ -738,6 +776,29 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
endpoint: String,
|
endpoint: String,
|
||||||
body: Option<serde_json::Value>,
|
body: Option<serde_json::Value>,
|
||||||
) -> TicketResult<T> {
|
) -> 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 {
|
let request = match body {
|
||||||
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
|
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
|
||||||
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
|
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
|
||||||
@@ -756,11 +817,7 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
response.status
|
response.status
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let mut value: Value = serde_json::from_str(&response.body).map_err(|error| {
|
serde_json::from_str(&response.body)
|
||||||
TicketError::Conflict(format!("decode ticket REST response: {error}"))
|
|
||||||
})?;
|
|
||||||
Self::canonicalize_ticket_references(&mut value);
|
|
||||||
serde_json::from_value(value)
|
|
||||||
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,9 +867,7 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.filter(|key| is_canonical_ticket_resource_key(key))
|
.filter(|key| is_canonical_ticket_resource_key(key))
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| TicketError::Conflict("required Ticket key is unavailable".to_string()))
|
||||||
TicketError::Conflict("required Ticket human key is unavailable".to_string())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_unit(
|
fn request_unit(
|
||||||
@@ -889,7 +944,7 @@ impl WorkspaceHttpTicketBackend {
|
|||||||
.is_some_and(is_canonical_ticket_resource_key)
|
.is_some_and(is_canonical_ticket_resource_key)
|
||||||
{
|
{
|
||||||
return Err(TicketError::Conflict(
|
return Err(TicketError::Conflict(
|
||||||
"required Ticket human key is unavailable".to_string(),
|
"required Ticket key is unavailable".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(TicketBackendOperationResult::Ticket(ticket))
|
Ok(TicketBackendOperationResult::Ticket(ticket))
|
||||||
@@ -1861,7 +1916,7 @@ provider = "github"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
let addr = listener.local_addr().unwrap();
|
let addr = listener.local_addr().unwrap();
|
||||||
let server = thread::spawn(move || {
|
let server = thread::spawn(move || {
|
||||||
@@ -2000,6 +2055,46 @@ provider = "github"
|
|||||||
assert_eq!(removed.target, "T-2");
|
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]
|
#[test]
|
||||||
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
|
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
|
||||||
assert!(is_canonical_ticket_resource_key("T-482"));
|
assert!(is_canonical_ticket_resource_key("T-482"));
|
||||||
|
|||||||
@@ -4449,6 +4449,13 @@ mod tests {
|
|||||||
.resolve_profile("builtin:companion", root.path(), "embedded-test-companion")
|
.resolve_profile("builtin:companion", root.path(), "embedded-test-companion")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(companion.feature.manage_workdir.enabled);
|
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]
|
#[test]
|
||||||
@@ -4475,6 +4482,8 @@ mod tests {
|
|||||||
.resolve_profile("builtin:coder", root.path(), "remote-test-worker")
|
.resolve_profile("builtin:coder", root.path(), "remote-test-worker")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(manifest.worker.name, "remote-test-worker");
|
assert_eq!(manifest.worker.name, "remote-test-worker");
|
||||||
|
assert!(manifest.feature.sub_worker.enabled);
|
||||||
|
assert!(!manifest.feature.worker.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -5458,6 +5458,36 @@ fn resolve_workspace_ticket_reference(
|
|||||||
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())).into())
|
.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)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
struct MergeRequestListHttpQuery {
|
struct MergeRequestListHttpQuery {
|
||||||
state: Option<String>,
|
state: Option<String>,
|
||||||
@@ -6418,9 +6448,18 @@ fn canonical_ticket_resource_key(resource_key: &str) -> Option<&str> {
|
|||||||
.then_some(resource_key)
|
.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!(
|
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,
|
api: &WorkspaceApi,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
ticket_id: &str,
|
ticket_id: &str,
|
||||||
_previous_state: &str,
|
previous_state: &str,
|
||||||
current_state: &str,
|
current_state: &str,
|
||||||
source: Option<RuntimeWorkerRef>,
|
source: Option<RuntimeWorkerRef>,
|
||||||
) {
|
) {
|
||||||
@@ -6532,7 +6571,7 @@ fn notify_ticket_recipients(
|
|||||||
recipients.sort();
|
recipients.sort();
|
||||||
recipients.dedup();
|
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 {
|
for recipient in recipients {
|
||||||
if source.as_ref().is_some_and(|source| source == &recipient) {
|
if source.as_ref().is_some_and(|source| source == &recipient) {
|
||||||
continue;
|
continue;
|
||||||
@@ -8217,6 +8256,11 @@ async fn spawn_known_worker(
|
|||||||
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
|
||||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||||
let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?;
|
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() {
|
let relation = if request.ticket_assignment.is_some() {
|
||||||
"assigned"
|
"assigned"
|
||||||
} else {
|
} else {
|
||||||
@@ -9261,9 +9305,8 @@ fn cleanup_working_directory_for_runtime(
|
|||||||
result.diagnostics,
|
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;
|
let mut summary = working_directory.summary;
|
||||||
|
persist_workdir_cleanup_observation(&api, runtime_id, &summary)?;
|
||||||
apply_workdir_occupancy_projection(&api, &mut summary)?;
|
apply_workdir_occupancy_projection(&api, &mut summary)?;
|
||||||
Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
Ok(Json(BrowserWorkingDirectoryDetailResponse {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
@@ -14154,11 +14197,7 @@ fn sync_runtime_workdir_observations(
|
|||||||
api.store.upsert_workdir_registry(&updated)?;
|
api.store.upsert_workdir_registry(&updated)?;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
record.materialization_status =
|
persist_workdir_runtime_miss(api, record, result.diagnostics.as_slice())?;
|
||||||
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)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -14172,17 +14211,54 @@ fn sync_runtime_workdir_observations(
|
|||||||
Ok(response.diagnostics)
|
Ok(response.diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workdir_status_from_runtime_miss(diagnostics: &[RuntimeDiagnostic]) -> &'static str {
|
fn persist_workdir_cleanup_observation(
|
||||||
if diagnostics
|
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()
|
.iter()
|
||||||
.any(|diagnostic| diagnostic.code == "working_directory_not_found")
|
.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"
|
"not_found"
|
||||||
} else {
|
} else {
|
||||||
"unknown"
|
"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> {
|
fn sync_all_runtime_workdir_observations(api: &WorkspaceApi) -> Vec<RuntimeDiagnostic> {
|
||||||
let mut diagnostics = Vec::new();
|
let mut diagnostics = Vec::new();
|
||||||
let runtimes = api.runtime.list_runtimes(api.config.max_records.min(200));
|
let runtimes = api.runtime.list_runtimes(api.config.max_records.min(200));
|
||||||
@@ -16980,22 +17056,24 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workdir_runtime_miss_uses_exact_typed_code() {
|
fn workdir_runtime_miss_uses_exact_typed_code() {
|
||||||
|
let typed_not_found = [RuntimeDiagnostic {
|
||||||
|
code: "working_directory_not_found".to_string(),
|
||||||
|
severity: DiagnosticSeverity::Warning,
|
||||||
|
message: "missing".to_string(),
|
||||||
|
}];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
workdir_status_from_runtime_miss(&[RuntimeDiagnostic {
|
workdir_status_from_runtime_miss(&typed_not_found),
|
||||||
code: "working_directory_not_found".to_string(),
|
|
||||||
severity: DiagnosticSeverity::Warning,
|
|
||||||
message: "missing".to_string(),
|
|
||||||
}]),
|
|
||||||
"not_found"
|
"not_found"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert!(workdir_runtime_miss_is_not_found(&typed_not_found));
|
||||||
workdir_status_from_runtime_miss(&[RuntimeDiagnostic {
|
|
||||||
code: "some_other_not_found".to_string(),
|
let unrelated = [RuntimeDiagnostic {
|
||||||
severity: DiagnosticSeverity::Warning,
|
code: "some_other_not_found".to_string(),
|
||||||
message: "not a typed workdir miss".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 {
|
struct DeterministicExecutionBackend {
|
||||||
@@ -18243,16 +18321,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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";
|
const INTERNAL_ID: &str = "00001KZ9SR97B";
|
||||||
for current_state in ["queued", "inprogress"] {
|
let state_change = ticket_notification_content("T-429", "ready", "queued");
|
||||||
let content = ticket_notification_content("T-429", current_state);
|
assert_eq!(
|
||||||
assert_eq!(
|
state_change,
|
||||||
content,
|
"Ticket T-429 changed state from ready to queued. Reread the current Ticket before acting."
|
||||||
format!(
|
);
|
||||||
"Ticket T-429 changed to {current_state}. 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));
|
assert!(!content.contains(INTERNAL_ID));
|
||||||
for forbidden in [
|
for forbidden in [
|
||||||
"workspace_id",
|
"workspace_id",
|
||||||
@@ -18427,15 +18508,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let inputs = execution.take_inputs();
|
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());
|
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_eq!(recipient.worker_id.to_string(), orchestrator.worker_id);
|
||||||
assert!(!content.contains(&ticket.id));
|
assert!(!content.contains(&ticket.id));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
content,
|
content,
|
||||||
&ticket_notification_content(
|
&ticket_notification_content(
|
||||||
ticket.resource_key.as_deref().unwrap(),
|
ticket.resource_key.as_deref().unwrap(),
|
||||||
|
previous_state,
|
||||||
current_state,
|
current_state,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -19554,6 +19643,7 @@ mod tests {
|
|||||||
notifications[0].1,
|
notifications[0].1,
|
||||||
ticket_notification_content(
|
ticket_notification_content(
|
||||||
ticket_ref.resource_key.as_deref().unwrap(),
|
ticket_ref.resource_key.as_deref().unwrap(),
|
||||||
|
TicketWorkflowState::Queued.as_str(),
|
||||||
TicketWorkflowState::Queued.as_str()
|
TicketWorkflowState::Queued.as_str()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -20186,6 +20276,25 @@ mod tests {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
ticket_id
|
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 {
|
let path = || ScopedRecordPath {
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
id: ticket_id.clone(),
|
id: ticket_id.clone(),
|
||||||
@@ -21308,6 +21417,87 @@ mod tests {
|
|||||||
.unwrap();
|
.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) {
|
fn seed_cleanup_link(api: &WorkspaceApi, runtime_worker_id: &str, workdir_id: &str) {
|
||||||
let runtime_worker_id = runtime_worker_id.parse::<u64>().unwrap();
|
let runtime_worker_id = runtime_worker_id.parse::<u64>().unwrap();
|
||||||
api.store
|
api.store
|
||||||
|
|||||||
+7
-56
@@ -22,7 +22,7 @@ use cli_connection::{
|
|||||||
};
|
};
|
||||||
use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login};
|
use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login};
|
||||||
use memory_lint::{LintCliOptions, LintStatus};
|
use memory_lint::{LintCliOptions, LintStatus};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use session_store::SegmentId;
|
use session_store::SegmentId;
|
||||||
use tui::{LaunchMode, LaunchOptions};
|
use tui::{LaunchMode, LaunchOptions};
|
||||||
|
|
||||||
@@ -1220,65 +1220,16 @@ async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| ParseError(error.to_string()))?;
|
.map_err(|error| ParseError(error.to_string()))?;
|
||||||
save_backend_token(backend_url, &token)?;
|
let token_path = client::save_backend_token(backend_url, "Bearer", &token)
|
||||||
println!("Saved Backend API token for {backend_url}");
|
.map_err(|error| ParseError(error.to_string()))?;
|
||||||
Ok(())
|
println!(
|
||||||
}
|
"Saved Backend API token for {} in {}",
|
||||||
|
client::BackendOrigin::parse(backend_url).map_err(|error| ParseError(error.to_string()))?,
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
token_path.display()
|
||||||
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(),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
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(())
|
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> {
|
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
|
||||||
let Some((subcommand, rest)) = args.split_first() else {
|
let Some((subcommand, rest)) = args.split_first() else {
|
||||||
return Err(ParseError(
|
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:
|
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;
|
- 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;
|
- 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;
|
- 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; };
|
web = { enabled = true; };
|
||||||
sub_worker = { enabled = true; };
|
sub_worker = { enabled = true; };
|
||||||
flow = { enabled = true; };
|
flow = { enabled = true; };
|
||||||
worker = { enabled = true; };
|
|
||||||
ticket = { enabled = true; thread = true; };
|
ticket = { enabled = true; thread = true; };
|
||||||
merge_request = {
|
merge_request = {
|
||||||
show = true;
|
show = true;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import "./base.dcdl" // {
|
|||||||
memory = { enabled = true; };
|
memory = { enabled = true; };
|
||||||
web = { enabled = true; };
|
web = { enabled = true; };
|
||||||
sub_worker = { enabled = true; };
|
sub_worker = { enabled = true; };
|
||||||
worker = { enabled = true; };
|
|
||||||
manage_workdir = { enabled = true; };
|
manage_workdir = { enabled = true; };
|
||||||
ticket = { enabled = true; authoring = true; thread = true; };
|
ticket = { enabled = true; authoring = true; thread = true; };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"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",
|
"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",
|
"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",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"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:@codemirror/view@6.43.8": "6.43.8",
|
||||||
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
|
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
|
||||||
"npm:@lezer/highlight@1.2.3": "1.2.3",
|
"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/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_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___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",
|
"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:clsx@2.1.1": "2.1.1",
|
||||||
"npm:cookie@0.6.0": "0.6.0",
|
"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",
|
"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-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3",
|
||||||
"npm:svelte@5.45.6": "5.45.6",
|
"npm:svelte@5.45.6": "5.45.6",
|
||||||
"npm:typescript@5.9.3": "5.9.3",
|
"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": {
|
"jsr": {
|
||||||
"@std/assert@1.0.19": {
|
"@std/assert@1.0.19": {
|
||||||
@@ -433,13 +434,13 @@
|
|||||||
"acorn"
|
"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==",
|
"integrity": "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@sveltejs/kit"
|
"@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==",
|
"integrity": "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@standard-schema/spec",
|
"@standard-schema/spec",
|
||||||
@@ -465,7 +466,7 @@
|
|||||||
],
|
],
|
||||||
"bin": true
|
"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==",
|
"integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@sveltejs/vite-plugin-svelte",
|
"@sveltejs/vite-plugin-svelte",
|
||||||
@@ -474,7 +475,7 @@
|
|||||||
"vite"
|
"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==",
|
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@sveltejs/vite-plugin-svelte-inspector",
|
"@sveltejs/vite-plugin-svelte-inspector",
|
||||||
@@ -966,7 +967,7 @@
|
|||||||
"vfile-message"
|
"vfile-message"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"vite@7.2.7": {
|
"vite@7.2.7_yaml@2.9.0": {
|
||||||
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
@@ -974,14 +975,18 @@
|
|||||||
"picomatch",
|
"picomatch",
|
||||||
"postcss",
|
"postcss",
|
||||||
"rollup",
|
"rollup",
|
||||||
"tinyglobby"
|
"tinyglobby",
|
||||||
|
"yaml"
|
||||||
],
|
],
|
||||||
"optionalDependencies": [
|
"optionalDependencies": [
|
||||||
"fsevents"
|
"fsevents"
|
||||||
],
|
],
|
||||||
|
"optionalPeers": [
|
||||||
|
"yaml"
|
||||||
|
],
|
||||||
"bin": true
|
"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==",
|
"integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"vite"
|
"vite"
|
||||||
@@ -993,6 +998,10 @@
|
|||||||
"w3c-keyname@2.2.8": {
|
"w3c-keyname@2.2.8": {
|
||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
|
||||||
},
|
},
|
||||||
|
"yaml@2.9.0": {
|
||||||
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
|
"bin": true
|
||||||
|
},
|
||||||
"zimmerframe@1.1.4": {
|
"zimmerframe@1.1.4": {
|
||||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
|
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
|
||||||
},
|
},
|
||||||
@@ -1024,7 +1033,8 @@
|
|||||||
"packageJson": {
|
"packageJson": {
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"npm:@fontsource/ibm-plex-mono@5.3.0",
|
"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",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/ibm-plex-mono": "5.3.0",
|
"@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 { item }: Props = $props();
|
||||||
|
let detailOpen = $state(false);
|
||||||
let nowMs = $state(Date.now());
|
let nowMs = $state(Date.now());
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -55,22 +56,20 @@
|
|||||||
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
|
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
|
function toolLabel(line: ConsoleLine): string {
|
||||||
const [firstLine = '', ...rest] = line.body.split('\n');
|
return line.toolCallLabel ?? line.toolCall?.name ?? line.title;
|
||||||
const [label, suffix = ''] = firstLine.split(' — ', 2);
|
}
|
||||||
return {
|
|
||||||
label,
|
function toolStatus(line: ConsoleLine): string {
|
||||||
suffix,
|
return line.toolStatus ?? line.toolCall?.state ?? '';
|
||||||
rest: rest.join('\n')
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldRenderMarkdown(line: ConsoleLine): boolean {
|
function shouldRenderMarkdown(line: ConsoleLine): boolean {
|
||||||
return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system';
|
return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system';
|
||||||
}
|
}
|
||||||
|
|
||||||
function bodyTextAfterToolSummary(line: ConsoleLine): string {
|
function toolBodyText(line: ConsoleLine): string {
|
||||||
return toolSummary(line).rest;
|
return detailOpen ? (line.expandedBody ?? line.body) : line.body;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -107,20 +106,27 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if item.kind === 'tool'}
|
{:else if item.kind === 'tool'}
|
||||||
<div class="tool-summary">
|
<div class="tool-summary">
|
||||||
<span class="tool-label">{toolSummary(item).label}</span>
|
<span class="tool-label">{toolLabel(item)}</span>
|
||||||
<span class="tool-separator"> — </span>
|
<span class={`tool-status ${item.toolCall?.state ?? ''}`}>{toolStatus(item)}</span>
|
||||||
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
|
{#if item.detail}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="tool-detail-button"
|
||||||
|
aria-expanded={detailOpen}
|
||||||
|
onclick={() => (detailOpen = !detailOpen)}
|
||||||
|
>detail</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.compaction}
|
{#if item.compaction}
|
||||||
<!-- rendered as one lifecycle item above -->
|
<!-- rendered as one lifecycle item above -->
|
||||||
{:else if item.kind === 'tool'}
|
{:else if item.kind === 'tool'}
|
||||||
{#if bodyTextAfterToolSummary(item)}
|
{#if toolBodyText(item)}
|
||||||
<p class="console-plain-text">
|
<p class="console-plain-text">
|
||||||
{#if isBashTool(item)}
|
{#if isBashTool(item)}
|
||||||
<AnsiText text={bodyTextAfterToolSummary(item)} />
|
<AnsiText text={toolBodyText(item)} />
|
||||||
{:else}
|
{:else}
|
||||||
{bodyTextAfterToolSummary(item)}
|
{toolBodyText(item)}
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -147,11 +153,10 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if item.detail}
|
{#if item.detail && detailOpen}
|
||||||
<details class="message-detail">
|
<div class="message-detail" role="region" aria-label={`${toolLabel(item)} detail`}>
|
||||||
<summary>detail</summary>
|
|
||||||
<p>{item.detail}</p>
|
<p>{item.detail}</p>
|
||||||
</details>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -306,48 +311,46 @@
|
|||||||
.tool-summary {
|
.tool-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 0;
|
gap: 0.5rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-label {
|
.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;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow: hidden;
|
||||||
|
color: var(--tui-cyan);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-separator,
|
.tool-status {
|
||||||
.tool-suffix {
|
flex: 0 0 auto;
|
||||||
color: var(--tui-dark-gray);
|
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);
|
color: var(--tui-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-state-running .tool-suffix,
|
.tool-state-running .tool-status,
|
||||||
.tool-state-streaming_args .tool-suffix,
|
.tool-state-streaming_args .tool-status,
|
||||||
.tool-state-pending .tool-suffix {
|
.tool-state-pending .tool-status {
|
||||||
color: var(--tui-yellow);
|
color: var(--tui-yellow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-state-done .tool-suffix {
|
.tool-state-done .tool-status {
|
||||||
color: var(--tui-dark-gray);
|
color: var(--tui-dark-gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.console-line.error-line .tool-status {
|
||||||
|
color: var(--tui-red);
|
||||||
|
}
|
||||||
|
|
||||||
.message-heading {
|
.message-heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -412,13 +415,47 @@
|
|||||||
color: var(--code);
|
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 {
|
.message-detail {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
border-left: 2px solid var(--line);
|
||||||
|
padding-left: 0.6rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-detail summary {
|
.message-detail p {
|
||||||
cursor: pointer;
|
margin: 0;
|
||||||
font-weight: 800;
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.tool-detail-button {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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", () => {
|
Deno.test("workerConsoleHref encodes runtime and worker target authority", () => {
|
||||||
assert(
|
assert(
|
||||||
workerConsoleHref({
|
workerConsoleHref({
|
||||||
@@ -313,26 +415,25 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
|
|||||||
!toolLines[0].streaming,
|
!toolLines[0].streaming,
|
||||||
"completed tool call should not remain streaming",
|
"completed tool call should not remain streaming",
|
||||||
);
|
);
|
||||||
assert(
|
assertEquals(toolLines[0].toolCallLabel, "Bash($ pwd)");
|
||||||
toolLines[0].body.includes("$ pwd"),
|
assertEquals(toolLines[0].toolStatus, "done");
|
||||||
"Bash command should be summarized",
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("/repo"),
|
toolLines[0].body.includes("/repo"),
|
||||||
"tool result should be folded into the Call block",
|
"tool result should be folded into the Call block",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("line9"),
|
toolLines[0].body.includes("line9"),
|
||||||
"Bash result preview should include the ninth output line",
|
"Bash preview should include the ninth output line",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!toolLines[0].body.includes("line10") &&
|
!toolLines[0].body.includes("line10") &&
|
||||||
!toolLines[0].body.includes("line12"),
|
toolLines[0].body.includes("… +3 more lines"),
|
||||||
"Bash result preview should be capped at ten display lines",
|
"Bash preview should retain its line cap",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("… +3 more lines"),
|
toolLines[0].expandedBody?.includes("line12") === true &&
|
||||||
"Bash result preview should show omitted output count",
|
!toolLines[0].expandedBody?.includes("more lines"),
|
||||||
|
"Bash detail should show every returned output line",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].detail?.includes("id: call-1"),
|
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");
|
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("elapsed"), line.body);
|
||||||
assert(!line.body.includes("stdout:"), line.body);
|
assert(!line.body.includes("stdout:"), line.body);
|
||||||
assert(line.body.includes("ready\n"), 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 projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
|
||||||
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
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("elapsed"), line.body);
|
||||||
assert(!line.body.includes("stdout:"), line.body);
|
assert(!line.body.includes("stdout:"), line.body);
|
||||||
assert(line.body.includes("[… earlier stdout omitted]\ntail\n"), 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);
|
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([
|
const projection = projectConsole([
|
||||||
{
|
{
|
||||||
eventId: "70",
|
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");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assertEquals(line.title, "Call · CustomTool");
|
assertEquals(line.title, "Call · CustomTool");
|
||||||
assertEquals(line.body.split("\n").length, 7);
|
assertEquals(line.toolCallLabel, 'CustomTool("first":"one","second":"two","third":"three","fourth":"four")');
|
||||||
assert(line.body.includes("CustomTool — done"), "tool state should be shown");
|
assertEquals(line.toolStatus, "done");
|
||||||
|
assertEquals(line.body.split("\n").length, 3);
|
||||||
assert(
|
assert(
|
||||||
line.body.includes('"first": "one"'),
|
line.body.includes("out1") && line.body.includes("… +3 more lines"),
|
||||||
"request preview should be shown",
|
"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([
|
const projection = projectConsole([
|
||||||
{
|
{
|
||||||
eventId: "72",
|
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");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assertEquals(line.title, "Call · Grep");
|
assertEquals(line.title, "Call · Grep");
|
||||||
assert(
|
assertEquals(line.toolCallLabel, "Grep(needle)");
|
||||||
line.body.includes("Grep — 6 matches"),
|
assertEquals(line.toolStatus, "done");
|
||||||
"Grep summary should be shown",
|
|
||||||
);
|
|
||||||
assert(line.body.includes("query: needle"), "Grep query should be shown");
|
|
||||||
assert(line.body.includes("hit1"), "first result should be shown");
|
assert(line.body.includes("hit1"), "first result should be shown");
|
||||||
assert(line.body.includes("hit5"), "fifth 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(
|
assert(
|
||||||
line.body.includes("… +1 more results"),
|
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");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assertEquals(line.title, "Call · Grep");
|
assertEquals(line.title, "Call · Grep");
|
||||||
assert(
|
assertEquals(line.toolCallLabel, "Grep(needle)");
|
||||||
line.body.includes("Grep — Failed"),
|
assertEquals(line.toolStatus, "error");
|
||||||
"error suffix should stay short",
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
line.body.includes(message),
|
line.body.includes(message),
|
||||||
"error detail should remain visible in the body",
|
"error detail should remain visible in the body",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!line.body.includes(`Grep — ${message}`),
|
!line.toolCallLabel?.includes(message),
|
||||||
"error detail should not be repeated in the suffix",
|
"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.length, 1);
|
||||||
assertEquals(toolLines[0].title, "Call · Read");
|
assertEquals(toolLines[0].title, "Call · Read");
|
||||||
assert(toolLines[0].streaming, "streaming tool call should remain streaming");
|
assert(toolLines[0].streaming, "streaming tool call should remain streaming");
|
||||||
|
assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
|
||||||
|
assertEquals(toolLines[0].toolStatus, "reading…");
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("/tmp/a.md") &&
|
toolLines[0].body.includes("/tmp/a.md"),
|
||||||
toolLines[0].body.includes("Read — reading"),
|
|
||||||
"Read call should render aggregate progress and path without content",
|
"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");
|
const toolLines = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assertEquals(toolLines.length, 1);
|
assertEquals(toolLines.length, 1);
|
||||||
assertEquals(toolLines[0].title, "Call · Read");
|
assertEquals(toolLines[0].title, "Call · Read");
|
||||||
assert(
|
assertEquals(toolLines[0].toolCallLabel, "Read(2 files)");
|
||||||
toolLines[0].body.includes("Read — 2 files read"),
|
assertEquals(toolLines[0].toolStatus, "done");
|
||||||
"aggregate count should be shown",
|
|
||||||
);
|
|
||||||
assert(
|
assert(
|
||||||
toolLines[0].body.includes("/tmp/a.md"),
|
toolLines[0].body.includes("/tmp/a.md"),
|
||||||
"first path should be listed",
|
"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");
|
const [line] = projection.lines.filter((line) => line.kind === "tool");
|
||||||
assertEquals(line.title, "Call · Edit");
|
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), [
|
assertEquals(line.diff?.map((row) => row.kind), [
|
||||||
"context",
|
"context",
|
||||||
"remove",
|
"remove",
|
||||||
@@ -1242,13 +1427,13 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
|
|||||||
assertEquals(projection.status, "running");
|
assertEquals(projection.status, "running");
|
||||||
assertEquals(
|
assertEquals(
|
||||||
projection.lines.map((line) =>
|
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:seed user:false",
|
||||||
"user:new user:false",
|
"user:new user:false",
|
||||||
"assistant:assistant reply: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",
|
"status:Compacting…:true",
|
||||||
"in_flight:partial: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((
|
const toolLines = projection.lines.filter((line) => line.kind === "tool");
|
||||||
line,
|
const bodies = toolLines.map((line) => line.body);
|
||||||
) => line.body);
|
assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
|
||||||
assertEquals(bodies[0], "Read — 1 file read\n src/main.rs");
|
assertEquals(bodies[0], " src/main.rs");
|
||||||
assert(
|
assert(
|
||||||
projection.lines[0].detail?.includes("from src/main.rs"),
|
projection.lines[0].detail?.includes("from src/main.rs"),
|
||||||
"Read summary detail path should be relative",
|
"Read summary detail path should be relative",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
bodies.some((body) =>
|
toolLines.some((line) =>
|
||||||
body.includes("Write — out.txt") && body.includes("Wrote out.txt")
|
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(
|
assert(
|
||||||
bodies.some((body) =>
|
toolLines.some((line) =>
|
||||||
body.includes("Edit — src/main.rs") && body.includes("Edited src/main.rs")
|
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(
|
assert(
|
||||||
bodies.some((body) =>
|
bodies.some((body) =>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
InternalWorkerSnapshot,
|
InternalWorkerSnapshot,
|
||||||
Segment,
|
Segment,
|
||||||
} from "$lib/generated/protocol";
|
} from "$lib/generated/protocol";
|
||||||
|
import { stringify as stringifyYaml } from "yaml";
|
||||||
import { workspaceRoute } from "$lib/workspace/api/http";
|
import { workspaceRoute } from "$lib/workspace/api/http";
|
||||||
import {
|
import {
|
||||||
applyRunActivityEvent,
|
applyRunActivityEvent,
|
||||||
@@ -86,6 +87,9 @@ export type ConsoleLine = {
|
|||||||
kind: ConsoleLineKind;
|
kind: ConsoleLineKind;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
expandedBody?: string;
|
||||||
|
toolCallLabel?: string;
|
||||||
|
toolStatus?: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
compaction?: ConsoleCompaction;
|
compaction?: ConsoleCompaction;
|
||||||
diff?: ConsoleDiffLine[];
|
diff?: ConsoleDiffLine[];
|
||||||
@@ -1376,7 +1380,10 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
|
|||||||
title: item.title.startsWith("Call · Tool result")
|
title: item.title.startsWith("Call · Tool result")
|
||||||
? item.title
|
? item.title
|
||||||
: `Call · ${toolCall.name}`,
|
: `Call · ${toolCall.name}`,
|
||||||
body: renderToolCall(toolCall),
|
body: renderToolResponse(toolCall),
|
||||||
|
expandedBody: renderToolResponse(toolCall, true),
|
||||||
|
toolCallLabel: toolCallSignature(toolCall),
|
||||||
|
toolStatus: toolCallStatus(toolCall),
|
||||||
detail: toolCallDetail(toolCall),
|
detail: toolCallDetail(toolCall),
|
||||||
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
|
||||||
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
|
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) {
|
switch (toolCall.name) {
|
||||||
case "Read":
|
case "Read":
|
||||||
return renderReadTool(toolCall);
|
return renderReadTool(toolCall);
|
||||||
@@ -1395,14 +1402,54 @@ function renderToolCall(toolCall: ToolCallView): string {
|
|||||||
case "Glob":
|
case "Glob":
|
||||||
return renderSearchTool(toolCall);
|
return renderSearchTool(toolCall);
|
||||||
case "Grep":
|
case "Grep":
|
||||||
return renderGrepTool(toolCall);
|
return renderGrepTool(toolCall, expanded);
|
||||||
case "Bash":
|
case "Bash":
|
||||||
return renderBashTool(toolCall);
|
return renderBashTool(toolCall, expanded);
|
||||||
default:
|
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[] {
|
function aggregateReadToolLines(lines: ConsoleLine[]): ConsoleLine[] {
|
||||||
const result: ConsoleLine[] = [];
|
const result: ConsoleLine[] = [];
|
||||||
let index = 0;
|
let index = 0;
|
||||||
@@ -1434,9 +1481,6 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
|||||||
const paths = calls.map(readPath);
|
const paths = calls.map(readPath);
|
||||||
const visiblePaths = inProgress ? paths.slice(-3) : paths;
|
const visiblePaths = inProgress ? paths.slice(-3) : paths;
|
||||||
const body = compactLines([
|
const body = compactLines([
|
||||||
inProgress
|
|
||||||
? `Read — reading (${count} file${plural(count)}…)`
|
|
||||||
: `Read — ${count} file${plural(count)} read`,
|
|
||||||
visiblePaths.map((path) => ` ${path}`).join("\n"),
|
visiblePaths.map((path) => ` ${path}`).join("\n"),
|
||||||
inProgress && paths.length > visiblePaths.length
|
inProgress && paths.length > visiblePaths.length
|
||||||
? ` … (${paths.length - visiblePaths.length} earlier)`
|
? ` … (${paths.length - visiblePaths.length} earlier)`
|
||||||
@@ -1447,6 +1491,8 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
|
|||||||
kind: "tool",
|
kind: "tool",
|
||||||
title: "Call · Read",
|
title: "Call · Read",
|
||||||
body,
|
body,
|
||||||
|
toolCallLabel: `Read(${count} file${plural(count)})`,
|
||||||
|
toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done",
|
||||||
detail: calls.map(readDetail).join("\n\n"),
|
detail: calls.map(readDetail).join("\n\n"),
|
||||||
eventId: group.at(-1)?.eventId,
|
eventId: group.at(-1)?.eventId,
|
||||||
source: "event",
|
source: "event",
|
||||||
@@ -1483,32 +1529,16 @@ function readDetail(toolCall: ToolCallView): string {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderReadTool(toolCall: ToolCallView): string {
|
function renderReadTool(_toolCall: ToolCallView): string {
|
||||||
return `Read — ${readPath(toolCall)} (${stateSuffix(toolCall.state)})`;
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWriteTool(toolCall: ToolCallView): string {
|
function renderWriteTool(toolCall: ToolCallView): string {
|
||||||
const args = parsedArgs(toolCall);
|
return knownToolResultText(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),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderEditTool(toolCall: ToolCallView): string {
|
function renderEditTool(toolCall: ToolCallView): string {
|
||||||
const args = parsedArgs(toolCall);
|
return knownToolResultText(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),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function editDiff(toolCall: ToolCallView): ConsoleDiffLine[] | undefined {
|
function editDiff(toolCall: ToolCallView): ConsoleDiffLine[] | undefined {
|
||||||
@@ -1591,52 +1621,20 @@ function lcsTable(oldLines: string[], newLines: string[]): number[][] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSearchTool(toolCall: ToolCallView): string {
|
function renderSearchTool(toolCall: ToolCallView): string {
|
||||||
const summary = toolCall.summary?.trim();
|
return knownToolResultText(toolCall) ?? "";
|
||||||
return compactLines([
|
|
||||||
`${toolCall.name} — ${toolHeaderSuffix(toolCall, summary)}`,
|
|
||||||
knownToolResultText(toolCall),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGrepTool(toolCall: ToolCallView): string {
|
function renderGrepTool(toolCall: ToolCallView, expanded: boolean): string {
|
||||||
const summary = toolCall.summary?.trim();
|
const result = knownToolResultText(toolCall);
|
||||||
return compactLines([
|
return expanded ? result ?? "" : cappedResultSection(result, 5) ?? "";
|
||||||
`Grep — ${toolHeaderSuffix(toolCall, summary)}`,
|
|
||||||
grepQueryText(toolCall),
|
|
||||||
cappedResultSection(knownToolResultText(toolCall), 5),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolHeaderSuffix(
|
function renderBashTool(toolCall: ToolCallView, expanded: boolean): string {
|
||||||
toolCall: ToolCallView,
|
if (["done", "error"].includes(toolCall.state)) {
|
||||||
summary?: string,
|
const result = resultText(toolCall);
|
||||||
): string {
|
return expanded ? result ?? "" : cappedDisplaySection(result, 10) ?? "";
|
||||||
if (toolCall.state === "error") {
|
|
||||||
return "Failed";
|
|
||||||
}
|
}
|
||||||
return summary ? firstLine(summary) : stateSuffix(toolCall.state);
|
return renderLiveCommandOutput(toolCall.command) ?? "";
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function commandStateSuffix(toolCall: ToolCallView): string {
|
function commandStateSuffix(toolCall: ToolCallView): string {
|
||||||
@@ -1690,12 +1688,9 @@ function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDefaultTool(toolCall: ToolCallView): string {
|
function renderDefaultTool(toolCall: ToolCallView, expanded: boolean): string {
|
||||||
return compactLines([
|
const result = resultText(toolCall);
|
||||||
`${toolCall.name} — ${stateSuffix(toolCall.state)}`,
|
return expanded ? result ?? "" : cappedDisplaySection(result, 3) ?? "";
|
||||||
cappedDisplaySection(argsText(toolCall), 3),
|
|
||||||
cappedDisplaySection(resultText(toolCall), 3),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolCallDetail(toolCall: ToolCallView): string {
|
function toolCallDetail(toolCall: ToolCallView): string {
|
||||||
@@ -1713,10 +1708,30 @@ function toolCallDetail(toolCall: ToolCallView): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resultText(toolCall: ToolCallView): string | undefined {
|
function resultText(toolCall: ToolCallView): string | undefined {
|
||||||
if (toolCall.output) {
|
const text = toolCall.output || toolCall.summary;
|
||||||
return toolCall.output;
|
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 {
|
function knownToolResultText(toolCall: ToolCallView): string | undefined {
|
||||||
@@ -1803,7 +1818,7 @@ function argsText(toolCall: ToolCallView): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const parsed = parseJson(raw);
|
const parsed = parseJson(raw);
|
||||||
return parsed === undefined ? raw : jsonPreview(parsed);
|
return parsed === undefined ? raw : stringifyYaml(parsed).trimEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsedArgs(
|
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");
|
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(
|
function cappedDisplaySection(
|
||||||
value: string | undefined,
|
value: string | undefined,
|
||||||
maxLines: number,
|
maxLines: number,
|
||||||
@@ -1960,24 +1960,34 @@ function applyLogEntry(
|
|||||||
applyLoggedItem(projection, `${eventId}-history-${index}`, item)
|
applyLoggedItem(projection, `${eventId}-history-${index}`, item)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "user_input":
|
case "annotated_segment_start":
|
||||||
projection.lines.push(
|
arrayField(entry, "history").forEach((historyEntry, index) =>
|
||||||
line(
|
applyLoggedHistoryEntry(
|
||||||
eventId,
|
projection,
|
||||||
"user",
|
`${eventId}-history-${index}`,
|
||||||
"User",
|
historyEntry,
|
||||||
segmentsToText(arrayField(entry, "segments") as Segment[]),
|
)
|
||||||
),
|
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case "user_input":
|
||||||
|
case "annotated_user_input":
|
||||||
|
applyLoggedUserInput(projection, eventId, entry);
|
||||||
|
break;
|
||||||
case "system_item":
|
case "system_item":
|
||||||
projection.lines.push(systemItemLine(eventId, entry["item"]));
|
projection.lines.push(systemItemLine(eventId, entry["item"]));
|
||||||
applyTaskSystemItem(projection, entry["item"]);
|
applyTaskSystemItem(projection, entry["item"]);
|
||||||
break;
|
break;
|
||||||
|
case "annotated_system_item":
|
||||||
|
applyLoggedSystemEntry(projection, eventId, entry["entry"]);
|
||||||
|
break;
|
||||||
case "assistant_item":
|
case "assistant_item":
|
||||||
case "tool_result":
|
case "tool_result":
|
||||||
applyLoggedItem(projection, eventId, entry["item"]);
|
applyLoggedItem(projection, eventId, entry["item"]);
|
||||||
break;
|
break;
|
||||||
|
case "annotated_assistant_item":
|
||||||
|
case "annotated_tool_result":
|
||||||
|
applyLoggedHistoryEntry(projection, eventId, entry["entry"]);
|
||||||
|
break;
|
||||||
case "run_errored":
|
case "run_errored":
|
||||||
projection.lines.push(
|
projection.lines.push(
|
||||||
line(
|
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(
|
function applyLoggedItem(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
|
|||||||
@@ -251,7 +251,8 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
|||||||
ticketDetailLoad.includes("/repositories") &&
|
ticketDetailLoad.includes("/repositories") &&
|
||||||
ticketDetailPage.includes('mutate("state", "/state"') &&
|
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||||
ticketDetailPage.includes("async function queueTicket") &&
|
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("/merge-request/merge") &&
|
||||||
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
||||||
ticketDetailPage.includes("mergeRequest.review_status") &&
|
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("item.kind === 'tool'") &&
|
||||||
consoleLine.includes("{#if isBashTool(item)}") &&
|
consoleLine.includes("{#if isBashTool(item)}") &&
|
||||||
consoleLine.includes(
|
consoleLine.includes(
|
||||||
"<AnsiText text={bodyTextAfterToolSummary(item)} />",
|
"<AnsiText text={toolBodyText(item)} />",
|
||||||
) &&
|
) &&
|
||||||
consoleLine.includes(
|
consoleLine.includes(
|
||||||
".console-line.tool-bash .console-plain-text",
|
".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 () => {
|
Deno.test("Worker Console renders Edit diffs without preformatted template gaps", async () => {
|
||||||
const consoleLine = await Deno.readTextFile(
|
const consoleLine = await Deno.readTextFile(
|
||||||
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
new URL("./ConsoleLineItem.svelte", import.meta.url),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
const initialData = untrack(() => data);
|
const initialData = untrack(() => data);
|
||||||
const loadedTicket = initialData.ticket.data;
|
const loadedTicket = initialData.ticket.data;
|
||||||
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
|
||||||
const loadedRepositories = initialData.repositories.data;
|
const loadedRepositories = $derived(data.repositories.data);
|
||||||
|
|
||||||
type QueueOutcome = {
|
type QueueOutcome = {
|
||||||
requested_ticket: string;
|
requested_ticket: string;
|
||||||
@@ -62,6 +62,8 @@
|
|||||||
let manualRuntimeId = $state("");
|
let manualRuntimeId = $state("");
|
||||||
let manualWorkerId = $state("");
|
let manualWorkerId = $state("");
|
||||||
let cancellationReason = $state("");
|
let cancellationReason = $state("");
|
||||||
|
let routeTicketSnapshot = `${initialData.ticketId}:${loadedTicket.item_revision}`;
|
||||||
|
let routeGeneration = 0;
|
||||||
const coderAssignment = $derived(
|
const coderAssignment = $derived(
|
||||||
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
|
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
|
||||||
);
|
);
|
||||||
@@ -95,13 +97,43 @@
|
|||||||
|
|
||||||
function applyTicket(updatedTicket: TicketDetail): void {
|
function applyTicket(updatedTicket: TicketDetail): void {
|
||||||
ticket = updatedTicket;
|
ticket = updatedTicket;
|
||||||
editTitle = ticket.title;
|
editTitle = updatedTicket.title;
|
||||||
editBody = ticket.body;
|
editBody = updatedTicket.body;
|
||||||
repositoryId = ticket.repository_id ?? "";
|
repositoryId = updatedTicket.repository_id ?? "";
|
||||||
refSelector = ticket.ref_selector ?? "";
|
refSelector = updatedTicket.ref_selector ?? "";
|
||||||
nextState = ticket.state;
|
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(
|
async function mutate(
|
||||||
action: string,
|
action: string,
|
||||||
suffix: string,
|
suffix: string,
|
||||||
@@ -109,40 +141,51 @@
|
|||||||
method = "POST",
|
method = "POST",
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (busy) return false;
|
if (busy) return false;
|
||||||
|
const generation = routeGeneration;
|
||||||
|
const path = `${ticketPath}${suffix}`;
|
||||||
busy = action;
|
busy = action;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
try {
|
try {
|
||||||
const path = `${ticketPath}${suffix}`;
|
|
||||||
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
|
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
|
||||||
method,
|
method,
|
||||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||||
});
|
});
|
||||||
|
if (generation !== routeGeneration) return false;
|
||||||
applyTicket(response);
|
applyTicket(response);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage = error instanceof Error ? error.message : String(error);
|
if (generation === routeGeneration) {
|
||||||
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
busy = null;
|
if (generation === routeGeneration) busy = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queueTicket(): Promise<void> {
|
async function queueTicket(): Promise<void> {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
const generation = routeGeneration;
|
||||||
|
const path = ticketPath;
|
||||||
busy = "queue";
|
busy = "queue";
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
queueMessage = null;
|
queueMessage = null;
|
||||||
try {
|
try {
|
||||||
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
|
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
|
||||||
`${ticketPath}/queue`,
|
`${path}/queue`,
|
||||||
{ method: "POST", body: JSON.stringify({}) },
|
{ 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(", ")}`;
|
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
|
||||||
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
|
applyTicket(updatedTicket);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage = error instanceof Error ? error.message : String(error);
|
if (generation === routeGeneration) {
|
||||||
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
busy = null;
|
if (generation === routeGeneration) busy = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,11 +195,13 @@
|
|||||||
principal: Record<string, string>,
|
principal: Record<string, string>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
const generation = routeGeneration;
|
||||||
|
const path = ticketPath;
|
||||||
busy = action;
|
busy = action;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
try {
|
try {
|
||||||
await workspaceApiJsonWithBody(
|
await workspaceApiJsonWithBody(
|
||||||
`${ticketPath}/assignments/${role}`,
|
`${path}/assignments/${role}`,
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({
|
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) {
|
} catch (error) {
|
||||||
errorMessage = error instanceof Error ? error.message : String(error);
|
if (generation === routeGeneration) {
|
||||||
|
errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
} finally {
|
} 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