Author SHA1 Message Date
Hare e84a9d3f9b fix: synchronize Resume busy state from protocol events 2026-08-30 14:11:12 +09:00
Hare a7bf5ceac3 test: update client session snapshot fixture 2026-08-30 13:23:56 +09:00
Hare 74139aeb7e chore: merge develop into hare/develop 2026-08-30 13:19:24 +09:00
Hare 0cae4fd05c fix: retry device login code collisions 2026-08-30 12:55:12 +09:00
Hare adb684a6bf fix: preserve typed user input snapshots 2026-08-30 12:33:15 +09:00
Hare 8493472983 refactor: require annotated session log history 2026-08-30 12:18:44 +09:00
Hare ebb272324c Merge branch 'work/T-552-ticket-relation-projection' into hare/develop 2026-08-30 10:15:33 +09:00
Hare c0290512b3 fix: align Ticket relation projection 2026-08-30 10:15:23 +09:00
Hare 4ec56fe41e Merge remote-tracking branch 'origin/develop' into work/T-545-canonical-session-snapshot 2026-08-30 09:42:57 +09:00
Hare f8a7c46cf9 fix(session): preflight migration and retain timestamps 2026-08-30 09:42:46 +09:00
Hare 2bab8a9bb6 fix: distinguish Ticket activity notifications 2026-08-30 09:00:48 +09:00
Hare 89e6a6215a Merge remote-tracking branch 'origin/develop' into work/T-545-canonical-session-snapshot
# Conflicts:
#	web/workspace/src/lib/workspace/console/model.ts
2026-08-30 00:42:29 +09:00
Hare 88be87e03e feat(session): expose canonical public snapshots 2026-08-30 00:41:51 +09:00
Hare 32fdd076bf feat: authenticate Backend target requests 2026-08-29 23:41:55 +09:00
Hare 402ae0d466 fix: replay annotated history in Web Console 2026-08-29 23:10:32 +09:00
Hare acb3c6d68b fix: separate Ticket keys from assignment identity 2026-08-29 22:52:03 +09:00
Hare 40ac83e632 chore: merge develop into hare/develop 2026-08-29 13:25:32 +09:00
Hare 58da395941 fix: merge workdir registry cleanup 2026-08-29 13:21:40 +09:00
Hare 0e3ef94c9e feat: merge web console tool call presentation 2026-08-29 13:21:35 +09:00
Hare 1f68dfc2b5 fix: group grep content output by file 2026-08-28 02:05:12 +09:00
Hare 84977a464c feat: improve web console tool call presentation 2026-08-28 00:05:00 +09:00
Hare 8cc1dc042d fix: remove deleted workdirs from registry 2026-08-27 18:36:17 +09:00
Hare 651d64f34d Merge branch 'work/00001M10N3Z0H-ticket-detail-route-sync' into hare/develop 2026-08-27 12:57:52 +09:00
Hare b98d4b59f5 fix: synchronize reused ticket detail routes 2026-08-27 12:55:01 +09:00
Hare df6d99c07d Merge branch 'develop' into hare/develop 2026-08-27 09:21:58 +09:00
Hare 9843510e1f fix: remove conflicting coder worker control provider 2026-08-26 23:52:25 +09:00
Hare 83bda3dfb2 fix: restore companion subworker control 2026-08-26 22:06:34 +09:00
76 changed files with 4897 additions and 1569 deletions
Generated
+1
View File
@@ -637,6 +637,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
name = "client"
version = "0.1.0"
dependencies = [
"chrono",
"futures",
"manifest",
"protocol",
+1
View File
@@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true
[dependencies]
chrono = { version = "0.4", default-features = false, features = ["clock"] }
protocol = { workspace = true }
manifest = { workspace = true }
ticket = { workspace = true }
+768
View File
@@ -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 { .. })
));
}
}
+6 -3
View File
@@ -1,3 +1,4 @@
use crate::BackendOrigin;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::time::Duration;
@@ -9,9 +10,11 @@ pub struct BackendAuthTarget {
impl BackendAuthTarget {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
}
let base_url = base_url.into();
let base_url = BackendOrigin::parse(&base_url)
.map(|origin| origin.to_string())
.unwrap_or(base_url);
Self { base_url }
}
fn api_url(&self, path: &str) -> String {
+136 -51
View File
@@ -1,11 +1,16 @@
use crate::{BackendApiClient, BackendApiClientError};
use futures::{SinkExt, StreamExt};
use protocol::stream::{decode_event, encode_method};
use protocol::{ErrorCode, Event, Method};
use reqwest::Method as HttpMethod;
use std::collections::VecDeque;
use std::fmt;
use tokio::sync::mpsc;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
pub use workspace_api::{
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
@@ -113,6 +118,7 @@ pub struct BackendRuntimeClient {
#[derive(Debug)]
pub enum BackendRuntimeClientError {
InvalidTarget(String),
Api(BackendApiClientError),
Http(reqwest::Error),
}
@@ -120,6 +126,7 @@ impl fmt::Display for BackendRuntimeClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidTarget(message) => f.write_str(message),
Self::Api(error) => write!(f, "{error}"),
Self::Http(error) => write!(f, "{error}"),
}
}
@@ -127,6 +134,12 @@ impl fmt::Display for BackendRuntimeClientError {
impl std::error::Error for BackendRuntimeClientError {}
impl From<BackendApiClientError> for BackendRuntimeClientError {
fn from(error: BackendApiClientError) -> Self {
Self::Api(error)
}
}
impl From<reqwest::Error> for BackendRuntimeClientError {
fn from(error: reqwest::Error) -> Self {
Self::Http(error)
@@ -137,7 +150,7 @@ pub async fn list_backend_workers(
target: &BackendRuntimeListTarget,
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
validate_list_target(target)?;
let http = reqwest::Client::new();
let api = BackendApiClient::from_stored_token(&target.base_url)?;
if let Some(runtime_id) = target.runtime_id.as_deref() {
let path = backend_runtime_workers_path(
target
@@ -146,12 +159,9 @@ pub async fn list_backend_workers(
.expect("validated Backend Workspace scope"),
runtime_id,
);
let url = join_base_and_path(&target.base_url, &path);
return Ok(http
.get(url)
.send()
.await?
.error_for_status()?
let response = api.request(HttpMethod::GET, &path)?.send().await?;
api.check_status(response.status())?;
return Ok(response
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.await?);
}
@@ -162,12 +172,9 @@ pub async fn list_backend_workers(
.as_deref()
.expect("validated Backend Workspace scope"),
);
let runtime_url = join_base_and_path(&target.base_url, &runtime_path);
let runtimes = http
.get(runtime_url)
.send()
.await?
.error_for_status()?
let response = api.request(HttpMethod::GET, &runtime_path)?.send().await?;
api.check_status(response.status())?;
let runtimes = response
.json::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
.await?;
@@ -181,29 +188,43 @@ pub async fn list_backend_workers(
.expect("validated Backend Workspace scope"),
&runtime.runtime_id,
);
let url = join_base_and_path(&target.base_url, &path);
match http
.get(url)
.send()
.await
.and_then(|response| response.error_for_status())
{
Ok(response) => {
let response = response
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.await?;
diagnostics.extend(response.diagnostics);
items.extend(response.items);
let response = match api.request(HttpMethod::GET, &path)?.send().await {
Ok(response) => response,
Err(error) => {
diagnostics.push(BackendDiagnostic {
code: "runtime_worker_list_failed".to_string(),
severity: BackendDiagnosticSeverity::Error,
message: format!(
"failed to list workers for runtime {}: {error}",
runtime.runtime_id
),
});
continue;
}
Err(error) => diagnostics.push(BackendDiagnostic {
};
if matches!(
response.status(),
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
) {
api.check_status(response.status())?;
}
if !response.status().is_success() {
diagnostics.push(BackendDiagnostic {
code: "runtime_worker_list_failed".to_string(),
severity: BackendDiagnosticSeverity::Error,
message: format!(
"failed to list workers for runtime {}: {error}",
runtime.runtime_id
"failed to list workers for runtime {}: Backend returned HTTP {}",
runtime.runtime_id,
response.status().as_u16()
),
}),
});
continue;
}
let response = response
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.await?;
diagnostics.extend(response.diagnostics);
items.extend(response.items);
}
Ok(BackendRuntimeListResponse {
@@ -224,7 +245,7 @@ pub async fn list_backend_stopped_workers(
"stopped worker listing requires a runtime id".to_string(),
));
};
let http = reqwest::Client::new();
let api = BackendApiClient::from_stored_token(&target.base_url)?;
let path = backend_runtime_workers_path(
target
.workspace_id
@@ -232,12 +253,12 @@ pub async fn list_backend_stopped_workers(
.expect("validated Backend Workspace scope"),
runtime_id,
);
let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped"));
Ok(http
.get(url)
let response = api
.request(HttpMethod::GET, &format!("{path}?status=stopped"))?
.send()
.await?
.error_for_status()?
.await?;
api.check_status(response.status())?;
Ok(response
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.await?)
}
@@ -246,33 +267,33 @@ pub async fn restore_backend_worker(
target: &BackendRuntimeTarget,
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
validate_target(target)?;
let http = reqwest::Client::new();
let api = BackendApiClient::from_stored_token(&target.base_url)?;
let path = backend_runtime_worker_restore_path(
&target.workspace_id,
&target.runtime_id,
&target.worker_id,
);
let url = join_base_and_path(&target.base_url, &path);
Ok(http
.post(url)
let response = api
.request(HttpMethod::POST, &path)?
.json(&serde_json::json!({}))
.send()
.await?
.error_for_status()?
.json::<BackendWorkerRestoreResponse>()
.await?)
.await?;
api.check_status(response.status())?;
Ok(response.json::<BackendWorkerRestoreResponse>().await?)
}
impl BackendRuntimeClient {
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
validate_target(&target)?;
let api = BackendApiClient::from_stored_token(&target.base_url)?;
let (event_tx, rx) = mpsc::unbounded_channel();
let (command_tx, command_rx) = mpsc::unbounded_channel();
let protocol_target = target.clone();
let protocol_event_tx = event_tx.clone();
let protocol_task = tokio::spawn(async move {
run_worker_protocol_transport(protocol_target, command_rx, protocol_event_tx).await;
run_worker_protocol_transport(protocol_target, api, command_rx, protocol_event_tx)
.await;
});
Ok(Self {
@@ -317,11 +338,21 @@ impl Drop for BackendRuntimeClient {
async fn run_worker_protocol_transport(
target: BackendRuntimeTarget,
api: BackendApiClient,
mut commands: mpsc::UnboundedReceiver<Method>,
tx: mpsc::UnboundedSender<Event>,
) {
let url = protocol_ws_url(&target);
match connect_async(&url).await {
let request = match protocol_ws_request(&target, &api) {
Ok(request) => request,
Err(error) => {
let _ = tx.send(diagnostic_event(format!(
"Backend protocol request could not be constructed for {}: {error}",
target.display_label()
)));
return;
}
};
match connect_async(request).await {
Ok((ws, _)) => {
let (mut sink, mut stream) = ws.split();
loop {
@@ -387,10 +418,8 @@ async fn run_worker_protocol_transport(
}
}
Err(error) => {
let _ = tx.send(diagnostic_event(format!(
"Backend protocol WebSocket connect failed for {}: {error}",
target.display_label()
)));
let message = protocol_connect_error_message(&target, &api, &error);
let _ = tx.send(diagnostic_event(message));
while commands.recv().await.is_some() {
let _ = tx.send(diagnostic_event(format!(
"Backend protocol command was not sent because command stream is unavailable for {}",
@@ -401,6 +430,29 @@ async fn run_worker_protocol_transport(
}
}
fn protocol_connect_error_message(
target: &BackendRuntimeTarget,
api: &BackendApiClient,
error: &tokio_tungstenite::tungstenite::Error,
) -> String {
if let tokio_tungstenite::tungstenite::Error::Http(response) = error {
if let Ok(status) = reqwest::StatusCode::from_u16(response.status().as_u16()) {
if matches!(
status,
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
) {
if let Err(error) = api.check_status(status) {
return error.to_string();
}
}
}
}
format!(
"Backend protocol WebSocket connect failed for {}: {error}",
target.display_label()
)
}
fn diagnostic_event(message: impl Into<String>) -> Event {
Event::Error {
code: ErrorCode::Internal,
@@ -496,6 +548,19 @@ fn backend_runtime_worker_restore_path(
)
}
fn protocol_ws_request(
target: &BackendRuntimeTarget,
api: &BackendApiClient,
) -> Result<tokio_tungstenite::tungstenite::http::Request<()>, String> {
let mut request = protocol_ws_url(target)
.into_client_request()
.map_err(|error| error.to_string())?;
let value = HeaderValue::from_str(&api.authorization_header_value())
.map_err(|_| "saved Backend token is not a valid Authorization header".to_string())?;
request.headers_mut().insert(AUTHORIZATION, value);
Ok(request)
}
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
let path = format!(
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
@@ -557,6 +622,26 @@ mod tests {
);
}
#[test]
fn protocol_request_attaches_saved_bearer_authorization() {
let target = BackendRuntimeTarget::new(
"http://127.0.0.1:8787/",
"workspace alpha",
"runtime/one",
"worker one",
);
let api = BackendApiClient::from_access_token_for_test(
"http://127.0.0.1:8787",
"websocket-secret",
)
.unwrap();
let request = protocol_ws_request(&target, &api).unwrap();
assert_eq!(
request.headers().get(AUTHORIZATION).unwrap(),
"Bearer websocket-secret"
);
}
#[test]
fn backend_worker_summary_decodes_current_occupied_workdir_contract() {
let payload = serde_json::json!({
+60 -38
View File
@@ -1,3 +1,5 @@
use crate::{BackendApiClient, BackendApiClientError};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use std::fmt;
use workspace_api::{RepositoryObservedStatus, RepositorySource};
@@ -70,7 +72,7 @@ impl BackendWorkspaceCatalogTarget {
#[derive(Debug)]
pub enum BackendWorkspaceClientError {
InvalidTarget(String),
RequestFailed { status: u16, message: String },
Api(BackendApiClientError),
Http(reqwest::Error),
}
@@ -78,9 +80,7 @@ impl fmt::Display for BackendWorkspaceClientError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidTarget(message) => f.write_str(message),
Self::RequestFailed { status, message } => {
write!(f, "Backend request failed with HTTP {status}: {message}")
}
Self::Api(error) => write!(f, "{error}"),
Self::Http(error) => write!(f, "{error}"),
}
}
@@ -88,6 +88,12 @@ impl fmt::Display for BackendWorkspaceClientError {
impl std::error::Error for BackendWorkspaceClientError {}
impl From<BackendApiClientError> for BackendWorkspaceClientError {
fn from(error: BackendApiClientError) -> Self {
Self::Api(error)
}
}
impl From<reqwest::Error> for BackendWorkspaceClientError {
fn from(error: reqwest::Error) -> Self {
Self::Http(error)
@@ -97,13 +103,21 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
pub async fn list_backend_workspaces(
target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
validate_target(target)?;
let url = format!(
"{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}",
target.base_url.trim_end_matches('/')
);
let response = reqwest::Client::new().get(url).send().await?;
let response = require_success(response).await?;
let client = BackendApiClient::from_stored_token(&target.base_url)?;
list_backend_workspaces_with_client(&client).await
}
async fn list_backend_workspaces_with_client(
client: &BackendApiClient,
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
let response = client
.request(
Method::GET,
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
)?
.send()
.await?;
client.check_status(response.status())?;
Ok(response.json::<Vec<BackendWorkspace>>().await?)
}
@@ -111,42 +125,50 @@ pub async fn create_backend_workspace(
target: &BackendWorkspaceCatalogTarget,
request: &CreateBackendWorkspaceRequest,
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
validate_target(target)?;
let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/'));
let response = reqwest::Client::new()
.post(url)
let client = BackendApiClient::from_stored_token(&target.base_url)?;
let response = client
.request(Method::POST, "/api/workspaces")?
.json(request)
.send()
.await?;
let response = require_success(response).await?;
client.check_status(response.status())?;
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
}
async fn require_success(
response: reqwest::Response,
) -> Result<reqwest::Response, BackendWorkspaceClientError> {
if response.status().is_success() {
return Ok(response);
}
let status = response.status().as_u16();
let message = response.text().await.unwrap_or_default();
Err(BackendWorkspaceClientError::RequestFailed { status, message })
}
fn validate_target(
target: &BackendWorkspaceCatalogTarget,
) -> Result<(), BackendWorkspaceClientError> {
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
return Err(BackendWorkspaceClientError::InvalidTarget(
"Backend API base URL must start with http:// or https://".to_string(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
#[tokio::test]
async fn workspace_catalog_request_uses_shared_bearer_client() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = vec![0; 4096];
let read = stream.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
assert!(request.starts_with("get /api/workspaces?limit=200 "));
assert!(request.contains("authorization: bearer catalog-secret\r\n"));
stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
)
.unwrap();
});
let client =
BackendApiClient::from_access_token_for_test(&base_url, "catalog-secret").unwrap();
assert!(
list_backend_workspaces_with_client(&client)
.await
.unwrap()
.is_empty()
);
handle.join().unwrap();
}
#[test]
fn create_request_keeps_operation_key_for_exact_retry() {
+6 -1
View File
@@ -8,7 +8,8 @@
//!
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
pub mod backend_auth;
pub mod backend_api;
mod backend_auth;
pub mod backend_runtime;
pub mod backend_workspace;
pub mod runtime_command;
@@ -18,6 +19,10 @@ pub mod ticket_role;
mod worker_client;
mod workspace_product;
pub use backend_api::{
BackendApiClient, BackendApiClientError, BackendOrigin, backend_token_file_path,
save_backend_token,
};
pub use backend_auth::{
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
poll_device_login, start_device_login, wait_for_device_login,
+13 -2
View File
@@ -1,6 +1,9 @@
use std::fmt;
use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
use crate::{
BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget,
BackendRuntimeTarget, WorkerRuntimeCommand,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetKind {
@@ -62,11 +65,19 @@ pub struct BackendTarget {
impl BackendTarget {
pub fn new(base_url: impl Into<String>, workspace_id: Option<impl Into<String>>) -> Self {
let base_url = base_url.into();
let base_url = BackendOrigin::parse(&base_url)
.map(|origin| origin.to_string())
.unwrap_or(base_url);
Self {
base_url: base_url.into(),
base_url,
workspace_id: workspace_id.map(Into::into),
}
}
pub fn authenticated_client(&self) -> Result<BackendApiClient, BackendApiClientError> {
BackendApiClient::from_stored_token(&self.base_url)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
+1 -1
View File
@@ -730,7 +730,7 @@ mod tests {
fn test_snapshot() -> Event {
Event::Snapshot {
entries: vec![],
session: protocol::SessionSnapshot { entries: vec![] },
greeting: Greeting {
worker_name: "ticket-intake".to_string(),
cwd: "/tmp".to_string(),
+77 -47
View File
@@ -14,7 +14,7 @@ use workspace_api::{
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
};
use crate::BackendWorkspaceClientError;
use crate::{BackendApiClient, BackendWorkspaceClientError};
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
@@ -47,9 +47,9 @@ struct BackendWorkspaceOrchestratorResponse {
/// Construction requires both the selected Backend URL and Workspace identity.
/// Callers should derive these once from `Target::resolve()` and must not retry
/// failed requests against repository-local state.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone)]
pub struct BackendWorkspaceProductClient {
base_url: String,
api: BackendApiClient,
workspace_id: String,
}
@@ -58,22 +58,32 @@ impl BackendWorkspaceProductClient {
base_url: impl Into<String>,
workspace_id: impl Into<String>,
) -> Result<Self, BackendWorkspaceClientError> {
let base_url = base_url.into().trim_end_matches('/').to_string();
if base_url.is_empty() {
return Err(BackendWorkspaceClientError::InvalidTarget(
"Backend base URL must not be empty".into(),
));
}
let base_url = base_url.into();
let api = BackendApiClient::from_stored_token(&base_url)?;
let workspace_id = workspace_id.into();
if workspace_id.trim().is_empty() {
return Err(BackendWorkspaceClientError::InvalidTarget(
"Backend Workspace identity must not be empty".into(),
));
}
Ok(Self {
base_url,
workspace_id,
})
Ok(Self { api, workspace_id })
}
#[cfg(test)]
fn new_with_access_token(
base_url: impl Into<String>,
workspace_id: impl Into<String>,
access_token: &str,
) -> Result<Self, BackendWorkspaceClientError> {
let base_url = base_url.into();
let api = BackendApiClient::from_access_token_for_test(&base_url, access_token)?;
let workspace_id = workspace_id.into();
if workspace_id.trim().is_empty() {
return Err(BackendWorkspaceClientError::InvalidTarget(
"Backend Workspace identity must not be empty".into(),
));
}
Ok(Self { api, workspace_id })
}
pub fn workspace_id(&self) -> &str {
@@ -316,7 +326,7 @@ impl BackendWorkspaceProductClient {
body: Option<&B>,
) -> Result<R, BackendWorkspaceClientError> {
let response = self.request(method, path, body)?.send()?;
let response = ensure_success(response)?;
self.api.check_status(response.status())?;
response.json().map_err(BackendWorkspaceClientError::Http)
}
@@ -326,7 +336,8 @@ impl BackendWorkspaceProductClient {
path: &str,
body: Option<&B>,
) -> Result<(), BackendWorkspaceClientError> {
ensure_success(self.request(method, path, body)?.send()?)?;
let response = self.request(method, path, body)?.send()?;
self.api.check_status(response.status())?;
Ok(())
}
@@ -336,14 +347,12 @@ impl BackendWorkspaceProductClient {
path: &str,
body: Option<&B>,
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
let client = reqwest::blocking::Client::builder().build()?;
let url = format!(
"{}/api/w/{}/{}",
self.base_url,
let path = format!(
"/api/w/{}/{}",
encode_path_segment(&self.workspace_id),
path.trim_start_matches('/')
);
let request = client.request(method, url);
let request = self.api.blocking_request(method, &path)?;
Ok(match body {
Some(body) => request.json(body),
None => request,
@@ -588,19 +597,6 @@ fn ticket_client_error(error: BackendWorkspaceClientError) -> TicketError {
TicketError::Sqlite(format!("Backend request failed: {error}"))
}
fn ensure_success(
response: reqwest::blocking::Response,
) -> Result<reqwest::blocking::Response, BackendWorkspaceClientError> {
if response.status().is_success() {
return Ok(response);
}
let status = response.status().as_u16();
let message = response
.text()
.unwrap_or_else(|_| "Backend request failed".to_string());
Err(BackendWorkspaceClientError::RequestFailed { status, message })
}
fn ticket_reference(id: &TicketIdOrSlug) -> String {
match id {
TicketIdOrSlug::Id(id) => id.to_string(),
@@ -698,24 +694,32 @@ mod tests {
fn objective_list_uses_workspace_scoped_backend_route() {
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
let (base_url, request, handle) = one_response_server("200 OK", body);
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let response = client.list_objectives(1_000).unwrap();
assert!(response.items.is_empty());
assert!(
request
.recv()
.unwrap()
.starts_with("GET /api/w/workspace-a/objectives?limit=1000 ")
);
let request = request.recv().unwrap();
assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 "));
assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
handle.join().unwrap();
}
#[test]
fn backend_mutation_failure_is_returned_without_local_fallback() {
let (base_url, request, handle) = one_response_server("403 Forbidden", "denied");
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
let (base_url, request, handle) =
one_response_server("403 Forbidden", "test-backend-token");
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let error = client
.create_objective(&ObjectiveCreateRequest {
@@ -727,6 +731,7 @@ mod tests {
.unwrap_err();
assert!(error.to_string().contains("403"));
assert!(!error.to_string().contains("test-backend-token"));
assert!(
request
.recv()
@@ -739,7 +744,12 @@ mod tests {
#[test]
fn ticket_relation_query_uses_workspace_scoped_backend_route() {
let (base_url, request, handle) = one_response_server("200 OK", "[]");
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let relations = client
.query_ticket_relations(
@@ -758,7 +768,12 @@ mod tests {
#[test]
fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
let (base_url, request, handle) = one_response_server("200 OK", "[]");
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap();
@@ -784,7 +799,12 @@ mod tests {
r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#,
),
]);
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let status = client.launch_ticket_intake("T-1").unwrap();
@@ -806,7 +826,12 @@ mod tests {
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#;
let (base_url, request, handle) = one_response_server("200 OK", body);
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-backend-token",
)
.unwrap();
let status = client.start_workspace_orchestrator().unwrap();
@@ -822,7 +847,12 @@ mod tests {
#[test]
fn product_client_requires_workspace_identity() {
let error = BackendWorkspaceProductClient::new("http://127.0.0.1:8787", "").unwrap_err();
let error = BackendWorkspaceProductClient::new_with_access_token(
"http://127.0.0.1:8787",
"",
"test-backend-token",
)
.unwrap_err();
assert!(error.to_string().contains("Workspace identity"));
}
+54
View File
@@ -279,4 +279,58 @@ mod tests {
assert_eq!(grep.matched_files, 2);
assert!(!grep.output.contains("c.txt"));
}
#[test]
fn grep_content_groups_lines_by_file_and_marks_matches() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("first.txt"),
"before\nneedle one\nafter\nomitted one\nomitted two\nbefore distant\nneedle distant\nafter distant\n",
)
.unwrap();
std::fs::write(temp.path().join("second.txt"), "needle two\n").unwrap();
let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone());
let grep = run_grep(
&root,
root.clone(),
GrepRequest {
pattern: "needle".to_string(),
path: FsPath::root(),
glob: Some("*.txt".to_string()),
output_mode: GrepOutputMode::Content,
case_insensitive: false,
before_context: 1,
after_context: 1,
multiline: false,
file_type: None,
limit: 20,
offset: 0,
},
&readable,
)
.unwrap();
assert_eq!(grep.match_count, 3);
assert_eq!(grep.matched_files, 2);
assert_eq!(
grep.output,
concat!(
"first.txt\n",
" 1 │ before\n",
" > 2 │ needle one\n",
" 3 │ after\n",
"\n",
" 6 │ before distant\n",
" > 7 │ needle distant\n",
" 8 │ after distant\n",
"\n",
"second.txt\n",
" > 1 │ needle two\n",
)
);
assert_eq!(grep.output.matches("first.txt").count(), 1);
assert_eq!(grep.output.matches("second.txt").count(), 1);
}
}
+49 -14
View File
@@ -1,3 +1,5 @@
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use crate::FsAccessPolicy;
@@ -57,20 +59,11 @@ impl GrepReport {
}
}
GrepOutputMode::Content => {
for line in &self.lines {
let separator = if line.is_match { ':' } else { '-' };
let path = logical_display(root, &line.path);
if self.show_line_numbers
&& let Some(number) = line.line_number
{
output.push_str(&format!(
"{path}{separator}{number}{separator}{}\n",
line.text
));
} else {
output.push_str(&format!("{path}{separator}{}\n", line.text));
}
}
output.push_str(&render_content_lines(
root,
&self.lines,
self.show_line_numbers,
));
}
}
GrepResult {
@@ -82,6 +75,48 @@ impl GrepReport {
}
}
fn render_content_lines(root: &Path, lines: &[ContentLine], show_line_numbers: bool) -> String {
let mut grouped = BTreeMap::<&Path, Vec<&ContentLine>>::new();
for line in lines {
grouped.entry(&line.path).or_default().push(line);
}
let mut output = String::new();
for (file_index, (path, file_lines)) in grouped.into_iter().enumerate() {
if file_index > 0 {
output.push('\n');
}
let _ = writeln!(output, "{}", logical_display(root, path));
let number_width = file_lines
.iter()
.filter_map(|line| line.line_number)
.map(|number| number.to_string().len())
.max()
.unwrap_or(1);
let mut previous_line_end = None;
for line in file_lines {
if let (Some(previous_end), Some(number)) = (previous_line_end, line.line_number)
&& number > previous_end
{
let _ = writeln!(output, "");
}
let marker = if line.is_match { '>' } else { ' ' };
if show_line_numbers && let Some(number) = line.line_number {
let _ = writeln!(output, " {marker} {number:>number_width$} │ {}", line.text);
} else {
let _ = writeln!(output, " {marker} │ {}", line.text);
}
previous_line_end = line
.line_number
.map(|number| number + line.text.split('\n').count() as u64);
}
}
output
}
fn logical_display(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
+19 -2
View File
@@ -946,7 +946,7 @@ fn apply_role_profile(
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
value["feature"]["worker"] = serde_json::json!({
"enabled": matches!(slug, "companion" | "orchestrator"),
"enabled": slug == "orchestrator",
"direct_spawn": slug != "orchestrator"
});
value["feature"]["manage_workdir"] = serde_json::json!({
@@ -1408,7 +1408,22 @@ mod tests {
}
#[test]
fn builtin_companion_can_manage_workdirs() {
fn builtin_coder_uses_sub_worker_control_without_worker_control() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"),
ProfileResolveOptions::with_worker_name("coder-worker"),
)
.unwrap();
assert!(resolved.manifest.feature.sub_worker.enabled);
assert!(!resolved.manifest.feature.worker.enabled);
}
#[test]
fn builtin_companion_uses_sub_worker_control_without_worker_control() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
@@ -1419,6 +1434,8 @@ mod tests {
.unwrap();
assert!(resolved.manifest.feature.manage_workdir.enabled);
assert!(resolved.manifest.feature.sub_worker.enabled);
assert!(!resolved.manifest.feature.worker.enabled);
}
#[test]
+142 -29
View File
@@ -340,8 +340,7 @@ pub struct InternalWorkerRef {
pub struct InternalWorkerSnapshot {
pub worker: InternalWorkerRef,
pub revision: u64,
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
pub entries: Vec<serde_json::Value>,
pub session: SessionSnapshot,
#[serde(default)]
pub status: WorkerStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -364,12 +363,114 @@ pub enum ToolResultDisposition {
OutcomeUnknown,
}
/// Canonical, storage-independent projection of committed session history.
///
/// Worker protocols expose this DTO instead of append-log records. New
/// storage variants can therefore be added without teaching every client how
/// to replay the durable log format.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct SessionSnapshot {
pub entries: Vec<SessionSnapshotEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum SessionEntryProvenance {
HumanInput,
WorkerInput,
FlowInstruction,
BackendInstruction,
ModelOutput,
ToolOutput,
DerivedSummary,
LegacyUnknown,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct SessionSnapshotEntry {
/// Stable identity from durable history metadata, or a deterministic
/// identity derived from the legacy segment and log position.
pub entry_id: String,
/// Timestamp copied from the durable log record that commits this entry.
pub timestamp: u64,
pub provenance: SessionEntryProvenance,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub derived_from: Vec<String>,
#[serde(flatten)]
pub data: SessionSnapshotEntryData,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SessionSnapshotEntryData {
UserInput {
segments: Vec<Segment>,
},
Message {
role: SessionMessageRole,
content: Vec<SessionContentPart>,
},
ToolCall {
call_id: String,
name: String,
arguments: String,
},
ToolResult {
call_id: String,
summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
content: Option<String>,
is_error: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<SessionToolAttachment>,
},
SystemItem {
item_kind: String,
content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
data: Option<serde_json::Value>,
},
RunError {
message: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum SessionMessageRole {
User,
Assistant,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SessionContentPart {
Text { text: String },
Refusal { refusal: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct SessionToolAttachment {
pub media_type: String,
/// Base64-encoded durable attachment body. Public snapshots preserve the
/// committed multimodal value instead of replacing it with placeholder text.
pub data_base64: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
pub enum Event {
/// A user input message was accepted, persisted as
/// `LogEntry::UserInput`, and is about to start a new turn.
/// `LogEntry::AnnotatedUserInput`, and is about to start a new turn.
/// Broadcast to every subscribed client so TUI / GUI instances show
/// the same user line that reconnect snapshots would replay from
/// history; clients must not synthesize a separate pending/fake
@@ -390,7 +491,7 @@ pub enum Event {
/// of parsing free-text prefixes like `[Notification] …` or
/// `[File: …]`.
///
/// One event per `LogEntry::SystemItem` commit. Disk-side and
/// One event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and
/// wire-side are 1:1.
SystemItem {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
@@ -555,8 +656,7 @@ pub enum Event {
/// role-specific entry events (`SegmentRotated` / `SystemItem`) —
/// there is no generic "every committed entry" broadcast.
Snapshot {
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
entries: Vec<serde_json::Value>,
session: SessionSnapshot,
greeting: Greeting,
#[serde(default)]
status: WorkerStatus,
@@ -589,14 +689,10 @@ pub enum Event {
/// Server-side segment log rotated to a fresh `SegmentStart`.
///
/// Fires on compaction and on auto-fork when the store head drifts
/// from the live writer's cached head. Clients drop their derived
/// view and reseed from `entry.history` exactly the way they would
/// from a connect-time `Snapshot`.
///
/// Payload is the JSON form of `session_store::LogEntry::SegmentStart`.
/// A compaction/fork has replaced the authoritative segment. Clients drop
/// their derived view and reseed from the canonical committed snapshot.
SegmentRotated {
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
entry: serde_json::Value,
session: SessionSnapshot,
},
/// Current Worker controller status. Broadcast on every controller-level
/// transition and included in `History` snapshots for late attach.
@@ -623,11 +719,10 @@ pub enum Event {
head_entries: usize,
targets: Vec<RewindTarget>,
},
/// A rewind has truncated the authoritative session. `entries` is the
/// retained session-log prefix clients should use to reseed display state.
/// A rewind has truncated the authoritative session. `session` is the
/// retained canonical snapshot clients should use to reseed display state.
RewindApplied {
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
entries: Vec<serde_json::Value>,
session: SessionSnapshot,
input: Vec<Segment>,
summary: RewindSummary,
},
@@ -1440,7 +1535,17 @@ mod tests {
#[test]
fn event_snapshot_format() {
let event = Event::Snapshot {
entries: vec![serde_json::json!({"kind": "user_input", "ts": 1, "segments": []})],
session: SessionSnapshot {
entries: vec![SessionSnapshotEntry {
entry_id: "entry-1".into(),
timestamp: 1,
provenance: SessionEntryProvenance::HumanInput,
derived_from: Vec::new(),
data: SessionSnapshotEntryData::UserInput {
segments: Vec::new(),
},
}],
},
greeting: Greeting {
worker_name: "test".into(),
cwd: "/tmp".into(),
@@ -1458,8 +1563,12 @@ mod tests {
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "snapshot");
assert!(parsed["data"]["entries"].is_array());
assert_eq!(parsed["data"]["entries"][0]["kind"], "user_input");
assert!(parsed["data"]["session"]["entries"].is_array());
assert_eq!(
parsed["data"]["session"]["entries"][0]["kind"],
"user_input"
);
assert_eq!(parsed["data"]["session"]["entries"][0]["timestamp"], 1);
assert_eq!(parsed["data"]["greeting"]["worker_name"], "test");
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
@@ -1469,7 +1578,7 @@ mod tests {
#[test]
fn event_snapshot_in_flight_roundtrip_and_default() {
let inbound = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"status":"running"}}"#;
let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"status":"running"}}"#;
let decoded: Event = serde_json::from_str(inbound).unwrap();
match decoded {
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
@@ -1477,7 +1586,9 @@ mod tests {
}
let event = Event::Snapshot {
entries: Vec::new(),
session: SessionSnapshot {
entries: Vec::new(),
},
greeting: Greeting {
worker_name: "test".into(),
cwd: "/tmp".into(),
@@ -1543,15 +1654,17 @@ mod tests {
#[test]
fn event_segment_rotated_roundtrip() {
let event = Event::SegmentRotated {
entry: serde_json::json!({"kind": "segment_start", "ts": 1, "history": []}),
session: SessionSnapshot {
entries: Vec::new(),
},
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "segment_rotated");
assert_eq!(parsed["data"]["entry"]["kind"], "segment_start");
assert!(parsed["data"]["session"]["entries"].is_array());
let decoded: Event = serde_json::from_str(&json).unwrap();
match decoded {
Event::SegmentRotated { entry } => assert_eq!(entry["kind"], "segment_start"),
Event::SegmentRotated { session } => assert!(session.entries.is_empty()),
other => panic!("expected SegmentRotated, got {other:?}"),
}
}
@@ -1627,8 +1740,8 @@ mod tests {
}
#[test]
fn event_snapshot_legacy_without_status_defaults_to_idle() {
let json = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
fn event_snapshot_without_status_defaults_to_idle() {
let json = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
let decoded: Event = serde_json::from_str(json).unwrap();
match decoded {
Event::Snapshot {
@@ -2039,11 +2152,11 @@ mod tests {
}
#[test]
fn legacy_snapshot_defaults_internal_workers_to_empty() {
fn snapshot_defaults_internal_workers_to_empty() {
let snapshot: Event = serde_json::from_value(serde_json::json!({
"event": "snapshot",
"data": {
"entries": [],
"session": { "entries": [] },
"greeting": {
"worker_name": "parent",
"cwd": ".",
+10 -1
View File
@@ -8,7 +8,9 @@ use crate::{
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId,
RunResult, ScopeRule, Segment, ToolResultDisposition, TurnResult, WorkerEvent, WorkerStatus,
RunResult, ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
ToolResultDisposition, TurnResult, WorkerEvent, WorkerStatus,
subscription::{
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
@@ -63,6 +65,13 @@ pub fn generated_protocol_types() -> String {
push_decl::<RewindSummary>(&cfg, &mut output);
push_decl::<InFlightBlock>(&cfg, &mut output);
push_decl::<InFlightSnapshot>(&cfg, &mut output);
push_decl::<SessionEntryProvenance>(&cfg, &mut output);
push_decl::<SessionMessageRole>(&cfg, &mut output);
push_decl::<SessionContentPart>(&cfg, &mut output);
push_decl::<SessionToolAttachment>(&cfg, &mut output);
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
push_decl::<SessionSnapshot>(&cfg, &mut output);
push_decl::<InternalWorkerKind>(&cfg, &mut output);
push_decl::<InternalWorkerRef>(&cfg, &mut output);
push_decl::<InternalWorkerSnapshot>(&cfg, &mut output);
+10 -25
View File
@@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize};
use crate::{LoggedItem, SessionId};
use crate::LoggedItem;
/// Stable logical identity of one model-visible history entry.
///
@@ -142,12 +142,15 @@ mod tests {
#[test]
fn annotated_segment_start_is_restore_visible_without_projecting_metadata() {
let session_id = uuid::Uuid::now_v7();
let history_entry = legacy_logged_history(LoggedItem::Message {
role: LoggedRole::Assistant,
content: vec![crate::LoggedContentPart::Text {
text: "answer".into(),
}],
});
let history_entry = LoggedHistoryEntry {
item: LoggedItem::Message {
role: LoggedRole::Assistant,
content: vec![crate::LoggedContentPart::Text {
text: "answer".into(),
}],
},
metadata: LoggedSessionHistoryMetadata::legacy_unknown(),
};
let state = crate::collect_state(&[crate::LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
@@ -160,21 +163,3 @@ mod tests {
assert_eq!(state.history[0].as_text(), Some("answer"));
}
}
/// Legacy Session Logs did not persist annotations. Decode helpers explicitly
/// create `LegacyUnknown`; they never infer Human/System authority from role or
/// plaintext.
pub fn legacy_logged_history(item: LoggedItem) -> LoggedHistoryEntry {
LoggedHistoryEntry {
item,
metadata: LoggedSessionHistoryMetadata::legacy_unknown(),
}
}
pub fn legacy_segment_history(
session_id: SessionId,
items: impl IntoIterator<Item = LoggedItem>,
) -> Vec<LoggedHistoryEntry> {
let _ = session_id;
items.into_iter().map(legacy_logged_history).collect()
}
@@ -0,0 +1,188 @@
//! Versioned decoder for Session schemas that predate canonical annotated history.
//!
//! These types are intentionally private to `session-store`. Current writers,
//! replay, and public projections use [`crate::LogEntry`] exclusively; only the
//! Worker Session schema migration is allowed to deserialize these shapes.
use agen::llm_client::types::RequestConfig;
use protocol::Segment;
use serde::Deserialize;
use crate::{
LogEntry, LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry, SegmentId,
SegmentOrigin, SessionExtension, SessionId, SystemItem,
};
#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum LegacyHistoryLogEntry {
SegmentStart {
ts: u64,
session_id: SessionId,
system_prompt: Option<String>,
config: RequestConfig,
history: Vec<LoggedItem>,
#[serde(default)]
forked_from: Option<SegmentOrigin>,
#[serde(default)]
compacted_from: Option<SegmentOrigin>,
},
UserInput {
ts: u64,
segments: Vec<Segment>,
#[serde(default)]
extensions: Vec<SessionExtension>,
},
AssistantItem {
ts: u64,
item: LoggedItem,
},
ToolResult {
ts: u64,
item: LoggedItem,
},
SystemItem {
ts: u64,
item: SystemItem,
},
}
/// Schema-v1 decoder. Non-history records already had their current shape, so
/// they pass through `LogEntry`; legacy history records are converted below.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum LegacySessionLogEntryV1 {
History(LegacyHistoryLogEntry),
Current(LogEntry),
}
/// Schema v2 retained the v1 history shapes while adding non-history records.
/// Keep a distinct type so supported source versions remain explicit rather
/// than turning migration compatibility into the current `LogEntry` contract.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum LegacySessionLogEntryV2 {
History(LegacyHistoryLogEntry),
Current(LogEntry),
}
pub(crate) fn decode_entry(
schema_version: u32,
line: &str,
session_id: SessionId,
segment_id: SegmentId,
line_index: usize,
) -> Result<LogEntry, serde_json::Error> {
let entry = match schema_version {
1 => match serde_json::from_str::<LegacySessionLogEntryV1>(line)? {
LegacySessionLogEntryV1::History(entry) => Entry::History(entry),
LegacySessionLogEntryV1::Current(entry) => Entry::Current(entry),
},
2 => match serde_json::from_str::<LegacySessionLogEntryV2>(line)? {
LegacySessionLogEntryV2::History(entry) => Entry::History(entry),
LegacySessionLogEntryV2::Current(entry) => Entry::Current(entry),
},
_ => unreachable!("legacy decoder called for unsupported schema {schema_version}"),
};
Ok(match entry {
Entry::History(entry) => {
canonicalize_history_entry(session_id, segment_id, line_index, entry)
}
Entry::Current(entry) => entry,
})
}
enum Entry {
History(LegacyHistoryLogEntry),
Current(LogEntry),
}
fn legacy_metadata(
segment_id: SegmentId,
line_index: usize,
item_index: usize,
) -> LoggedSessionHistoryMetadata {
let mut identity = Vec::with_capacity(32);
identity.extend_from_slice(segment_id.as_bytes());
identity.extend_from_slice(&(line_index as u64).to_be_bytes());
identity.extend_from_slice(&(item_index as u64).to_be_bytes());
LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId(format!(
"l-{}",
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, identity)
)),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
}
}
fn canonicalize_history_entry(
_session_id: SessionId,
segment_id: SegmentId,
line_index: usize,
entry: LegacyHistoryLogEntry,
) -> LogEntry {
match entry {
LegacyHistoryLogEntry::SegmentStart {
ts,
session_id,
system_prompt,
config,
history,
forked_from,
compacted_from,
} => LogEntry::AnnotatedSegmentStart {
ts,
session_id,
system_prompt,
config,
history: history
.into_iter()
.enumerate()
.map(|(item_index, item)| LoggedHistoryEntry {
item,
metadata: legacy_metadata(segment_id, line_index, item_index),
})
.collect(),
forked_from,
compacted_from,
},
LegacyHistoryLogEntry::UserInput {
ts,
segments,
extensions,
} => LogEntry::AnnotatedUserInput {
ts,
history: vec![LoggedHistoryEntry {
item: LoggedItem::from(agen::Item::user_message(Segment::flatten_to_text(
&segments,
))),
metadata: legacy_metadata(segment_id, line_index, 0),
}],
segments,
extensions,
},
LegacyHistoryLogEntry::AssistantItem { ts, item } => LogEntry::AnnotatedAssistantItem {
ts,
entry: LoggedHistoryEntry {
item,
metadata: legacy_metadata(segment_id, line_index, 0),
},
},
LegacyHistoryLogEntry::ToolResult { ts, item } => LogEntry::AnnotatedToolResult {
ts,
entry: LoggedHistoryEntry {
item,
metadata: legacy_metadata(segment_id, line_index, 0),
},
},
LegacyHistoryLogEntry::SystemItem { ts, item } => LogEntry::AnnotatedSystemItem {
ts,
entry: LoggedSystemHistoryEntry {
item,
metadata: legacy_metadata(segment_id, line_index, 0),
},
},
}
}
+5 -3
View File
@@ -26,14 +26,16 @@
//! let (session_id, segment_id) = create_segment(&store, SegmentStartState {
//! system_prompt: None,
//! config: &config,
//! history: &[],
//! history: Vec::new(),
//! })?;
//! ```
pub mod event_trace;
pub mod fs_store;
pub mod history;
mod legacy_session_log;
pub mod logged_item;
pub mod public_snapshot;
pub mod segment;
pub mod segment_log;
pub mod store;
@@ -48,11 +50,11 @@ pub use fs_store::FsStore;
pub use history::{
LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry,
LoggedWorkerSubject, legacy_logged_history, legacy_segment_history,
LoggedWorkerSubject,
};
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
pub use segment::{
SegmentStartState, append_entry, append_system_item, classify_history_item,
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork,
fork_at, restore, restore_by_segment, save_config_changed, save_delta, save_extension,
save_run_completed, save_run_errored, save_turn_end, save_usage, save_user_input,
+484
View File
@@ -0,0 +1,484 @@
use base64::{
Engine as _,
engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD},
};
use protocol::{
Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole, SessionSnapshot,
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
};
use crate::{
LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole,
LoggedSessionHistoryOrigin, SessionId, SystemItem,
};
/// Project a complete current-segment log. A valid segment starts with one
/// canonical annotated SegmentStart record; malformed partial input uses the
/// nil session only to keep the public failure projection deterministic.
pub fn project_current_session_snapshot(log: &[LogEntry]) -> SessionSnapshot {
let session_id = log.iter().find_map(|entry| match entry {
LogEntry::AnnotatedSegmentStart { session_id, .. } => Some(*session_id),
_ => None,
});
project_session_snapshot(session_id.unwrap_or_else(SessionId::nil), log)
}
/// Project the current durable segment into the only public session-history
/// representation. Append-log records remain an internal persistence format.
pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> SessionSnapshot {
let mut session_key = session_id;
let mut entries = Vec::new();
for (log_index, record) in log.iter().enumerate() {
match record {
LogEntry::AnnotatedSegmentStart {
ts,
session_id,
history,
..
} => {
session_key = *session_id;
entries.clear();
extend_history(&mut entries, history, None, *ts);
}
LogEntry::AnnotatedUserInput {
ts,
segments,
history,
..
} => extend_history(&mut entries, history, Some(segments), *ts),
LogEntry::AnnotatedAssistantItem { ts, entry }
| LogEntry::AnnotatedToolResult { ts, entry } => {
if let Some(data) = project_item(&entry.item) {
entries.push(history_entry(entry, *ts, data));
}
}
LogEntry::AnnotatedSystemItem { ts, entry } => entries.push(system_entry(
&entry.item,
entry.metadata.entry_id.0.clone(),
*ts,
provenance(&entry.metadata.origin),
derivation_ids(entry),
)),
LogEntry::RunErrored { ts, message, .. } => entries.push(legacy_entry(
&session_key,
log_index,
0,
*ts,
SessionSnapshotEntryData::RunError {
message: message.clone(),
},
)),
// Run checkpoints, configuration, usage, and extension state are
// controller/storage authority rather than committed conversation.
LogEntry::Invoke { .. }
| LogEntry::TurnEnd { .. }
| LogEntry::RunCompleted { .. }
| LogEntry::ActiveRunCheckpoint { .. }
| LogEntry::PausedTurnAbandoned { .. }
| LogEntry::ConfigChanged { .. }
| LogEntry::LlmUsage { .. }
| LogEntry::Extension { .. } => {}
}
}
SessionSnapshot { entries }
}
fn extend_history(
output: &mut Vec<SessionSnapshotEntry>,
history: &[LoggedHistoryEntry],
input_segments: Option<&Vec<Segment>>,
timestamp: u64,
) {
let mut attached_segments = false;
for entry in history {
let data = if !attached_segments
&& input_segments.is_some()
&& matches!(
&entry.item,
LoggedItem::Message {
role: LoggedRole::User,
..
}
) {
attached_segments = true;
SessionSnapshotEntryData::UserInput {
segments: input_segments.cloned().unwrap_or_default(),
}
} else {
let Some(data) = project_item(&entry.item) else {
continue;
};
data
};
output.push(history_entry(entry, timestamp, data));
}
}
fn history_entry(
entry: &LoggedHistoryEntry,
timestamp: u64,
data: SessionSnapshotEntryData,
) -> SessionSnapshotEntry {
SessionSnapshotEntry {
entry_id: entry.metadata.entry_id.0.clone(),
timestamp,
provenance: provenance(&entry.metadata.origin),
derived_from: entry
.metadata
.derivation
.as_ref()
.map(|derivation| {
derivation
.sources
.iter()
.map(|source| source.0.clone())
.collect()
})
.unwrap_or_default(),
data,
}
}
fn derivation_ids(entry: &crate::LoggedSystemHistoryEntry) -> Vec<String> {
entry
.metadata
.derivation
.as_ref()
.map(|derivation| {
derivation
.sources
.iter()
.map(|source| source.0.clone())
.collect()
})
.unwrap_or_default()
}
fn legacy_entry(
session_key: &SessionId,
log_index: usize,
item_index: usize,
timestamp: u64,
data: SessionSnapshotEntryData,
) -> SessionSnapshotEntry {
SessionSnapshotEntry {
entry_id: legacy_entry_id(session_key, log_index, item_index),
timestamp,
provenance: SessionEntryProvenance::LegacyUnknown,
derived_from: Vec::new(),
data,
}
}
fn legacy_entry_id(session_key: &SessionId, log_index: usize, item_index: usize) -> String {
let mut identity = Vec::with_capacity(32);
identity.extend_from_slice(session_key.as_bytes());
identity.extend_from_slice(&(log_index as u64).to_be_bytes());
identity.extend_from_slice(&(item_index as u64).to_be_bytes());
format!("l-{}", URL_SAFE_NO_PAD.encode(identity))
}
fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance {
match origin {
LoggedSessionHistoryOrigin::HumanInput { .. } => SessionEntryProvenance::HumanInput,
LoggedSessionHistoryOrigin::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
LoggedSessionHistoryOrigin::FlowInstruction { .. } => {
SessionEntryProvenance::FlowInstruction
}
LoggedSessionHistoryOrigin::BackendInstruction { .. } => {
SessionEntryProvenance::BackendInstruction
}
LoggedSessionHistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
LoggedSessionHistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
LoggedSessionHistoryOrigin::DerivedSummary => SessionEntryProvenance::DerivedSummary,
LoggedSessionHistoryOrigin::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
}
}
fn project_item(item: &LoggedItem) -> Option<SessionSnapshotEntryData> {
match item {
LoggedItem::Message { role, content } => {
let role = match role {
LoggedRole::User => SessionMessageRole::User,
LoggedRole::Assistant => SessionMessageRole::Assistant,
// System prompts and instruction history never cross the public
// snapshot boundary. Typed SystemItems have separate records.
LoggedRole::System => return None,
};
Some(SessionSnapshotEntryData::Message {
role,
content: content
.iter()
.map(|part| match part {
LoggedContentPart::Text { text } => {
SessionContentPart::Text { text: text.clone() }
}
LoggedContentPart::Refusal { refusal } => SessionContentPart::Refusal {
refusal: refusal.clone(),
},
})
.collect(),
})
}
LoggedItem::ToolCall {
call_id,
name,
arguments,
} => Some(SessionSnapshotEntryData::ToolCall {
call_id: call_id.clone(),
name: name.clone(),
arguments: arguments.clone(),
}),
LoggedItem::ToolResult {
call_id,
summary,
content,
is_error,
attachments,
..
} => Some(SessionSnapshotEntryData::ToolResult {
call_id: call_id.clone(),
summary: summary.clone(),
content: content.clone(),
is_error: *is_error,
attachments: attachments
.iter()
.map(|attachment| match attachment {
crate::logged_item::LoggedAttachment::Image { mime_type, data } => {
SessionToolAttachment {
media_type: mime_type.clone(),
data_base64: BASE64.encode(data),
}
}
})
.collect(),
}),
// Hidden model reasoning is never observable.
LoggedItem::Reasoning { .. } => None,
}
}
fn system_entry(
item: &SystemItem,
entry_id: String,
timestamp: u64,
provenance: SessionEntryProvenance,
derived_from: Vec<String>,
) -> SessionSnapshotEntry {
let mut data = serde_json::to_value(item).ok();
if let Some(serde_json::Value::Object(object)) = data.as_mut() {
object.remove("prompt_provenance");
}
let item_kind = data
.as_ref()
.and_then(|value| value.get("kind"))
.and_then(serde_json::Value::as_str)
.unwrap_or("system_item")
.to_owned();
SessionSnapshotEntry {
entry_id,
timestamp,
provenance,
derived_from,
data: SessionSnapshotEntryData::SystemItem {
item_kind,
content: item.history_text(),
data,
},
}
}
#[cfg(test)]
mod tests {
use agen::llm_client::RequestConfig;
use super::*;
use crate::{
LoggedHistoryDerivation, LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata,
LoggedWorkerSubject,
};
#[test]
fn current_projection_is_stable_and_hides_reasoning_and_system_prompts() {
let session_id = crate::new_session_id();
let log = vec![LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: vec![
LoggedItem::Message {
role: LoggedRole::System,
content: vec![LoggedContentPart::Text {
text: "secret prompt".into(),
}],
},
LoggedItem::Reasoning {
text: "secret reasoning".into(),
summary: Vec::new(),
encrypted_content: None,
signature: None,
},
LoggedItem::Message {
role: LoggedRole::Assistant,
content: vec![LoggedContentPart::Text {
text: "visible".into(),
}],
},
]
.into_iter()
.map(|item| LoggedHistoryEntry {
item,
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
})
.collect(),
forked_from: None,
compacted_from: None,
}];
let first = project_session_snapshot(session_id, &log);
let second = project_session_snapshot(session_id, &log);
assert_eq!(first, second);
assert_eq!(first.entries.len(), 1);
assert_eq!(first.entries[0].timestamp, 1);
assert_eq!(
first.entries[0].provenance,
SessionEntryProvenance::LegacyUnknown
);
let json = serde_json::to_string(&first).unwrap();
assert!(!json.contains("secret prompt"));
assert!(!json.contains("secret reasoning"));
assert!(json.contains("visible"));
}
#[test]
fn annotated_user_input_attaches_segments_to_first_user_role_entry_for_any_origin() {
let session_id = crate::new_session_id();
let segments = vec![Segment::Text {
content: "normal submit".into(),
}];
for origin in [
LoggedSessionHistoryOrigin::LegacyUnknown,
LoggedSessionHistoryOrigin::FlowInstruction {
selector: "builtin:coder-review".into(),
definition_id: "flow-definition".into(),
definition_revision: 7,
instance_id: "flow-instance".into(),
state_id: "implement".into(),
},
] {
let user_entry_id = LoggedSessionHistoryEntryId::new();
let source_entry_id = LoggedSessionHistoryEntryId::new();
let log = vec![
LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: Vec::new(),
forked_from: None,
compacted_from: None,
},
LogEntry::AnnotatedUserInput {
ts: 2,
segments: segments.clone(),
history: vec![
LoggedHistoryEntry {
item: LoggedItem::Message {
role: LoggedRole::System,
content: vec![LoggedContentPart::Text {
text: "flow instruction".into(),
}],
},
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::FlowInstruction {
selector: "builtin:coder-review".into(),
definition_id: "flow-definition".into(),
definition_revision: 7,
instance_id: "flow-instance".into(),
state_id: "implement".into(),
},
derivation: None,
},
},
LoggedHistoryEntry {
item: LoggedItem::Message {
role: LoggedRole::User,
content: vec![LoggedContentPart::Text {
text: "normal submit".into(),
}],
},
metadata: LoggedSessionHistoryMetadata {
entry_id: user_entry_id.clone(),
origin: origin.clone(),
derivation: Some(LoggedHistoryDerivation {
sources: vec![source_entry_id.clone()],
}),
},
},
],
extensions: Vec::new(),
},
];
let snapshot = project_current_session_snapshot(&log);
assert_eq!(snapshot.entries.len(), 1);
assert_eq!(snapshot.entries[0].entry_id, user_entry_id.0);
assert_eq!(snapshot.entries[0].provenance, provenance(&origin));
assert_eq!(snapshot.entries[0].derived_from, vec![source_entry_id.0]);
assert_eq!(
snapshot.entries[0].data,
SessionSnapshotEntryData::UserInput {
segments: segments.clone(),
}
);
}
}
#[test]
fn annotated_projection_preserves_identity_and_provenance() {
let session_id = crate::new_session_id();
let metadata = LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::ModelOutput {
worker: LoggedWorkerSubject {
workspace_id: None,
runtime_id: None,
worker_id: "worker".into(),
},
},
derivation: None,
};
let expected_id = metadata.entry_id.0.clone();
let log = vec![LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: vec![LoggedHistoryEntry {
item: LoggedItem::Message {
role: LoggedRole::Assistant,
content: vec![LoggedContentPart::Text { text: "ok".into() }],
},
metadata,
}],
forked_from: None,
compacted_from: None,
}];
let snapshot = project_session_snapshot(session_id, &log);
assert_eq!(snapshot.entries[0].entry_id, expected_id);
assert_eq!(
snapshot.entries[0].provenance,
SessionEntryProvenance::ModelOutput
);
}
}
+39 -45
View File
@@ -4,11 +4,9 @@
//! The caller (typically Worker) holds the Engine directly and calls these
//! functions after state-mutating operations.
use crate::logged_item::{LoggedItem, to_logged};
use crate::segment_log::{self, LogEntry, SegmentOrigin};
use crate::store::{Store, StoreError};
use crate::system_item::SystemItem;
use crate::{SegmentId, SessionId};
use crate::{LoggedHistoryEntry, LoggedSystemHistoryEntry, SegmentId, SessionId};
use agen::EngineResult;
use agen::llm_client::RequestConfig;
use agen::llm_client::types::Item;
@@ -18,7 +16,7 @@ use protocol::Segment;
pub struct SegmentStartState<'a> {
pub system_prompt: Option<&'a str>,
pub config: &'a RequestConfig,
pub history: &'a [Item],
pub history: Vec<LoggedHistoryEntry>,
}
/// Create a new session + initial segment, writing the initial
@@ -44,12 +42,12 @@ pub fn create_segment_with_ids(
segment_id: SegmentId,
state: SegmentStartState<'_>,
) -> Result<(), StoreError> {
let entry = LogEntry::SegmentStart {
let entry = LogEntry::AnnotatedSegmentStart {
ts: segment_log::now_millis(),
session_id,
system_prompt: state.system_prompt.map(String::from),
config: state.config.clone(),
history: to_logged(state.history),
history: state.history.to_vec(),
forked_from: None,
compacted_from: None,
};
@@ -70,12 +68,12 @@ pub fn create_compacted_segment(
source_turn_count: usize,
) -> Result<SegmentId, StoreError> {
let segment_id = crate::new_segment_id();
let entry = LogEntry::SegmentStart {
let entry = LogEntry::AnnotatedSegmentStart {
ts: segment_log::now_millis(),
session_id: source_session_id,
system_prompt: state.system_prompt.map(String::from),
config: state.config.clone(),
history: to_logged(state.history),
history: state.history.to_vec(),
forked_from: None,
compacted_from: Some(SegmentOrigin {
segment_id: source_segment_id,
@@ -154,12 +152,12 @@ pub fn ensure_head_or_fork(
}
let source_segment_id = *segment_id;
let fork_id = crate::new_segment_id();
let entry = LogEntry::SegmentStart {
let entry = LogEntry::AnnotatedSegmentStart {
ts: segment_log::now_millis(),
session_id,
system_prompt: state.system_prompt.map(String::from),
config: state.config.clone(),
history: to_logged(state.history),
history: state.history.to_vec(),
forked_from: Some(SegmentOrigin {
segment_id: source_segment_id,
at_turn_index,
@@ -183,8 +181,9 @@ pub fn save_user_input(
session_id: SessionId,
segment_id: SegmentId,
segments: Vec<Segment>,
history: Vec<LoggedHistoryEntry>,
) -> Result<(), StoreError> {
save_user_input_with_extensions(store, session_id, segment_id, segments, Vec::new())
save_user_input_with_extensions(store, session_id, segment_id, segments, history, Vec::new())
}
/// Atomically persist one typed user submission and Runtime-owned session
@@ -194,15 +193,17 @@ pub fn save_user_input_with_extensions(
session_id: SessionId,
segment_id: SegmentId,
segments: Vec<Segment>,
history: Vec<LoggedHistoryEntry>,
extensions: Vec<segment_log::SessionExtension>,
) -> Result<(), StoreError> {
append_entry(
store,
session_id,
segment_id,
LogEntry::UserInput {
LogEntry::AnnotatedUserInput {
ts: segment_log::now_millis(),
segments,
history,
extensions,
},
)
@@ -220,64 +221,57 @@ pub fn save_delta(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
new_items: &[Item],
new_items: &[LoggedHistoryEntry],
) -> Result<(), StoreError> {
if new_items.is_empty() {
return Ok(());
}
let ts = segment_log::now_millis();
for item in new_items {
for entry in new_items {
let item = Item::from(entry.item.clone());
if item.is_user_message() {
// Already persisted by save_user_input at submit time.
continue;
}
let entry = classify_history_item(item, ts);
let entry = classify_logged_history_entry(entry.clone(), ts);
append_entry(store, session_id, segment_id, entry)?;
}
Ok(())
}
/// Map one history item to its singular `LogEntry` form. Used by the
/// fallback `save_delta` path and the controller's worker-callback
/// classifier so write classification lives in one place.
pub fn classify_history_item(item: &Item, ts: u64) -> LogEntry {
/// Map one annotated history entry to its singular `LogEntry` form. Used by
/// the fallback `save_delta` path and the controller's worker-callback
/// classifier so write classification lives in one place without discarding
/// identity or provenance.
/// Map one already-annotated history entry to its singular canonical record
/// without changing its identity or provenance.
pub fn classify_logged_history_entry(entry: LoggedHistoryEntry, ts: u64) -> LogEntry {
let item = Item::from(entry.item.clone());
if item.is_tool_result() {
LogEntry::ToolResult {
ts,
item: LoggedItem::from(item),
}
} else if item.is_assistant_message() || item.is_tool_call() || item.is_reasoning() {
LogEntry::AssistantItem {
ts,
item: LoggedItem::from(item),
}
LogEntry::AnnotatedToolResult { ts, entry }
} else {
// Defensive: anything else (future Item kinds) routes through
// AssistantItem rather than getting silently dropped.
LogEntry::AssistantItem {
ts,
item: LoggedItem::from(item),
}
// Assistant messages, tool calls, reasoning, and future non-user
// items all use the assistant-side canonical record.
LogEntry::AnnotatedAssistantItem { ts, entry }
}
}
/// Append a single typed system item as `LogEntry::SystemItem`. Helper
/// for the Worker-side interceptor commit path; mirrors the per-item
/// commit shape used for assistant / tool result entries.
/// Append one typed system item and its history metadata as a canonical
/// `LogEntry::AnnotatedSystemItem`.
pub fn append_system_item(
store: &impl Store,
session_id: SessionId,
segment_id: SegmentId,
item: SystemItem,
entry: LoggedSystemHistoryEntry,
) -> Result<(), StoreError> {
append_entry(
store,
session_id,
segment_id,
LogEntry::SystemItem {
LogEntry::AnnotatedSystemItem {
ts: segment_log::now_millis(),
item,
entry,
},
)
}
@@ -430,12 +424,12 @@ pub fn fork(
) -> Result<(SessionId, SegmentId), StoreError> {
let session_id = crate::new_session_id();
let fork_id = crate::new_segment_id();
let entry = LogEntry::SegmentStart {
let entry = LogEntry::AnnotatedSegmentStart {
ts: segment_log::now_millis(),
session_id,
system_prompt: state.system_prompt.map(String::from),
config: state.config.clone(),
history: to_logged(state.history),
history: state.history.to_vec(),
forked_from: None,
compacted_from: None,
};
@@ -470,7 +464,7 @@ pub fn fork_at(
// segment), before any turn completes.
entries
.iter()
.position(|e| !matches!(e, LogEntry::SegmentStart { .. }))
.position(|e| !matches!(e, LogEntry::AnnotatedSegmentStart { .. }))
.unwrap_or(entries.len())
} else {
entries
@@ -482,12 +476,12 @@ pub fn fork_at(
let state = segment_log::collect_state(&entries[..cut]);
let fork_id = crate::new_segment_id();
let entry = LogEntry::SegmentStart {
let entry = LogEntry::AnnotatedSegmentStart {
ts: segment_log::now_millis(),
session_id: source_session_id,
system_prompt: state.system_prompt,
config: state.config,
history: to_logged(&state.history),
history: state.annotated_history,
forked_from: Some(SegmentOrigin {
segment_id: source_id,
at_turn_index,
+86 -133
View File
@@ -16,7 +16,6 @@ use serde::{Deserialize, Serialize};
use crate::history::{LoggedHistoryEntry, LoggedSystemHistoryEntry};
use crate::logged_item::LoggedItem;
use crate::system_item::SystemItem;
/// A single segment log entry, serialized as one JSONL line.
///
@@ -50,28 +49,7 @@ impl SessionExtension {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LogEntry {
/// Segment start. Always the first entry in a segment log.
/// For forked segments, `history` contains the seed state from the parent.
SegmentStart {
ts: u64,
/// Session this segment belongs to. Compaction / fork inherits
/// the source segment's session_id; only fresh "new conversation"
/// segments mint a new session_id.
session_id: crate::SessionId,
system_prompt: Option<String>,
config: RequestConfig,
history: Vec<LoggedItem>,
/// Origin: forked from a sibling segment at a specific turn boundary.
/// The referenced segment is guaranteed to share `session_id`.
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_from: Option<SegmentOrigin>,
/// Origin: compacted from a sibling segment at a specific turn boundary.
/// The referenced segment is guaranteed to share `session_id`.
#[serde(default, skip_serializing_if = "Option::is_none")]
compacted_from: Option<SegmentOrigin>,
},
/// Schema-v2 segment seed. Retained entries keep their stable logical
/// Canonical segment seed. Retained entries keep their stable logical
/// identity and origin across fork/compaction/restore.
AnnotatedSegmentStart {
ts: u64,
@@ -105,22 +83,7 @@ pub enum LogEntry {
/// restore conservatively instead of re-running a dangling tool call.
Invoke { ts: u64, trigger: InvokeKind },
/// User input accepted at submit time. Carries the original typed
/// `Vec<Segment>` so clients can re-render typed atoms (paste chips,
/// file refs) on segment restore.
/// Replay flattens these into a `Item::user_message` for the worker
/// history; the worker layer never sees segments directly.
UserInput {
ts: u64,
segments: Vec<Segment>,
/// Typed durable state committed atomically with this input record.
/// Runtime-owned Flow invocation uses this to avoid a Backend-instance
/// commit that can get ahead of Worker history.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extensions: Vec<SessionExtension>,
},
/// Schema-v2 user submission with its exact model-visible entries. Typed
/// Canonical user submission with its exact model-visible entries. Typed
/// Flow instructions and caller-attributed input remain separate entries.
AnnotatedUserInput {
ts: u64,
@@ -130,35 +93,19 @@ pub enum LogEntry {
history: Vec<LoggedHistoryEntry>,
},
/// Schema-v2 model output and metadata committed as one journal record.
/// Canonical model output and metadata committed as one journal record.
AnnotatedAssistantItem { ts: u64, entry: LoggedHistoryEntry },
/// One assistant-side item appended to history — assistant message,
/// reasoning, or tool call. Singular: one entry per history item so
/// the wire-side `Event::*` lane and on-disk LogEntry stay 1:1.
AssistantItem { ts: u64, item: LoggedItem },
/// Schema-v2 tool output and metadata committed as one journal record.
/// Canonical tool output and metadata committed as one journal record.
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
/// One tool-execution result appended to history.
ToolResult { ts: u64, item: LoggedItem },
/// Schema-v2 typed system event and model-visible metadata committed
/// Canonical typed system event and model-visible metadata committed
/// together.
AnnotatedSystemItem {
ts: u64,
entry: LoggedSystemHistoryEntry,
},
/// One typed agent-injected system item: notification, child-Worker
/// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each
/// `SystemItem` carries kind metadata that the LLM
/// itself never sees (the LLM gets `Item::system_message` with the
/// item's denormalised `body`), but live clients and replay paths
/// dispatch on `kind` for typed rendering.
SystemItem { ts: u64, item: SystemItem },
/// Turn boundary. Records the turn count after increment.
TurnEnd { ts: u64, turn_count: usize },
@@ -260,6 +207,10 @@ pub struct RestoredState {
pub system_prompt: Option<String>,
pub config: RequestConfig,
pub history: Vec<Item>,
/// Canonical persisted history with stable identity and provenance. This is
/// the authority for rewrites, forks, and annotated restore; `history` is
/// retained as the model-facing item projection.
pub annotated_history: Vec<LoggedHistoryEntry>,
pub turn_count: usize,
/// AgentTurns consumed by the active paused/yielded logical run.
pub active_run_turn_count: Option<usize>,
@@ -276,7 +227,7 @@ pub struct RestoredState {
/// session-store は domain を不透明扱いし、各ドメインが自前で fold する。
pub extensions: Vec<(String, serde_json::Value)>,
/// User submissions in original typed form, in submit order.
/// One entry per `LogEntry::UserInput`; the K-th entry corresponds to
/// One entry per `LogEntry::AnnotatedUserInput`; the K-th entry corresponds to
/// the K-th `Item::user_message` derived during replay (modulo
/// pre-compaction history seeded via `SegmentStart.history`, whose
/// original segments are not preserved). Used by clients to re-render
@@ -291,6 +242,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
system_prompt: None,
config: RequestConfig::default(),
history: Vec::new(),
annotated_history: Vec::new(),
turn_count: 0,
active_run_turn_count: None,
last_run_interrupted: false,
@@ -304,18 +256,6 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.entries_count += 1;
match entry {
LogEntry::SegmentStart {
session_id,
system_prompt,
config,
history,
..
} => {
state.session_id = Some(*session_id);
state.system_prompt = system_prompt.clone();
state.config = config.clone();
state.history = history.iter().cloned().map(Item::from).collect();
}
LogEntry::AnnotatedSegmentStart {
session_id,
system_prompt,
@@ -326,6 +266,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.session_id = Some(*session_id);
state.system_prompt = system_prompt.clone();
state.config = config.clone();
state.annotated_history = history.clone();
state.history = history
.iter()
.cloned()
@@ -338,26 +279,13 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.last_run_interrupted = true;
state.active_run_turn_count = Some(0);
}
LogEntry::UserInput {
segments,
extensions,
..
} => {
let text = Segment::flatten_to_text(segments);
state.history.push(Item::user_message(text));
state.user_segments.push(segments.clone());
state.extensions.extend(
extensions
.iter()
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
);
}
LogEntry::AnnotatedUserInput {
segments,
extensions,
history,
..
} => {
state.annotated_history.extend(history.iter().cloned());
state
.history
.extend(history.iter().cloned().map(|entry| Item::from(entry.item)));
@@ -370,20 +298,16 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
}
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => {
state.annotated_history.push(entry.clone());
state.history.push(Item::from(entry.item.clone()));
}
LogEntry::AnnotatedSystemItem { entry, .. } => {
state.annotated_history.push(LoggedHistoryEntry {
item: LoggedItem::from(entry.item.to_history_item()),
metadata: entry.metadata.clone(),
});
state.history.push(entry.item.to_history_item());
}
LogEntry::AssistantItem { item, .. } => {
state.history.push(Item::from(item.clone()));
}
LogEntry::ToolResult { item, .. } => {
state.history.push(Item::from(item.clone()));
}
LogEntry::SystemItem { item, .. } => {
state.history.push(item.to_history_item());
}
LogEntry::TurnEnd { turn_count, .. } => {
if let Some(active_turn_count) = &mut state.active_run_turn_count {
*active_turn_count += turn_count.saturating_sub(state.turn_count);
@@ -465,6 +389,20 @@ pub fn now_millis() -> u64 {
#[cfg(test)]
mod tests {
use super::*;
use crate::{
LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin,
};
fn annotated(item: Item) -> LoggedHistoryEntry {
LoggedHistoryEntry {
item: LoggedItem::from(item),
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
}
}
#[test]
fn replay_empty() {
@@ -476,12 +414,12 @@ mod tests {
#[test]
fn replay_segment_start_sets_initial_state() {
let state = collect_state(&[LogEntry::SegmentStart {
let state = collect_state(&[LogEntry::AnnotatedSegmentStart {
ts: 1000,
session_id: uuid::Uuid::nil(),
system_prompt: Some("You are helpful.".into()),
config: RequestConfig::default().with_max_tokens(1024),
history: vec![Item::user_message("seed").into()],
history: vec![annotated(Item::user_message("seed"))],
forked_from: None,
compacted_from: None,
}]);
@@ -494,7 +432,7 @@ mod tests {
#[test]
fn replay_full_turn() {
let state = collect_state(&[
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts: 1000,
session_id: uuid::Uuid::nil(),
system_prompt: None,
@@ -503,14 +441,15 @@ mod tests {
forked_from: None,
compacted_from: None,
},
LogEntry::UserInput {
LogEntry::AnnotatedUserInput {
ts: 2000,
extensions: vec![],
segments: vec![Segment::text("Hello")],
history: vec![annotated(Item::user_message("Hello"))],
},
LogEntry::AssistantItem {
LogEntry::AnnotatedAssistantItem {
ts: 3000,
item: Item::assistant_message("Hi!").into(),
entry: annotated(Item::assistant_message("Hi!")),
},
LogEntry::TurnEnd {
ts: 3100,
@@ -531,7 +470,7 @@ mod tests {
#[test]
fn replay_incomplete_invoke_is_interrupted() {
let state = collect_state(&[
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts: 1000,
session_id: uuid::Uuid::nil(),
system_prompt: None,
@@ -544,14 +483,15 @@ mod tests {
ts: 2000,
trigger: InvokeKind::UserSend,
},
LogEntry::UserInput {
LogEntry::AnnotatedUserInput {
ts: 2001,
extensions: vec![],
segments: vec![Segment::text("run a tool")],
history: vec![annotated(Item::user_message("run a tool"))],
},
LogEntry::AssistantItem {
LogEntry::AnnotatedAssistantItem {
ts: 3000,
item: Item::tool_call("call_1", "side_effect", "{}").into(),
entry: annotated(Item::tool_call("call_1", "side_effect", "{}")),
},
]);
@@ -561,7 +501,7 @@ mod tests {
#[test]
fn replay_with_tool_calls() {
let state = collect_state(&[
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts: 1000,
session_id: uuid::Uuid::nil(),
system_prompt: None,
@@ -570,22 +510,27 @@ mod tests {
forked_from: None,
compacted_from: None,
},
LogEntry::UserInput {
LogEntry::AnnotatedUserInput {
ts: 2000,
extensions: vec![],
segments: vec![Segment::text("Check weather")],
history: vec![annotated(Item::user_message("Check weather"))],
},
LogEntry::AssistantItem {
LogEntry::AnnotatedAssistantItem {
ts: 3000,
item: Item::tool_call("call_1", "get_weather", r#"{"city":"Tokyo"}"#).into(),
entry: annotated(Item::tool_call(
"call_1",
"get_weather",
r#"{"city":"Tokyo"}"#,
)),
},
LogEntry::ToolResult {
LogEntry::AnnotatedToolResult {
ts: 3500,
item: Item::tool_result("call_1", "Sunny, 25C").into(),
entry: annotated(Item::tool_result("call_1", "Sunny, 25C")),
},
LogEntry::AssistantItem {
LogEntry::AnnotatedAssistantItem {
ts: 4000,
item: Item::assistant_message("It's sunny in Tokyo!").into(),
entry: annotated(Item::assistant_message("It's sunny in Tokyo!")),
},
LogEntry::TurnEnd {
ts: 4100,
@@ -599,9 +544,9 @@ mod tests {
#[test]
fn replay_restores_durable_tool_image_detail() {
let entry = LogEntry::ToolResult {
let entry = LogEntry::AnnotatedToolResult {
ts: 3500,
item: Item::tool_result_item_with_attachments(
entry: annotated(Item::tool_result_item_with_attachments(
"call_image",
"attached",
None,
@@ -609,8 +554,7 @@ mod tests {
vec![agen::tool::Attachment::Image(
agen::tool::ImageAttachment::new("image/png", b"durable-image".to_vec()),
)],
)
.into(),
)),
};
let persisted = serde_json::to_string(&entry).unwrap();
let restored_entry: LogEntry = serde_json::from_str(&persisted).unwrap();
@@ -630,7 +574,7 @@ mod tests {
#[test]
fn replay_config_changed() {
let state = collect_state(&[
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts: 1000,
session_id: uuid::Uuid::nil(),
system_prompt: None,
@@ -650,7 +594,7 @@ mod tests {
#[test]
fn replay_llm_usage_appends_to_usage_history() {
let state = collect_state(&[
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts: 1000,
session_id: uuid::Uuid::nil(),
system_prompt: None,
@@ -659,10 +603,11 @@ mod tests {
forked_from: None,
compacted_from: None,
},
LogEntry::UserInput {
LogEntry::AnnotatedUserInput {
ts: 2000,
extensions: vec![],
segments: vec![Segment::text("hi")],
history: vec![annotated(Item::user_message("hi"))],
},
LogEntry::LlmUsage {
ts: 2100,
@@ -672,9 +617,9 @@ mod tests {
cache_write_tokens: 0,
output_tokens: 10,
},
LogEntry::AssistantItem {
LogEntry::AnnotatedAssistantItem {
ts: 2200,
item: Item::assistant_message("yo").into(),
entry: annotated(Item::assistant_message("yo")),
},
LogEntry::LlmUsage {
ts: 3100,
@@ -698,7 +643,7 @@ mod tests {
#[test]
fn replay_without_llm_usage_keeps_usage_history_empty() {
let state = collect_state(&[
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts: 1000,
session_id: uuid::Uuid::nil(),
system_prompt: None,
@@ -707,10 +652,11 @@ mod tests {
forked_from: None,
compacted_from: None,
},
LogEntry::UserInput {
LogEntry::AnnotatedUserInput {
ts: 2000,
extensions: vec![],
segments: vec![Segment::text("hi")],
history: vec![annotated(Item::user_message("hi"))],
},
]);
assert!(state.usage_history.is_empty());
@@ -771,7 +717,7 @@ mod tests {
#[test]
fn replay_invoke_marker_only_mutates_interrupted_state() {
let state = collect_state(&[
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts: 0,
session_id: uuid::Uuid::nil(),
system_prompt: None,
@@ -784,10 +730,11 @@ mod tests {
ts: 100,
trigger: InvokeKind::UserSend,
},
LogEntry::UserInput {
LogEntry::AnnotatedUserInput {
ts: 101,
extensions: vec![],
segments: vec![Segment::text("hi")],
history: vec![annotated(Item::user_message("hi"))],
},
LogEntry::TurnEnd {
ts: 200,
@@ -806,7 +753,7 @@ mod tests {
#[test]
fn replay_paused_turn_abandoned_clears_interrupted_marker() {
let state = collect_state(&[
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts: 0,
session_id: uuid::Uuid::nil(),
system_prompt: None,
@@ -830,7 +777,7 @@ mod tests {
#[test]
fn replay_restores_active_run_budget_across_compaction_checkpoint() {
let state = collect_state(&[
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts: 0,
session_id: uuid::Uuid::nil(),
system_prompt: None,
@@ -861,7 +808,7 @@ mod tests {
}))
.expect("legacy run-completed entry");
let state = collect_state(&[
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts: 0,
session_id: uuid::Uuid::nil(),
system_prompt: None,
@@ -924,7 +871,7 @@ mod tests {
#[test]
fn replay_extension_collects_domain_payload_pairs() {
let state = collect_state(&[
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts: 1000,
session_id: uuid::Uuid::nil(),
system_prompt: None,
@@ -983,9 +930,12 @@ mod tests {
#[test]
fn user_input_extensions_restore_with_the_same_committed_input() {
let segments = vec![Segment::text("Flow instructions"), Segment::text("Ticket")];
let entry = LogEntry::UserInput {
let entry = LogEntry::AnnotatedUserInput {
ts: 9999,
segments: segments.clone(),
history: vec![annotated(Item::user_message(Segment::flatten_to_text(
&segments,
)))],
extensions: vec![SessionExtension::new(
"flow.runtime.v1",
serde_json::json!({ "state": "implement", "revision": 0 }),
@@ -1000,7 +950,7 @@ mod tests {
assert_eq!(state.extensions[0].1["state"], "implement");
}
/// Mixed segments survive a JSON round-trip through `LogEntry::UserInput`,
/// Mixed segments survive a JSON round-trip through `LogEntry::AnnotatedUserInput`,
/// and `collect_state` derives `Item::user_message` from the flattened
/// text while preserving the original segments separately. This covers
/// the segments → flatten → Item replay path from the ticket.
@@ -1020,16 +970,19 @@ mod tests {
path: "src/main.rs".into(),
},
];
let entry = LogEntry::UserInput {
let entry = LogEntry::AnnotatedUserInput {
ts: 4242,
extensions: vec![],
segments: segments.clone(),
history: vec![annotated(Item::user_message(Segment::flatten_to_text(
&segments,
)))],
};
// JSON round-trip preserves the variant byte-for-byte.
let json = serde_json::to_string(&entry).unwrap();
let parsed: LogEntry = serde_json::from_str(&json).unwrap();
let state = collect_state(&[
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id: uuid::Uuid::nil(),
system_prompt: None,
+1 -1
View File
@@ -8,7 +8,7 @@
//! `kind` instead of parsing text prefixes like `[Notification] …` or
//! `[File: …]`.
//!
//! Persisted as the payload of [`crate::LogEntry::SystemItem`] (one
//! Persisted as the payload of [`crate::LogEntry::AnnotatedSystemItem`] (one
//! entry per item), and broadcast live as the payload of
//! `Event::SystemItem` on the wire.
//!
+451 -20
View File
@@ -20,7 +20,8 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
const SESSION_SCHEMA_VERSION: u32 = 2;
const SESSION_SCHEMA_VERSION: u32 = 3;
const PREVIOUS_SESSION_SCHEMA_VERSION: u32 = 2;
const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1;
const SESSION_FILE: &str = "session.json";
const SEGMENTS_DIR: &str = "segments";
@@ -47,9 +48,15 @@ impl WorkerSessionStore {
Ok(bytes) => {
let mut manifest: SessionManifest = serde_json::from_slice(&bytes)?;
match manifest.schema_version {
SESSION_SCHEMA_VERSION => {}
LEGACY_SESSION_SCHEMA_VERSION => {
validate_legacy_segment_logs(&root)?;
SESSION_SCHEMA_VERSION => {
validate_canonical_segment_logs(&root)?;
}
PREVIOUS_SESSION_SCHEMA_VERSION | LEGACY_SESSION_SCHEMA_VERSION => {
migrate_segment_logs_to_v3(
&root,
manifest.session_id,
manifest.schema_version,
)?;
manifest.schema_version = SESSION_SCHEMA_VERSION;
atomic_write_json(&root.join(SESSION_FILE), &manifest)?;
}
@@ -144,6 +151,41 @@ impl WorkerSessionStore {
.join(format!("{segment_id}.trace.jsonl"))
}
fn append_log_entry(&self, path: &Path, entry: &LogEntry) -> Result<(), StoreError> {
let _guard = self
.append_lock
.lock()
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
let mut file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.append(true)
.open(path)?;
let committed_len = truncate_uncommitted_tail(&mut file)?;
file.seek(SeekFrom::Start(0))?;
let mut existing = Vec::new();
file.read_to_end(&mut existing)?;
parse_jsonl::<LogEntry>(&existing)?;
let line = serde_json::to_string(entry)?;
let mut record = Vec::with_capacity(line.len() + 1);
record.extend_from_slice(line.as_bytes());
record.push(b'\n');
if let Err(write_error) = file.write_all(&record) {
return match file.set_len(committed_len) {
Ok(()) => Err(write_error.into()),
Err(rollback_error) => Err(std::io::Error::new(
rollback_error.kind(),
format!(
"session append failed ({write_error}) and rollback failed: {rollback_error}"
),
)
.into()),
};
}
Ok(())
}
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
let _guard = self
.append_lock
@@ -183,7 +225,7 @@ impl Store for WorkerSessionStore {
entry: &LogEntry,
) -> Result<(), StoreError> {
self.ensure_session(session_id, true)?;
self.append_line(&self.log_path(segment_id), &serde_json::to_string(entry)?)
self.append_log_entry(&self.log_path(segment_id), entry)
}
fn read_all(
@@ -286,37 +328,138 @@ impl Store for WorkerSessionStore {
}
}
fn validate_legacy_segment_logs(root: &Path) -> Result<(), StoreError> {
fn segment_log_paths(root: &Path) -> Result<Vec<(SegmentId, PathBuf)>, StoreError> {
let segments = root.join(SEGMENTS_DIR);
if !segments.exists() {
return Ok(());
return Ok(Vec::new());
}
let mut paths = Vec::new();
for entry in fs::read_dir(&segments)? {
let entry = entry?;
let path = entry.path();
let metadata = fs::symlink_metadata(&path)?;
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
return Err(StoreError::Corrupt {
line: 0,
message: format!("non-UTF-8 Worker Session segment path: {}", path.display()),
});
};
if !name.ends_with(".jsonl") || name.ends_with(".trace.jsonl") {
if name.ends_with(".trace.jsonl") || name.starts_with('.') {
continue;
}
let contents = fs::read_to_string(&path)?;
for (line_index, line) in contents.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
serde_json::from_str::<LogEntry>(line).map_err(|error| StoreError::Corrupt {
line: line_index + 1,
if !name.ends_with(".jsonl") {
continue;
}
if !metadata.file_type().is_file() {
return Err(StoreError::Corrupt {
line: 0,
message: format!(
"cannot migrate legacy Worker Session log {}: {error}",
"Worker Session segment is not a regular file: {}",
path.display()
),
});
}
let segment_id =
name.trim_end_matches(".jsonl")
.parse()
.map_err(|_| StoreError::Corrupt {
line: 0,
message: format!("invalid Worker Session segment name: {name}"),
})?;
paths.push((segment_id, path));
}
paths.sort_by_key(|(segment_id, _)| *segment_id);
Ok(paths)
}
fn migrate_segment_logs_to_v3(
root: &Path,
session_id: SessionId,
source_schema_version: u32,
) -> Result<(), StoreError> {
struct MigrationPlan {
path: PathBuf,
source: Vec<u8>,
output: Vec<u8>,
}
// Phase 1 is strictly read-only. Every segment must parse and canonicalize
// successfully before the first authoritative byte is replaced.
let mut plans = Vec::new();
for (segment_id, path) in segment_log_paths(root)? {
let source = fs::read(&path)?;
let canonical = parse_legacy_jsonl(source_schema_version, session_id, segment_id, &source)
.map_err(|error| StoreError::Corrupt {
line: 0,
message: format!(
"cannot migrate Worker Session log {}: {error}",
path.display()
),
})?;
let mut output = Vec::new();
for entry in canonical {
serde_json::to_writer(&mut output, &entry)?;
output.push(b'\n');
}
plans.push(MigrationPlan {
path,
source,
output,
});
}
// Fence the complete preflight snapshot before starting phase 2. Session
// open is the exclusive restore boundary; this additionally fails closed
// if an unexpected writer raced the preflight.
for plan in &plans {
if fs::read(&plan.path)? != plan.source {
return Err(StoreError::Corrupt {
line: 0,
message: format!(
"Worker Session segment changed during migration: {}",
plan.path.display()
),
});
}
}
for plan in plans {
atomic_write_bytes(&plan.path, &plan.output)?;
}
Ok(())
}
fn validate_canonical_segment_logs(root: &Path) -> Result<(), StoreError> {
for (_, path) in segment_log_paths(root)? {
let _: Vec<LogEntry> = parse_jsonl(&fs::read(&path)?)?;
}
Ok(())
}
fn parse_legacy_jsonl(
schema_version: u32,
session_id: SessionId,
segment_id: SegmentId,
bytes: &[u8],
) -> Result<Vec<LogEntry>, serde_json::Error> {
let text = std::str::from_utf8(bytes).map_err(|error| {
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
})?;
text.lines()
.enumerate()
.filter(|(_, line)| !line.trim().is_empty())
.map(|(line_index, line)| {
crate::legacy_session_log::decode_entry(
schema_version,
line,
session_id,
segment_id,
line_index,
)
})
.collect()
}
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
let mut bytes = serde_json::to_vec_pretty(value)?;
bytes.push(b'\n');
@@ -418,7 +561,21 @@ fn truncate_uncommitted_tail(file: &mut File) -> std::io::Result<u64> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{Store, new_segment_id, new_session_id};
use crate::{
LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata,
LoggedSessionHistoryOrigin, Store, new_segment_id, new_session_id,
};
fn annotated(item: agen::Item) -> LoggedHistoryEntry {
LoggedHistoryEntry {
item: LoggedItem::from(item),
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
}
}
#[test]
fn canonical_layout_and_single_session_invariant() {
@@ -445,7 +602,7 @@ mod tests {
}
#[test]
fn schema_v1_logs_are_validated_and_promoted_to_v2() {
fn schema_v1_logs_are_rewritten_and_promoted_to_v3() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
@@ -467,7 +624,7 @@ mod tests {
}
#[test]
fn schema_v1_migration_rejects_corrupt_log_before_manifest_update() {
fn schema_v1_migration_rejects_corrupt_log_before_v3_manifest_update() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let manifest = SessionManifest {
@@ -492,6 +649,280 @@ mod tests {
assert_eq!(persisted.schema_version, LEGACY_SESSION_SCHEMA_VERSION);
}
#[test]
fn schema_v2_migration_rewrites_legacy_records_with_stable_unknown_provenance() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap();
atomic_write_json(
&root.path().join(SESSION_FILE),
&SessionManifest {
schema_version: PREVIOUS_SESSION_SCHEMA_VERSION,
session_id,
},
)
.unwrap();
let source = vec![
serde_json::json!({
"kind": "segment_start",
"ts": 1,
"session_id": session_id,
"system_prompt": null,
"config": agen::llm_client::RequestConfig::default(),
"history": [LoggedItem::from(agen::Item::assistant_message("prior"))],
"forked_from": null,
"compacted_from": null
}),
serde_json::json!({
"kind": "user_input",
"ts": 2,
"segments": [{ "kind": "text", "content": "hello" }],
"extensions": []
}),
serde_json::json!({
"kind": "assistant_item",
"ts": 3,
"item": LoggedItem::from(agen::Item::assistant_message("reply"))
}),
];
let path = root
.path()
.join(SEGMENTS_DIR)
.join(format!("{segment_id}.jsonl"));
let mut bytes = Vec::new();
for entry in source {
serde_json::to_writer(&mut bytes, &entry).unwrap();
bytes.push(b'\n');
}
fs::write(&path, bytes).unwrap();
let store = WorkerSessionStore::new(root.path()).unwrap();
let first = store.read_all(session_id, segment_id).unwrap();
assert!(matches!(first[0], LogEntry::AnnotatedSegmentStart { .. }));
assert!(matches!(first[1], LogEntry::AnnotatedUserInput { .. }));
assert!(matches!(first[2], LogEntry::AnnotatedAssistantItem { .. }));
let first_bytes = fs::read(&path).unwrap();
drop(store);
let reopened = WorkerSessionStore::new(root.path()).unwrap();
assert_eq!(fs::read(&path).unwrap(), first_bytes);
let snapshot = crate::public_snapshot::project_current_session_snapshot(
&reopened.read_all(session_id, segment_id).unwrap(),
);
assert_eq!(snapshot.entries.len(), 3);
assert_eq!(
snapshot
.entries
.iter()
.map(|entry| entry.timestamp)
.collect::<Vec<_>>(),
vec![1, 2, 3]
);
assert!(snapshot.entries.iter().all(|entry| {
entry.provenance == protocol::SessionEntryProvenance::LegacyUnknown
&& entry.entry_id.len() <= 64
}));
}
#[test]
fn schema_v2_preflight_keeps_earlier_segments_unchanged_when_later_is_corrupt() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let valid_segment = uuid::Uuid::from_u128(1);
let corrupt_segment = uuid::Uuid::from_u128(2);
fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap();
atomic_write_json(
&root.path().join(SESSION_FILE),
&SessionManifest {
schema_version: PREVIOUS_SESSION_SCHEMA_VERSION,
session_id,
},
)
.unwrap();
let manifest_before = fs::read(root.path().join(SESSION_FILE)).unwrap();
let valid_path = root
.path()
.join(SEGMENTS_DIR)
.join(format!("{valid_segment}.jsonl"));
let valid_entry = serde_json::json!({
"kind": "segment_start",
"ts": 1,
"session_id": session_id,
"system_prompt": null,
"config": agen::llm_client::RequestConfig::default(),
"history": [LoggedItem::from(agen::Item::assistant_message("prior"))],
"forked_from": null,
"compacted_from": null
});
let mut valid_bytes = serde_json::to_vec(&valid_entry).unwrap();
valid_bytes.push(b'\n');
fs::write(&valid_path, &valid_bytes).unwrap();
let corrupt_path = root
.path()
.join(SEGMENTS_DIR)
.join(format!("{corrupt_segment}.jsonl"));
fs::write(&corrupt_path, b"{not-json}\n").unwrap();
let corrupt_before = fs::read(&corrupt_path).unwrap();
let error = match WorkerSessionStore::new(root.path()) {
Ok(_) => panic!("later corrupt segment must fail migration preflight"),
Err(error) => error,
};
assert!(matches!(error, StoreError::Corrupt { .. }));
assert_eq!(fs::read(&valid_path).unwrap(), valid_bytes);
assert_eq!(fs::read(&corrupt_path).unwrap(), corrupt_before);
assert_eq!(
fs::read(root.path().join(SESSION_FILE)).unwrap(),
manifest_before
);
}
#[test]
fn current_jsonl_requires_annotations_across_append_rewrite_and_reopen() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
let store = WorkerSessionStore::new(root.path()).unwrap();
store
.create_segment(
session_id,
segment_id,
&[LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: agen::llm_client::RequestConfig::default(),
history: vec![annotated(agen::Item::user_message("seed"))],
forked_from: None,
compacted_from: None,
}],
)
.unwrap();
store
.append(
session_id,
segment_id,
&LogEntry::AnnotatedAssistantItem {
ts: 2,
entry: annotated(agen::Item::assistant_message("reply")),
},
)
.unwrap();
let before_rewrite = store.read_all(session_id, segment_id).unwrap();
store
.create_segment(session_id, segment_id, &before_rewrite)
.unwrap();
drop(store);
let reopened = WorkerSessionStore::new(root.path()).unwrap();
let restored = reopened.read_all(session_id, segment_id).unwrap();
assert_eq!(
serde_json::to_value(&restored).unwrap(),
serde_json::to_value(&before_rewrite).unwrap()
);
for entry in &restored {
match entry {
LogEntry::AnnotatedSegmentStart { history, .. } => assert!(history.iter().all(
|entry| !entry.metadata.entry_id.0.is_empty()
&& matches!(
entry.metadata.origin,
LoggedSessionHistoryOrigin::LegacyUnknown
)
)),
LogEntry::AnnotatedAssistantItem { entry, .. } => {
assert!(!entry.metadata.entry_id.0.is_empty());
assert!(matches!(
entry.metadata.origin,
LoggedSessionHistoryOrigin::LegacyUnknown
));
}
_ => {}
}
}
let log = fs::read_to_string(reopened.log_path(segment_id)).unwrap();
for line in log.lines() {
let value: serde_json::Value = serde_json::from_str(line).unwrap();
let kind = value["kind"].as_str().unwrap();
assert!(
!matches!(
kind,
"segment_start"
| "user_input"
| "assistant_item"
| "tool_result"
| "system_item"
),
"current-schema JSONL contains legacy history record: {kind}"
);
}
}
#[test]
fn schema_v3_rejects_legacy_records_and_new_writes_are_canonical() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
let store = WorkerSessionStore::new(root.path()).unwrap();
store
.create_segment(
session_id,
segment_id,
&[LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: agen::llm_client::RequestConfig::default(),
history: vec![annotated(agen::Item::assistant_message("seed"))],
forked_from: None,
compacted_from: None,
}],
)
.unwrap();
store
.append(
session_id,
segment_id,
&LogEntry::AnnotatedUserInput {
ts: 2,
segments: vec![protocol::Segment::Text {
content: "new".into(),
}],
history: vec![annotated(agen::Item::user_message("new"))],
extensions: Vec::new(),
},
)
.unwrap();
let entries = store.read_all(session_id, segment_id).unwrap();
assert!(matches!(entries[0], LogEntry::AnnotatedSegmentStart { .. }));
assert!(matches!(entries[1], LogEntry::AnnotatedUserInput { .. }));
drop(store);
let path = root
.path()
.join(SEGMENTS_DIR)
.join(format!("{segment_id}.jsonl"));
let mut file = OpenOptions::new().append(true).open(path).unwrap();
serde_json::to_writer(
&mut file,
&serde_json::json!({
"kind": "system_item",
"ts": 3,
"item": { "kind": "legacy_ignored", "slug": "legacy" }
}),
)
.unwrap();
file.write_all(b"\n").unwrap();
let error = match WorkerSessionStore::new(root.path()) {
Ok(_) => panic!("schema v3 must reject a legacy history record"),
Err(error) => error,
};
assert!(matches!(error, StoreError::Corrupt { .. }));
}
#[test]
fn reopen_preserves_session_and_segment_ids() {
let root = tempfile::tempdir().unwrap();
+28 -12
View File
@@ -1,12 +1,25 @@
use agen::EngineResult;
use agen::llm_client::types::{Item, RequestConfig};
use session_store::{
FsStore, LogEntry, Store, TraceEntry, collect_state, new_segment_id, new_session_id,
FsStore, LogEntry, LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, Store, TraceEntry, collect_state,
new_segment_id, new_session_id,
};
use std::io::Write;
fn annotated(item: Item) -> LoggedHistoryEntry {
LoggedHistoryEntry {
item: LoggedItem::from(item),
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
}
}
fn nil_session_start(ts: u64, session_id: uuid::Uuid) -> LogEntry {
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts,
session_id,
system_prompt: None,
@@ -25,7 +38,7 @@ fn round_trip_write_and_read() {
let segid = new_segment_id();
let entries = vec![
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts: 1000,
session_id: sid,
system_prompt: Some("You are helpful.".into()),
@@ -34,14 +47,15 @@ fn round_trip_write_and_read() {
forked_from: None,
compacted_from: None,
},
LogEntry::UserInput {
LogEntry::AnnotatedUserInput {
ts: 2000,
extensions: vec![],
segments: vec![protocol::Segment::text("Hello")],
history: vec![annotated(Item::user_message("Hello"))],
},
LogEntry::AssistantItem {
LogEntry::AnnotatedAssistantItem {
ts: 3000,
item: Item::assistant_message("Hi there!").into(),
entry: annotated(Item::assistant_message("Hi there!")),
},
LogEntry::TurnEnd {
ts: 3100,
@@ -79,14 +93,14 @@ fn create_segment_writes_all_entries() {
let sid = new_session_id();
let segid = new_segment_id();
let entries = [LogEntry::SegmentStart {
let entries = [LogEntry::AnnotatedSegmentStart {
ts: 1000,
session_id: sid,
system_prompt: None,
config: RequestConfig::default(),
history: vec![
Item::user_message("seed").into(),
Item::assistant_message("ok").into(),
annotated(Item::user_message("seed")),
annotated(Item::assistant_message("ok")),
],
forked_from: None,
compacted_from: None,
@@ -205,7 +219,7 @@ fn read_entry_count_matches_append_tally() {
let segid = new_segment_id();
let entries = [
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts: 1000,
session_id: sid,
system_prompt: None,
@@ -214,10 +228,11 @@ fn read_entry_count_matches_append_tally() {
forked_from: None,
compacted_from: None,
},
LogEntry::UserInput {
LogEntry::AnnotatedUserInput {
ts: 2000,
extensions: vec![],
segments: vec![protocol::Segment::text("Hello")],
history: vec![annotated(Item::user_message("Hello"))],
},
];
@@ -254,10 +269,11 @@ fn unterminated_utf8_tail_is_ignored_and_replaced_on_append() {
assert_eq!(store.read_all(sid, segid).unwrap().len(), 1);
assert_eq!(store.read_entry_count(sid, segid).unwrap(), 1);
let next = LogEntry::UserInput {
let next = LogEntry::AnnotatedUserInput {
ts: 2,
extensions: vec![],
segments: vec![protocol::Segment::text("recovered")],
history: vec![annotated(Item::user_message("recovered"))],
};
store.append(sid, segid, &next).unwrap();
+67 -26
View File
@@ -16,6 +16,21 @@ use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state};
// Helpers
// =============================================================================
fn annotated(items: &[Item]) -> Vec<session_store::LoggedHistoryEntry> {
items
.iter()
.cloned()
.map(|item| session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::from(item),
metadata: session_store::LoggedSessionHistoryMetadata {
entry_id: session_store::LoggedSessionHistoryEntryId::new(),
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
})
.collect()
}
fn simple_text_events() -> Vec<Event> {
vec![
Event::text_block_start(0),
@@ -144,6 +159,7 @@ async fn run_and_persist(
session_id,
segment_id,
vec![protocol::Segment::text(input)],
annotated(&[Item::user_message(input)]),
)
.unwrap();
@@ -154,8 +170,8 @@ async fn run_and_persist(
worker.engine = locked.unlock();
let projected = worker.history();
let new_items = &projected[history_before..];
session_store::save_delta(store, session_id, segment_id, new_items).unwrap();
let new_items = annotated(&projected[history_before..]);
session_store::save_delta(store, session_id, segment_id, &new_items).unwrap();
session_store::save_turn_end(store, session_id, segment_id, worker.turn_count()).unwrap();
match &result {
@@ -219,7 +235,7 @@ async fn session_run_logs_entries() {
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: &worker.history(),
history: annotated(&worker.history()),
},
)
.unwrap();
@@ -237,7 +253,10 @@ async fn session_run_logs_entries() {
);
// First entry is SegmentStart
assert!(matches!(&entries[0], LogEntry::SegmentStart { .. }));
assert!(matches!(
&entries[0],
LogEntry::AnnotatedSegmentStart { .. }
));
// Has a RunCompleted with Finished
let has_finished = entries.iter().any(|e| {
@@ -264,7 +283,7 @@ async fn session_restore_round_trip() {
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: &worker.history(),
history: annotated(&worker.history()),
},
)
.unwrap();
@@ -303,7 +322,7 @@ async fn session_run_with_tool_call() {
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: &worker.history(),
history: annotated(&worker.history()),
},
)
.unwrap();
@@ -314,12 +333,12 @@ async fn session_run_with_tool_call() {
let has_tool_results = entries
.iter()
.any(|e| matches!(e, LogEntry::ToolResult { .. }));
.any(|e| matches!(e, LogEntry::AnnotatedToolResult { .. }));
assert!(has_tool_results, "should have ToolResult entry");
let has_assistant = entries
.iter()
.any(|e| matches!(e, LogEntry::AssistantItem { .. }));
.any(|e| matches!(e, LogEntry::AnnotatedAssistantItem { .. }));
assert!(has_assistant, "should have AssistantItem entry");
}
@@ -338,7 +357,7 @@ async fn session_resume_after_pause() {
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: &worker.history(),
history: annotated(&worker.history()),
},
)
.unwrap();
@@ -377,7 +396,7 @@ async fn session_fork_creates_new_session() {
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: &worker.history(),
history: annotated(&worker.history()),
},
)
.unwrap();
@@ -390,7 +409,7 @@ async fn session_fork_creates_new_session() {
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: &worker.history(),
history: annotated(&worker.history()),
},
)
.unwrap();
@@ -399,7 +418,10 @@ async fn session_fork_creates_new_session() {
// Fork should have a SegmentStart with the current history
let fork_entries = store.read_all(fork_sid, fork_segid).unwrap();
assert_eq!(fork_entries.len(), 1);
assert!(matches!(&fork_entries[0], LogEntry::SegmentStart { .. }));
assert!(matches!(
&fork_entries[0],
LogEntry::AnnotatedSegmentStart { .. }
));
let fork_state = collect_state(&fork_entries);
assert_eq!(fork_state.session_id, Some(fork_sid));
@@ -418,7 +440,7 @@ async fn session_fork_at_truncates_within_session() {
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: &worker.history(),
history: annotated(&worker.history()),
},
)
.unwrap();
@@ -445,6 +467,23 @@ async fn session_fork_at_truncates_within_session() {
.expect("source segment has the matching TurnEnd");
let source_state_at_fork = collect_state(&all_entries[..=turn_end_pos]);
assert_eq!(fork_state.history.len(), source_state_at_fork.history.len());
assert_eq!(
fork_state.annotated_history, source_state_at_fork.annotated_history,
"fork_at must preserve every retained history entry identity and provenance",
);
assert!(fork_state.annotated_history.iter().all(|entry| {
!entry.metadata.entry_id.0.is_empty()
&& matches!(
entry.metadata.origin,
session_store::LoggedSessionHistoryOrigin::LegacyUnknown
| session_store::LoggedSessionHistoryOrigin::HumanInput { .. }
| session_store::LoggedSessionHistoryOrigin::WorkerInput { .. }
| session_store::LoggedSessionHistoryOrigin::BackendInstruction { .. }
| session_store::LoggedSessionHistoryOrigin::ModelOutput { .. }
| session_store::LoggedSessionHistoryOrigin::ToolOutput { .. }
| session_store::LoggedSessionHistoryOrigin::DerivedSummary
)
}));
// list_segments should show both source and fork in the same Session.
let segs = store.list_segments(sid).unwrap();
@@ -463,7 +502,7 @@ async fn session_config_changed_logged() {
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: &worker.history(),
history: annotated(&worker.history()),
},
)
.unwrap();
@@ -496,7 +535,7 @@ async fn session_auto_forks_on_conflict() {
SegmentStartState {
system_prompt: worker_a.get_system_prompt(),
config: worker_a.request_config(),
history: &worker_a.history(),
history: annotated(&worker_a.history()),
},
)
.unwrap();
@@ -505,12 +544,14 @@ async fn session_auto_forks_on_conflict() {
let mut entries_written: usize = 1;
// Simulate another Worker writing to the same segment behind our back.
let extra_entry = LogEntry::UserInput {
ts: 9999,
extensions: vec![],
segments: vec![protocol::Segment::text("Interloper")],
};
store.append(sid, original_segid, &extra_entry).unwrap();
session_store::save_user_input(
&store,
sid,
original_segid,
vec![protocol::Segment::text("Interloper")],
annotated(&[Item::user_message("Interloper")]),
)
.unwrap();
// Now the on-disk count exceeds our tally — ensure_head_or_fork should auto-fork.
session_store::ensure_head_or_fork(
@@ -522,7 +563,7 @@ async fn session_auto_forks_on_conflict() {
SegmentStartState {
system_prompt: worker_a.get_system_prompt(),
config: worker_a.request_config(),
history: &worker_a.history(),
history: annotated(&worker_a.history()),
},
)
.unwrap();
@@ -543,7 +584,7 @@ async fn session_auto_forks_on_conflict() {
// The new segment records its lineage forward via forked_from; the
// source segment is left immutable (no terminal marker written back).
match &fork_entries[0] {
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
forked_from: Some(origin),
..
} => {
@@ -563,7 +604,7 @@ async fn session_auto_forks_on_conflict() {
);
let has_interloper = original_entries
.iter()
.any(|e| matches!(e, LogEntry::UserInput { .. }));
.any(|e| matches!(e, LogEntry::AnnotatedUserInput { .. }));
assert!(has_interloper);
}
@@ -581,7 +622,7 @@ async fn nested_past_fork_leaves_ancestors_immutable() {
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: &worker.history(),
history: annotated(&worker.history()),
},
)
.unwrap();
@@ -618,7 +659,7 @@ async fn nested_past_fork_leaves_ancestors_immutable() {
// fork2's lineage points at fork1, not the root.
match &store.read_all(sid, fork2).unwrap()[0] {
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
forked_from: Some(origin),
..
} => assert_eq!(origin.segment_id, fork1),
+1 -1
View File
@@ -1546,7 +1546,7 @@ fn model_ticket_reference(
match ticket.meta.resource_key {
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
Some(_) => Err(ToolError::ExecutionFailed(format!(
"{tool_name} failed: required Ticket human key is unavailable"
"{tool_name} failed: required Ticket key is unavailable"
))),
None => Ok(ticket.meta.id),
}
+1 -1
View File
@@ -129,7 +129,7 @@ pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(GrepParams);
let meta = ToolMeta::new("Grep")
.description("Search Workdir file contents with a regex. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
.description("Search Workdir file contents with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
session: session.clone(),
+173 -192
View File
@@ -765,7 +765,7 @@ impl App {
fn method_for_run(&mut self, segments: Vec<Segment>) -> Method {
// TurnHeader / UserMessage blocks are pushed only after the Worker
// emits `Event::UserMessage` from a committed `LogEntry::UserInput`.
// emits `Event::UserMessage` from a committed `LogEntry::AnnotatedUserInput`.
// Locally we only clear the input buffer and forward the method,
// while remembering enough local state to undo the visible submit if
// the accepted run produced no assistant output and was rolled back.
@@ -1098,10 +1098,9 @@ impl App {
self.blocks.push(Block::UserMessage { segments });
self.assistant_streaming = false;
}
Event::SegmentRotated { entry } => {
Event::SegmentRotated { session } => {
let retained_run_errors = self.run_error_messages.clone();
self.reset_for_rotation();
self.apply_log_entry_raw(&entry);
self.restore_session(&session, self.greeting.clone());
for message in retained_run_errors {
self.blocks.push(Block::Alert {
level: AlertLevel::Error,
@@ -1408,14 +1407,14 @@ impl App {
self.latest_memory_worker_event = Some(event.message);
}
Event::Snapshot {
entries,
session,
greeting,
status,
in_flight,
internal_workers,
} => {
self.rewind_refresh_fence = false;
self.restore_snapshot(&entries, greeting, in_flight);
self.restore_snapshot(&session, greeting, in_flight);
self.replace_internal_worker_snapshots(internal_workers);
self.set_worker_status(status);
}
@@ -1455,11 +1454,11 @@ impl App {
}
}
Event::RewindApplied {
entries,
session,
input,
summary,
} => {
self.restore_rewind_snapshot(&entries);
self.restore_rewind_snapshot(&session);
self.rewind_refresh_fence = true;
let restored_composer = if self.input.is_empty() {
self.input.replace_with_segments(&input);
@@ -2173,7 +2172,7 @@ impl App {
) -> InternalWorkerView {
let mut app = App::new(snapshot.worker.name.clone());
app.mode = mode;
app.restore_entries(&snapshot.entries, None);
app.restore_session(&snapshot.session, None);
app.apply_in_flight_snapshot(snapshot.in_flight);
app.set_worker_status(snapshot.status);
if let Some(error) = snapshot.error {
@@ -2254,14 +2253,14 @@ impl App {
fn restore_snapshot(
&mut self,
entries: &[serde_json::Value],
session: &protocol::SessionSnapshot,
greeting: protocol::Greeting,
in_flight: InFlightSnapshot,
) {
self.greeting = Some(greeting.clone());
self.context_window = greeting.context_window;
self.session_context_tokens = greeting.context_tokens;
self.restore_entries(entries, Some(greeting));
self.restore_session(session, Some(greeting));
self.apply_in_flight_snapshot(in_flight);
}
@@ -2270,7 +2269,7 @@ impl App {
/// session tail; always clear/replay from it even if this TUI instance has
/// somehow lost connect-time greeting metadata. Skipping the restore in
/// that case would leave old post-target output visible after success.
fn restore_rewind_snapshot(&mut self, entries: &[serde_json::Value]) {
fn restore_rewind_snapshot(&mut self, session: &protocol::SessionSnapshot) {
let greeting = self.greeting.clone().or_else(|| {
self.blocks.iter().find_map(|b| match b {
Block::Greeting(g) => Some(g.clone()),
@@ -2283,7 +2282,7 @@ impl App {
self.session_context_tokens = greeting.context_tokens;
}
let missing_greeting = greeting.is_none();
self.restore_entries(entries, greeting);
self.restore_session(session, greeting);
if missing_greeting {
self.blocks.push(Block::Alert {
level: AlertLevel::Warn,
@@ -2293,9 +2292,9 @@ impl App {
}
}
fn restore_entries(
fn restore_session(
&mut self,
entries: &[serde_json::Value],
session: &protocol::SessionSnapshot,
greeting: Option<protocol::Greeting>,
) {
self.run_error_messages.clear();
@@ -2309,137 +2308,90 @@ impl App {
}
self.assistant_streaming = false;
for entry in entries {
self.apply_log_entry_raw(entry);
for entry in &session.entries {
use protocol::{SessionContentPart, SessionMessageRole, SessionSnapshotEntryData};
match &entry.data {
SessionSnapshotEntryData::UserInput { segments } => {
self.turn_index += 1;
self.blocks.push(Block::TurnHeader {
turn: self.turn_index,
});
if !segments.is_empty() {
self.blocks.push(Block::UserMessage {
segments: segments.clone(),
});
}
}
SessionSnapshotEntryData::Message { role, content } => {
let role = match role {
SessionMessageRole::User => agen::Role::User,
SessionMessageRole::Assistant => agen::Role::Assistant,
};
let item = agen::Item::Message {
id: None,
role,
content: content
.iter()
.map(|part| match part {
SessionContentPart::Text { text } => {
agen::ContentPart::Text { text: text.clone() }
}
SessionContentPart::Refusal { refusal } => {
agen::ContentPart::Refusal {
refusal: refusal.clone(),
}
}
})
.collect(),
status: None,
};
let value = serde_json::to_value(item).expect("Item is Serialize");
self.push_history_item(&value);
}
SessionSnapshotEntryData::ToolCall {
call_id,
name,
arguments,
} => {
let item =
agen::Item::tool_call(call_id.clone(), name.clone(), arguments.clone());
let value = serde_json::to_value(item).expect("Item is Serialize");
self.push_history_item(&value);
}
SessionSnapshotEntryData::ToolResult {
call_id,
summary,
content,
is_error,
..
} => {
let item = agen::Item::tool_result_item(
call_id.clone(),
summary.clone(),
content.clone(),
*is_error,
);
let value = serde_json::to_value(item).expect("Item is Serialize");
self.push_history_item(&value);
}
SessionSnapshotEntryData::SystemItem { data, .. } => {
if let Some(data) = data {
self.apply_system_item(data);
}
}
SessionSnapshotEntryData::RunError { message } => {
self.push_run_error(message.clone());
}
}
}
self.mark_orphan_tool_calls_incomplete_pass();
}
/// Drop the derived view in preparation for replaying a new
/// `SegmentStart` (compaction / fork). Greeting is preserved
/// because the Worker identity hasn't changed.
fn reset_for_rotation(&mut self) {
let greeting = self.blocks.iter().find_map(|b| match b {
Block::Greeting(g) => Some(g.clone()),
_ => None,
});
self.turn_index = 0;
self.blocks.clear();
self.cache = FileCache::new();
self.task_store = TaskStore::new();
self.task_pane_scroll = 0;
if let Some(g) = greeting {
self.greeting = Some(g.clone());
self.blocks.push(Block::Greeting(g));
}
}
/// Walk a single `LogEntry` JSON value and translate it into blocks
/// the live event path would have produced. Shared between
/// `restore_snapshot` (replay path) and `apply_log_entry` (live
/// path).
fn apply_log_entry_raw(&mut self, value: &serde_json::Value) {
let Ok(entry) = serde_json::from_value::<session_store::LogEntry>(value.clone()) else {
return;
};
match entry {
session_store::LogEntry::SegmentStart { history, .. } => {
for logged in history {
let item: agen::Item = logged.into();
let item_value = serde_json::to_value(&item).expect("Item is Serialize");
self.push_history_item(&item_value);
}
}
session_store::LogEntry::UserInput { segments, .. } => {
self.turn_index += 1;
self.blocks.push(Block::TurnHeader {
turn: self.turn_index,
});
if !segments.is_empty() {
self.blocks.push(Block::UserMessage { segments });
}
}
session_store::LogEntry::AssistantItem { item, .. }
| session_store::LogEntry::ToolResult { item, .. } => {
let it: agen::Item = item.into();
let item_value = serde_json::to_value(&it).expect("Item is Serialize");
self.push_history_item(&item_value);
}
session_store::LogEntry::SystemItem { item, .. } => {
let value = serde_json::to_value(&item).expect("SystemItem is Serialize");
self.apply_system_item(&value);
}
session_store::LogEntry::Extension {
domain, payload, ..
} if domain == "yoi.compaction" => {
self.apply_compaction_extension(&payload);
}
session_store::LogEntry::RunErrored { message, .. } => {
self.push_run_error(message);
}
// Non-history-bearing variants don't affect the block view.
_ => {}
}
}
/// Dispatch one `SystemItem` JSON value into the appropriate block.
///
/// Kind-based routing replaces the old free-text `[Notification]` /
/// `[File: …]` parsing path: each kind maps directly to a typed
/// block (`Block::Notify`, `Block::WorkerEvent`, …).
fn apply_compaction_extension(&mut self, payload: &serde_json::Value) {
if payload.get("kind").and_then(|value| value.as_str()) != Some("compaction_block") {
return;
}
match payload.get("state").and_then(|value| value.as_str()) {
Some("running") => {
if self.last_streaming_compact_mut().is_none() {
self.blocks.push(Block::Compact(CompactEvent::Streaming {
started_at: Instant::now(),
}));
}
}
Some("done") => {
let new_segment_id = payload
.get("new_segment_id")
.and_then(|value| value.as_str())
.and_then(|value| value.parse::<uuid::Uuid>().ok())
.unwrap_or_else(uuid::Uuid::nil);
if let Some(evt) = self.last_streaming_compact_mut() {
*evt = CompactEvent::Done {
new_segment_id,
elapsed_secs: None,
};
} else {
self.blocks.push(Block::Compact(CompactEvent::Done {
new_segment_id,
elapsed_secs: None,
}));
}
}
Some("failed") => {
let error = payload
.get("error")
.and_then(|value| value.as_str())
.unwrap_or("compact failed")
.to_string();
if let Some(evt) = self.last_streaming_compact_mut() {
*evt = CompactEvent::Failed {
error,
elapsed_secs: None,
};
} else {
self.blocks.push(Block::Compact(CompactEvent::Failed {
error,
elapsed_secs: None,
}));
}
}
_ => {}
}
}
fn apply_system_item(&mut self, value: &serde_json::Value) {
let Ok(item) = serde_json::from_value::<session_store::SystemItem>(value.clone()) else {
// Unknown / forward-compat shape: fall back to rendering the
@@ -2542,6 +2494,15 @@ fn fmt_millis(ms: u64) -> String {
}
}
#[cfg(test)]
fn public_session(values: Vec<serde_json::Value>) -> protocol::SessionSnapshot {
let entries = values
.into_iter()
.map(|value| serde_json::from_value(value).expect("LogEntry deserializes"))
.collect::<Vec<session_store::LogEntry>>();
session_store::public_snapshot::project_current_session_snapshot(&entries)
}
fn message_text(item: &serde_json::Value) -> String {
item["content"]
.as_array()
@@ -2685,7 +2646,7 @@ mod rewind_refresh_tests {
});
app.handle_worker_event(Event::RewindApplied {
entries: vec![],
session: protocol::SessionSnapshot { entries: vec![] },
input: vec![Segment::text("selected rewind input")],
summary: summary(3),
});
@@ -2704,7 +2665,7 @@ mod rewind_refresh_tests {
});
app.handle_worker_event(Event::RewindApplied {
entries: vec![],
session: protocol::SessionSnapshot { entries: vec![] },
input: vec![Segment::text("rewound input")],
summary: summary(1),
});
@@ -2747,7 +2708,7 @@ mod rewind_refresh_tests {
});
app.handle_worker_event(Event::RewindApplied {
entries: vec![],
session: protocol::SessionSnapshot { entries: vec![] },
input: vec![Segment::text("rewound input")],
summary: summary(2),
});
@@ -2976,6 +2937,17 @@ mod composer_history_persistence_tests {
mod completion_flow_tests {
use super::*;
fn annotated(item: agen::Item) -> session_store::LoggedHistoryEntry {
session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::from(item),
metadata: session_store::LoggedSessionHistoryMetadata {
entry_id: session_store::LoggedSessionHistoryEntryId::new(),
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
}
}
#[test]
fn typing_at_creates_completion_state_and_emits_query() {
let mut app = App::new("test".into());
@@ -3278,7 +3250,7 @@ mod completion_flow_tests {
#[test]
fn committed_user_message_survives_fresh_segment_rotation() {
let mut app = App::new("test".into());
let start = session_store::LogEntry::SegmentStart {
let start = session_store::LogEntry::AnnotatedSegmentStart {
ts: session_store::segment_log::now_millis(),
session_id: uuid::Uuid::nil(),
system_prompt: None,
@@ -3289,7 +3261,9 @@ mod completion_flow_tests {
};
app.handle_worker_event(Event::SegmentRotated {
entry: serde_json::to_value(start).expect("LogEntry is Serialize"),
session: public_session(vec![
serde_json::to_value(start).expect("LogEntry is Serialize"),
]),
});
app.handle_worker_event(Event::UserMessage {
segments: vec![Segment::text("first persisted message")],
@@ -3533,23 +3507,23 @@ mod completion_flow_tests {
}
#[test]
fn snapshot_renders_system_message_block_from_session_start() {
fn snapshot_excludes_system_prompt_history_from_public_blocks() {
let mut app = App::new("test".into());
let session_start = session_store::LogEntry::SegmentStart {
let session_start = session_store::LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: Default::default(),
history: vec![session_store::LoggedItem::from(
&agen::Item::system_message("[File: src/main.rs]\nfn main() {}"),
)],
history: vec![annotated(agen::Item::system_message(
"[File: src/main.rs]\nfn main() {}",
))],
forked_from: None,
compacted_from: None,
};
let session_start_value = serde_json::to_value(&session_start).unwrap();
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
entries: vec![session_start_value],
session: public_session(vec![session_start_value]),
status: WorkerStatus::Running,
in_flight: Default::default(),
internal_workers: Vec::new(),
@@ -3557,10 +3531,8 @@ mod completion_flow_tests {
assert!(matches!(app.worker_status, WorkerStatus::Running));
assert!(app.running);
assert!(matches!(
app.blocks.get(1),
Some(Block::SystemMessage { text }) if text == "[File: src/main.rs]\nfn main() {}"
));
assert_eq!(app.blocks.len(), 1);
assert!(matches!(app.blocks.first(), Some(Block::Greeting(_))));
}
#[test]
@@ -3595,7 +3567,7 @@ mod completion_flow_tests {
};
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
entries: vec![serde_json::to_value(run_errored).unwrap()],
session: public_session(vec![serde_json::to_value(run_errored).unwrap()]),
status: WorkerStatus::Idle,
in_flight: Default::default(),
internal_workers: Vec::new(),
@@ -3623,7 +3595,7 @@ mod completion_flow_tests {
code: ErrorCode::ProviderError,
message: "provider unavailable".into(),
});
let segment_start = session_store::LogEntry::SegmentStart {
let segment_start = session_store::LogEntry::AnnotatedSegmentStart {
ts: 5,
session_id: uuid::Uuid::nil(),
system_prompt: None,
@@ -3633,7 +3605,7 @@ mod completion_flow_tests {
compacted_from: None,
};
app.handle_worker_event(Event::SegmentRotated {
entry: serde_json::to_value(segment_start).unwrap(),
session: public_session(vec![serde_json::to_value(segment_start).unwrap()]),
});
let errors = app
@@ -3656,7 +3628,9 @@ mod completion_flow_tests {
let mut app = App::new("test".into());
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
status: WorkerStatus::Running,
in_flight: InFlightSnapshot {
blocks: vec![
@@ -3762,7 +3736,9 @@ mod completion_flow_tests {
},
revision,
status: WorkerStatus::Idle,
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
in_flight: protocol::InFlightSnapshot::default(),
error: None,
internal_workers: Vec::new(),
@@ -3977,7 +3953,9 @@ mod completion_flow_tests {
assert_eq!(app.selected_worker_view().worker_name, "parent");
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
status: WorkerStatus::Idle,
in_flight: Default::default(),
internal_workers: Vec::new(),
@@ -4026,7 +4004,9 @@ mod completion_flow_tests {
});
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
status: WorkerStatus::Idle,
in_flight: Default::default(),
internal_workers: vec![InternalWorkerSnapshot {
@@ -4037,7 +4017,9 @@ mod completion_flow_tests {
kind: protocol::InternalWorkerKind::SubWorker,
},
revision: 4,
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
status: WorkerStatus::Running,
error: None,
in_flight: Default::default(),
@@ -4193,7 +4175,9 @@ mod completion_flow_tests {
greeting.context_tokens = 45_000;
app.handle_worker_event(Event::Snapshot {
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
greeting,
status: WorkerStatus::Idle,
in_flight: Default::default(),
@@ -4363,40 +4347,37 @@ mod completion_flow_tests {
});
let assistant_item_entries = vec![
serde_json::json!({
"kind": "assistant_item",
"ts": 1,
"item": {
"kind": "tool_call",
"call_id": "c1",
"name": "TaskCreate",
"arguments": r#"{"subject":"a","description":"A"}"#,
},
}),
serde_json::json!({
"kind": "assistant_item",
"ts": 2,
"item": {
"kind": "tool_call",
"call_id": "c2",
"name": "TaskCreate",
"arguments": r#"{"subject":"b","description":"B"}"#,
},
}),
serde_json::json!({
"kind": "assistant_item",
"ts": 3,
"item": {
"kind": "tool_call",
"call_id": "u1",
"name": "TaskUpdate",
"arguments": r#"{"taskid":2,"status":"inprogress"}"#,
},
}),
serde_json::to_value(session_store::LogEntry::AnnotatedAssistantItem {
ts: 1,
entry: annotated(agen::Item::tool_call(
"c1",
"TaskCreate",
r#"{"subject":"a","description":"A"}"#,
)),
})
.unwrap(),
serde_json::to_value(session_store::LogEntry::AnnotatedAssistantItem {
ts: 2,
entry: annotated(agen::Item::tool_call(
"c2",
"TaskCreate",
r#"{"subject":"b","description":"B"}"#,
)),
})
.unwrap(),
serde_json::to_value(session_store::LogEntry::AnnotatedAssistantItem {
ts: 3,
entry: annotated(agen::Item::tool_call(
"u1",
"TaskUpdate",
r#"{"taskid":2,"status":"inprogress"}"#,
)),
})
.unwrap(),
];
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
entries: assistant_item_entries,
session: public_session(assistant_item_entries),
status: WorkerStatus::Running,
in_flight: Default::default(),
internal_workers: Vec::new(),
+10 -6
View File
@@ -547,7 +547,9 @@ async fn run_e2e_rewind_fixture(
let mut app = App::new_with_persistent_input_history(worker_name.clone(), &workspace_root);
app.connected = true;
app.handle_worker_event(Event::Snapshot {
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
status: WorkerStatus::Idle,
greeting: Greeting {
worker_name: worker_name.clone(),
@@ -673,7 +675,9 @@ async fn run_e2e_rewind_fixture(
if let Some(submitted_at) = pending_apply {
if submitted_at.elapsed() >= apply_delay {
app.handle_worker_event(Event::RewindApplied {
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
input: vec![Segment::text("rewind-live-refresh")],
summary: RewindSummary {
truncated_to_entries: 1,
@@ -2023,13 +2027,13 @@ mod tests {
let mut app = App::new("agent".to_string());
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
entries: vec![],
session: protocol::SessionSnapshot { entries: vec![] },
status: WorkerStatus::Idle,
in_flight: Default::default(),
internal_workers: Vec::new(),
});
app.handle_worker_event(Event::RewindApplied {
entries: vec![],
session: protocol::SessionSnapshot { entries: vec![] },
input: vec![Segment::Text {
content: "retry this".into(),
}],
@@ -2050,7 +2054,7 @@ mod tests {
let mut app = App::new("agent".to_string());
app.handle_worker_event(Event::Snapshot {
greeting: test_greeting(),
entries: vec![],
session: protocol::SessionSnapshot { entries: vec![] },
status: WorkerStatus::Idle,
in_flight: Default::default(),
internal_workers: Vec::new(),
@@ -2058,7 +2062,7 @@ mod tests {
type_keys(&mut app, "draft");
app.handle_worker_event(Event::RewindApplied {
entries: vec![],
session: protocol::SessionSnapshot { entries: vec![] },
input: vec![Segment::Text {
content: "retry this".into(),
}],
+6 -2
View File
@@ -849,7 +849,9 @@ async fn ticket_queue_notification_sends_notify_when_socket_available() {
let mut writer = JsonLineWriter::new(writer);
writer
.write(&Event::Snapshot {
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
greeting: protocol::Greeting {
worker_name: "test-orchestrator".to_string(),
cwd: temp.path().display().to_string(),
@@ -891,7 +893,9 @@ async fn send_notify_only_can_deliver_weak_notification_without_auto_run() {
let mut writer = JsonLineWriter::new(writer);
writer
.write(&Event::Snapshot {
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
greeting: protocol::Greeting {
worker_name: "yoi".to_string(),
cwd: temp.path().display().to_string(),
+15 -3
View File
@@ -623,6 +623,17 @@ mod tests {
const SOURCE: WorkerVisibilitySource = WorkerVisibilitySource::ResumePicker;
fn annotated(item: agen::Item) -> session_store::LoggedHistoryEntry {
session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::from(item),
metadata: session_store::LoggedSessionHistoryMetadata {
entry_id: session_store::LoggedSessionHistoryEntryId::new(),
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
}
}
#[test]
fn stored_metadata_summary_uses_segment_marker_without_reading_session_log() {
let dir = tempdir().unwrap();
@@ -910,7 +921,7 @@ mod tests {
timestamp_ms: 0,
}),
Event::Snapshot {
entries: vec![],
session: protocol::SessionSnapshot { entries: vec![] },
greeting: test_greeting(),
status: WorkerStatus::Idle,
in_flight: Default::default(),
@@ -1207,7 +1218,7 @@ mod tests {
.append(
session_id,
segment_id,
&LogEntry::SegmentStart {
&LogEntry::AnnotatedSegmentStart {
ts,
session_id,
system_prompt: None,
@@ -1231,9 +1242,10 @@ mod tests {
.append(
session_id,
segment_id,
&LogEntry::UserInput {
&LogEntry::AnnotatedUserInput {
ts,
segments: vec![protocol::Segment::text(text)],
history: vec![annotated(agen::Item::user_message(text))],
extensions: vec![],
},
)
+20 -5
View File
@@ -1530,7 +1530,9 @@ impl Runtime {
}
}
Ok(protocol::Event::Snapshot {
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
greeting: protocol::Greeting {
worker_name: worker_ref.worker_id.to_string(),
cwd: String::new(),
@@ -3152,7 +3154,9 @@ mod tests {
),
);
let snapshot = protocol::Event::Snapshot {
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
greeting: protocol::Greeting {
worker_name: "parent".to_string(),
cwd: "/tmp".to_string(),
@@ -4581,7 +4585,17 @@ mod tests {
backend.set_worker_snapshot(
&detail.worker_ref,
protocol::Event::Snapshot {
entries: vec![expected_entry.clone()],
session: protocol::SessionSnapshot {
entries: vec![protocol::SessionSnapshotEntry {
entry_id: "restored-log-entry".to_owned(),
timestamp: 1,
provenance: protocol::SessionEntryProvenance::LegacyUnknown,
derived_from: Vec::new(),
data: protocol::SessionSnapshotEntryData::RunError {
message: expected_entry.to_string(),
},
}],
},
greeting: protocol::Greeting {
worker_name: "live-worker".to_string(),
cwd: "/tmp/live".to_string(),
@@ -4606,12 +4620,13 @@ mod tests {
.unwrap();
match snapshot {
protocol::Event::Snapshot {
entries,
session,
greeting,
status,
..
} => {
assert_eq!(entries, vec![expected_entry]);
assert_eq!(session.entries.len(), 1);
assert_eq!(session.entries[0].entry_id, "restored-log-entry");
assert_eq!(greeting.worker_name, "live-worker");
assert_eq!(status, protocol::WorkerStatus::Running);
}
+34 -18
View File
@@ -37,7 +37,7 @@ use crate::working_directory::{
WorkingDirectoryBinding, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer,
};
use async_trait::async_trait;
use protocol::{Event, Method, Segment, WorkerStatus};
use protocol::{ErrorCode, Event, Method, Segment, WorkerStatus};
use session_store::{CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore};
#[cfg(test)]
use session_store::{FsStore, FsWorkerStore};
@@ -67,8 +67,7 @@ const USER_INPUT_COMMIT_TIMEOUT: Duration = Duration::from_secs(9);
fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
let extensions = match entry {
LogEntry::UserInput { extensions, .. }
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
_ => return false,
};
extensions.iter().any(|extension| {
@@ -1443,7 +1442,6 @@ where
let streams = subscribe_worker_protocol_session(&handle);
let mut events = streams.events;
let mut entry_events = streams.log_entries;
let bridge_handle = handle.clone();
let bridge_busy = busy.clone();
if let Err(message) = self.spawn_on_adapter_runtime(async move {
loop {
@@ -1451,12 +1449,28 @@ where
event = events.recv() => {
match event {
Ok(event) => {
let next_busy = match &event {
Event::InvokeStart { .. }
| Event::Status {
status: WorkerStatus::Running,
} => Some(true),
Event::RunEnd { .. }
| Event::Error {
code: ErrorCode::NotPaused,
..
}
| Event::Status {
status:
WorkerStatus::Idle
| WorkerStatus::Paused
| WorkerStatus::Stopped,
}
| Event::Shutdown => Some(false),
_ => None,
};
let _ = bridge_context.publish_protocol_event(event);
if matches!(
bridge_handle.shared_state.get_status(),
WorkerStatus::Idle | WorkerStatus::Paused
) {
bridge_busy.store(false, Ordering::SeqCst);
if let Some(next_busy) = next_busy {
bridge_busy.store(next_busy, Ordering::SeqCst);
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
@@ -2566,18 +2580,22 @@ mod tests {
) {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let matches = {
let observed = {
let workers = backend.workers.lock().unwrap();
let execution = workers.get(worker_ref).expect("live Worker execution");
execution.handle.shared_state.get_status() == expected_status
&& execution.busy.load(Ordering::SeqCst) == expected_busy
(
execution.handle.shared_state.get_status(),
execution.busy.load(Ordering::SeqCst),
)
};
if matches {
if observed == (expected_status, expected_busy) {
return;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for adapter state {expected_status:?}, busy={expected_busy}"
"timed out waiting for adapter state {expected_status:?}, busy={expected_busy}; last observed status={:?}, busy={}",
observed.0,
observed.1,
);
std::thread::sleep(Duration::from_millis(10));
}
@@ -3244,8 +3262,7 @@ mod tests {
assert!(entries.iter().any(|entry| {
matches!(
entry,
LogEntry::UserInput { segments, .. }
| LogEntry::AnnotatedUserInput { segments, .. }
LogEntry::AnnotatedUserInput { segments, .. }
if segments == &vec![Segment::text("start the ticket")]
)
}));
@@ -3253,8 +3270,7 @@ mod tests {
.iter()
.find_map(|entry| {
let extensions = match entry {
LogEntry::UserInput { extensions, .. }
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
_ => return None,
};
extensions
+18 -39
View File
@@ -84,10 +84,7 @@ impl WorkerHandle {
(entries, entry_rx, in_flight)
};
let event = Event::Snapshot {
entries: entries
.into_iter()
.map(|entry| serde_json::to_value(entry).expect("log entry serializes"))
.collect(),
session: session_store::public_snapshot::project_current_session_snapshot(&entries),
greeting: self.shared_state.greeting.clone(),
status: self.shared_state.get_status(),
in_flight,
@@ -634,8 +631,8 @@ fn protocol_command_status(status: WorkdirCommandStatus) -> ProtocolCommandStatu
///
/// `Worker::wire_history_persistence` is called separately to wire the
/// per-item history commit callback so every assistant / tool item
/// landing in `worker.history` becomes a singular `LogEntry::AssistantItem`
/// / `ToolResult` commit through the sync writer.
/// landing in `worker.history` becomes a singular `LogEntry::AnnotatedAssistantItem`
/// / `AnnotatedToolResult` commit through the sync writer.
pub(crate) fn wire_event_bridges_on_engine<C, St>(
worker: &mut Worker<C, St>,
event_tx: &broadcast::Sender<Event>,
@@ -998,14 +995,6 @@ where
if feature_config.sub_worker.enabled {
worker.register_worker_orchestration_instruction();
if !feature_config.worker.enabled {
feature_registry.add_module(
crate::feature::builtin::manage_worker::sub_worker_control_feature(
worker.workspace_client_handle(),
spawned_registry.clone(),
),
);
}
}
let host_worker_observation_provider = worker.worker_observation_provider();
@@ -1328,7 +1317,7 @@ async fn controller_loop<C, St>(
}
// Stage the run without a speculative user-message echo.
// `Worker::run` validates the input, commits
// `LogEntry::UserInput`, and the session-log sink turns that
// `LogEntry::AnnotatedUserInput`, and the session-log sink turns that
// committed entry into the live `Event::UserMessage`. That
// keeps every client ordered against `SegmentStart` replay and
// makes persisted history the single source of visible user
@@ -1354,7 +1343,7 @@ async fn controller_loop<C, St>(
Method::Notify { message, auto_run } => {
// Client-side live echo is delivered as `Event::SystemItem`
// once the interceptor commits the corresponding
// `LogEntry::SystemItem` entry — drained out of the
// `LogEntry::AnnotatedSystemItem` entry — drained out of the
// notify buffer + broadcast through the sink. No
// separate echo here.
worker.push_notify(message, auto_run);
@@ -1882,28 +1871,16 @@ where
St: Store,
{
match worker.rewind_to(target, expected_head_entries) {
Ok(applied) => match applied
.entries
.into_iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()
{
Ok(entries) => {
let _ = event_tx.send(Event::RewindApplied {
entries,
input: applied.input,
summary: applied.summary,
});
true
}
Err(error) => {
let _ = event_tx.send(Event::Error {
code: ErrorCode::Internal,
message: format!("failed to encode rewind snapshot: {error}"),
});
false
}
},
Ok(applied) => {
let session =
session_store::public_snapshot::project_current_session_snapshot(&applied.entries);
let _ = event_tx.send(Event::RewindApplied {
session,
input: applied.input,
summary: applied.summary,
});
true
}
Err(err) => {
let _ = event_tx.send(Event::Error {
code: ErrorCode::InvalidRequest,
@@ -2109,7 +2086,9 @@ mod tests {
let mut writer = JsonLineWriter::new(w);
writer
.write(&Event::Snapshot {
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
greeting: protocol::Greeting {
worker_name: "parent".into(),
cwd: "/tmp".into(),
+18 -6
View File
@@ -1481,7 +1481,9 @@ mod tests {
let mut writer = JsonLineWriter::new(stream);
writer
.write(&Event::Snapshot {
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
greeting: protocol::Greeting {
worker_name: "target".into(),
cwd: "/tmp".into(),
@@ -1514,7 +1516,9 @@ mod tests {
.unwrap();
writer
.write(&Event::Snapshot {
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
greeting: protocol::Greeting {
worker_name: "target".into(),
cwd: "/tmp".into(),
@@ -1603,7 +1607,9 @@ mod tests {
let mut writer = JsonLineWriter::new(stream);
writer
.write(&Event::Snapshot {
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
greeting: protocol::Greeting {
worker_name: "target".into(),
cwd: "/tmp".into(),
@@ -1627,7 +1633,9 @@ mod tests {
let mut writer = JsonLineWriter::new(writer_half);
writer
.write(&Event::Snapshot {
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
greeting: protocol::Greeting {
worker_name: "target".into(),
cwd: "/tmp".into(),
@@ -1729,7 +1737,9 @@ mod tests {
.unwrap();
writer
.write(&Event::Snapshot {
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
greeting: protocol::Greeting {
worker_name: "alerted".into(),
cwd: "/tmp".into(),
@@ -1779,7 +1789,9 @@ mod tests {
let mut writer = JsonLineWriter::new(stream);
let _ = writer
.write(&Event::Snapshot {
entries: Vec::new(),
session: protocol::SessionSnapshot {
entries: Vec::new(),
},
greeting: protocol::Greeting {
worker_name: "child-live".into(),
cwd: "/tmp".into(),
@@ -963,9 +963,12 @@ permission = "read"
.append(
session_id,
segment_id,
&LogEntry::UserInput {
&LogEntry::AnnotatedUserInput {
ts: 1,
extensions: vec![],
history: vec![crate::session_history::test_logged_history_entry(
agen::Item::user_message("verify current Flow conditions"),
)],
segments: vec![Segment::Text {
content: "verify current Flow conditions".into(),
}],
@@ -185,12 +185,9 @@ impl Tool for StageMemoryCandidateTool {
})?);
}
if matches!(params.kind, CandidateKind::Preference)
&& entries.iter().any(|entry| {
!matches!(
entry.origin,
crate::WorkerHistoryProvenance::HumanInput { .. }
)
})
&& entries
.iter()
.any(|entry| !matches!(entry.origin, protocol::SessionEntryProvenance::HumanInput))
{
return Err(ToolError::InvalidArgument(
"preference candidates require exclusively HumanInput evidence; model, Worker, Flow, backend, derived, and legacy-unknown origins are not preference authority"
@@ -324,10 +321,23 @@ fn evidence_kind(entry: &SessionEntryEvidence) -> EvidenceKind {
}
}
fn evidence_origin(origin: &crate::WorkerHistoryProvenance) -> EvidenceOrigin {
use crate::WorkerHistoryProvenance as Origin;
let mut evidence = EvidenceOrigin {
kind: EvidenceOriginKind::LegacyUnknown,
fn evidence_origin(origin: &protocol::SessionEntryProvenance) -> EvidenceOrigin {
use protocol::SessionEntryProvenance as Origin;
let kind = match origin {
Origin::HumanInput => EvidenceOriginKind::HumanInput,
Origin::WorkerInput => EvidenceOriginKind::WorkerInput,
Origin::FlowInstruction => EvidenceOriginKind::FlowInstruction,
Origin::BackendInstruction => EvidenceOriginKind::BackendInstruction,
Origin::ModelOutput => EvidenceOriginKind::ModelOutput,
Origin::ToolOutput => EvidenceOriginKind::ToolOutput,
Origin::DerivedSummary => EvidenceOriginKind::DerivedSummary,
Origin::LegacyUnknown => EvidenceOriginKind::LegacyUnknown,
};
EvidenceOrigin {
kind,
// The public SessionSnapshot intentionally excludes account, Worker,
// Runtime, and Flow internals. Preserve the authenticated origin class
// without inventing missing control-plane identity fields.
account_id: None,
workspace_id: None,
runtime_id: None,
@@ -335,46 +345,7 @@ fn evidence_origin(origin: &crate::WorkerHistoryProvenance) -> EvidenceOrigin {
flow_selector: None,
flow_definition_id: None,
flow_definition_revision: None,
};
match origin {
Origin::HumanInput { account_id } => {
evidence.kind = EvidenceOriginKind::HumanInput;
evidence.account_id = Some(account_id.clone());
}
Origin::WorkerInput { actor } => {
evidence.kind = EvidenceOriginKind::WorkerInput;
evidence.workspace_id = actor.workspace_id.clone();
evidence.runtime_id = actor.runtime_id.clone();
evidence.worker_id = Some(actor.worker_id.clone());
}
Origin::FlowInstruction {
selector,
definition_id,
definition_revision,
..
} => {
evidence.kind = EvidenceOriginKind::FlowInstruction;
evidence.flow_selector = Some(selector.clone());
evidence.flow_definition_id = Some(definition_id.clone());
evidence.flow_definition_revision = Some(*definition_revision);
}
Origin::BackendInstruction { .. } => evidence.kind = EvidenceOriginKind::BackendInstruction,
Origin::ModelOutput { worker } => {
evidence.kind = EvidenceOriginKind::ModelOutput;
evidence.workspace_id = worker.workspace_id.clone();
evidence.runtime_id = worker.runtime_id.clone();
evidence.worker_id = Some(worker.worker_id.clone());
}
Origin::ToolOutput { worker } => {
evidence.kind = EvidenceOriginKind::ToolOutput;
evidence.workspace_id = worker.workspace_id.clone();
evidence.runtime_id = worker.runtime_id.clone();
evidence.worker_id = Some(worker.worker_id.clone());
}
Origin::DerivedSummary => evidence.kind = EvidenceOriginKind::DerivedSummary,
Origin::LegacyUnknown => evidence.kind = EvidenceOriginKind::LegacyUnknown,
}
evidence
}
fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
@@ -502,12 +473,10 @@ mod tests {
}
#[test]
fn human_origin_projects_account_authority_into_evidence() {
let origin = evidence_origin(&crate::WorkerHistoryProvenance::HumanInput {
account_id: "account-1".into(),
});
fn public_human_origin_preserves_class_without_inventing_account_authority() {
let origin = evidence_origin(&protocol::SessionEntryProvenance::HumanInput);
assert_eq!(origin.kind, EvidenceOriginKind::HumanInput);
assert_eq!(origin.account_id.as_deref(), Some("account-1"));
assert_eq!(origin.account_id, None);
}
#[test]
@@ -209,9 +209,7 @@ impl WorkspaceHttpObjectiveBackend {
.and_then(serde_json::Value::as_str)
.filter(|key| is_canonical_resource_key(key, "T-"))
.map(ToOwned::to_owned)
.ok_or_else(|| {
ToolError::ExecutionFailed("required T- human key is unavailable".to_string())
})
.ok_or_else(|| ToolError::ExecutionFailed("required T- key is unavailable".to_string()))
}
fn objective_url(&self, id: &str) -> String {
@@ -295,7 +293,7 @@ fn is_canonical_resource_key(resource_key: &str, prefix: &str) -> bool {
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
if !is_canonical_resource_key(&response.resource_key, "O-") {
return Err(ToolError::ExecutionFailed(
"required O- human key is unavailable".to_string(),
"required O- key is unavailable".to_string(),
));
}
let projected = serde_json::json!({
@@ -712,7 +710,7 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread")]
async fn objective_show_summary_uses_projected_human_key() {
async fn objective_show_summary_uses_projected_key() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
@@ -764,7 +762,7 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread")]
async fn objective_link_summaries_resolve_internal_ticket_ids_to_human_keys() {
async fn objective_link_summaries_resolve_internal_ticket_ids_to_keys() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
@@ -833,7 +831,7 @@ mod tests {
}
#[test]
fn objective_output_rejects_noncanonical_human_keys() {
fn objective_output_rejects_noncanonical_keys() {
let response = ObjectiveDetail {
resource_key: "O-internal".to_string(),
title: "Objective".to_string(),
@@ -228,7 +228,7 @@ pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryRespo
fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, String> {
let item = object(value, "Ticket query item")?;
Ok(ModelTicketQueryItem {
ticket: human_ref(item, "resource_key", "T-")?,
ticket: resource_ref(item, "resource_key", "T-")?,
title: string_field(item, "title")?,
state: string_field(item, "state")?,
readiness: optional_string(item, "readiness")?,
@@ -245,7 +245,7 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
.transpose()?,
linked_objectives: string_array(item, "linked_objective_keys")?
.into_iter()
.map(|key| validate_human_ref(key, "O-"))
.map(|key| validate_resource_ref(key, "O-"))
.collect::<Result<Vec<_>, _>>()?,
relation_count: usize_field(item, "relation_count")?,
blocker_count: usize_field(item, "blocker_count")?,
@@ -273,7 +273,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
.collect::<Result<Vec<_>, _>>()?;
Ok(ModelTicketDetail {
ticket: human_ref(root, "resource_key", "T-")?,
ticket: resource_ref(root, "resource_key", "T-")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
@@ -332,10 +332,10 @@ fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem
let item = object(value, "Objective query item")?;
let linked_tickets = string_array(item, "linked_ticket_keys")?
.into_iter()
.map(|key| validate_human_ref(key, "T-"))
.map(|key| validate_resource_ref(key, "T-"))
.collect::<Result<Vec<_>, _>>()?;
Ok(ModelObjectiveQueryItem {
objective: human_ref(item, "resource_key", "O-")?,
objective: resource_ref(item, "resource_key", "O-")?,
title: string_field(item, "title")?,
summary: optional_string(item, "snippet")?,
state: string_field(item, "state")?,
@@ -349,7 +349,7 @@ fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem
pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDetail, String> {
let root = object(&value, "Objective detail response")?;
Ok(ModelObjectiveDetail {
objective: human_ref(root, "resource_key", "O-")?,
objective: resource_ref(root, "resource_key", "O-")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
@@ -373,7 +373,7 @@ pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDet
fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
let worker = object(value, "Worker summary")?;
Ok(ModelWorkerSummary {
worker: human_ref(worker, "worker_resource_key", "W-")?,
worker: resource_ref(worker, "worker_resource_key", "W-")?,
})
}
@@ -422,34 +422,18 @@ fn project_relation(
kind_key: &str,
) -> Result<ModelRelation, String> {
let relation = object(value, "Ticket relation")?;
let relation_data = relation.get("relation").and_then(Value::as_object);
let kind = if kind_key == "kind" {
relation_data
.ok_or_else(|| "Ticket relation is missing relation data".to_string())
.and_then(|data| string_field(data, "kind"))?
} else {
string_field(relation, kind_key)?
};
let note = match relation_data {
Some(data) => optional_string(data, "note")?,
None => optional_string(relation, "note")?,
};
let created_at = match relation_data {
Some(data) => optional_string(data, "at")?,
None => optional_string(relation, "at")?,
};
Ok(ModelRelation {
ticket: human_ref(relation, ticket_key, "T-")?,
kind,
note,
created_at,
ticket: resource_ref(relation, ticket_key, "T-")?,
kind: string_field(relation, kind_key)?,
note: optional_string(relation, "note")?,
created_at: optional_string(relation, "at")?,
})
}
fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
let blocker = object(value, "Ticket blocker")?;
Ok(ModelBlocker {
ticket: human_ref(blocker, "blocking_resource_key", "T-")?,
ticket: resource_ref(blocker, "blocking_resource_key", "T-")?,
kind: string_field(blocker, "relation_kind")?,
state: optional_string(blocker, "blocking_state")?,
resolved: bool_field(blocker, "resolved")?,
@@ -466,7 +450,7 @@ fn project_notice(value: &Value) -> Result<ModelNotice, String> {
fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, String> {
let summary = object(value, "Objective summary")?;
Ok(ModelObjectiveSummary {
objective: human_ref(summary, "resource_key", "O-")?,
objective: resource_ref(summary, "resource_key", "O-")?,
title: string_field(summary, "title")?,
state: string_field(summary, "state")?,
})
@@ -475,7 +459,7 @@ fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, Str
fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
let summary = object(value, "Ticket summary")?;
Ok(ModelTicketSummary {
ticket: human_ref(summary, "resource_key", "T-")?,
ticket: resource_ref(summary, "resource_key", "T-")?,
title: string_field(summary, "title")?,
state: string_field(summary, "state")?,
})
@@ -491,9 +475,7 @@ fn project_assignment(
let principal = match kind.as_str() {
"worker" => current_coder
.map(|coder| coder.worker.clone())
.ok_or_else(|| {
"Worker assignment is missing a Workspace human key projection".to_string()
})?,
.ok_or_else(|| "Worker assignment is missing a Workspace key projection".to_string())?,
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
"user" => "user".to_string(),
other => format!("source:{other}"),
@@ -645,23 +627,23 @@ fn string_array(object: &Map<String, Value>, key: &str) -> Result<Vec<String>, S
.collect()
}
fn human_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
fn resource_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
let value = object
.get(key)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| format!("required {prefix} human key is unavailable"))?;
validate_human_ref(value, prefix)
.ok_or_else(|| format!("required {prefix} key is unavailable"))?;
validate_resource_ref(value, prefix)
}
fn validate_human_ref(value: String, prefix: &str) -> Result<String, String> {
fn validate_resource_ref(value: String, prefix: &str) -> Result<String, String> {
let valid = value.strip_prefix(prefix).is_some_and(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
});
if valid {
Ok(value)
} else {
Err(format!("required {prefix} human key is unavailable"))
Err(format!("required {prefix} key is unavailable"))
}
}
@@ -671,7 +653,7 @@ mod tests {
use serde_json::json;
#[test]
fn objective_projection_exposes_only_human_resource_references() {
fn objective_projection_exposes_only_resource_references() {
let projected = project_objective_detail(json!({
"id": "00001M10HW6BV",
"resource_key": "O-543",
@@ -779,9 +761,98 @@ mod tests {
}
#[test]
fn human_resource_projection_rejects_noncanonical_keys() {
fn relation_projection_accepts_current_workspace_api_shapes() {
let outgoing = project_relation(
&json!({
"ticket_id": "internal-source-ticket",
"kind": "depends_on",
"target": "internal-target-ticket",
"target_resource_key": "T-535",
"note": "required foundation",
"author": "internal-author",
"at": "2026-08-22T00:00:00Z"
}),
"target_resource_key",
"kind",
)
.expect("outgoing relation projection");
let incoming = project_relation(
&json!({
"source_ticket": "internal-source-ticket",
"source_resource_key": "T-536",
"inverse_kind": "blocks",
"forward_kind": "depends_on",
"note": null,
"author": "internal-author",
"at": "2026-08-22T00:01:00Z"
}),
"source_resource_key",
"forward_kind",
)
.expect("incoming relation projection");
let outgoing = serde_json::to_value(outgoing).expect("serialize outgoing relation");
assert_eq!(
outgoing,
json!({
"ticket": "T-535",
"kind": "depends_on",
"note": "required foundation",
"created_at": "2026-08-22T00:00:00Z"
})
);
let incoming = serde_json::to_value(incoming).expect("serialize incoming relation");
assert_eq!(
incoming,
json!({
"ticket": "T-536",
"kind": "depends_on",
"note": null,
"created_at": "2026-08-22T00:01:00Z"
})
);
let projection = format!("{outgoing}{incoming}");
for internal in [
"internal-source-ticket",
"internal-target-ticket",
"internal-author",
] {
assert!(!projection.contains(internal));
}
}
#[test]
fn relation_projection_rejects_missing_workspace_keys() {
let outgoing = json!({
"kind": "depends_on",
"target": "internal-target-ticket",
"note": null,
"author": "internal-author",
"at": "2026-08-22T00:00:00Z"
});
let incoming = json!({
"source_resource_key": "not-a-ticket-key",
"forward_kind": "depends_on",
"note": null,
"author": "internal-author",
"at": "2026-08-22T00:01:00Z"
});
assert!(
project_relation(&outgoing, "target_resource_key", "kind")
.expect_err("missing outgoing key must fail")
.contains("T-")
);
assert!(
project_relation(&incoming, "source_resource_key", "forward_kind")
.expect_err("invalid incoming key must fail")
.contains("T-")
);
}
#[test]
fn resource_projection_rejects_noncanonical_keys() {
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
assert!(validate_human_ref(key.to_string(), prefix).is_err());
assert!(validate_resource_ref(key.to_string(), prefix).is_err());
}
}
+119 -24
View File
@@ -306,19 +306,32 @@ struct BackendTicketService {
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 {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
let ticket = self.backend.show(ticket_ref.into())?;
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,
})
ticket_handoff_from_record(self.backend.show(ticket_ref.into())?)
}
}
impl TicketService for WorkspaceTicketService {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
}
}
@@ -640,9 +653,14 @@ impl FeatureModule for TicketFeature {
let Some(backend) = self.tool_backend(context) else {
return Ok(());
};
let ticket_service: Arc<dyn TicketService> = Arc::new(BackendTicketService {
backend: backend.clone(),
});
let ticket_service: Arc<dyn TicketService> = match &self.backend {
TicketFeatureBackend::WorkspaceClient(client) => Arc::new(WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend::new(client.clone()),
}),
TicketFeatureBackend::Local { .. } => Arc::new(BackendTicketService {
backend: backend.clone(),
}),
};
context.services().provide(
ServiceDeclaration::new(
ServiceId::builtin(TICKET_SERVICE_ID),
@@ -714,6 +732,26 @@ impl WorkspaceHttpTicketBackend {
Self::invoke_client(client, workspace_id, operation)
}
fn show_unprojected(&self, ticket_ref: &str) -> TicketResult<Ticket> {
let client = self.client.clone();
let workspace_id = self.client.workspace_id().unwrap_or_default().to_string();
let ticket_path = Self::ticket_path(&TicketIdOrSlug::from(ticket_ref));
let request = move || {
Self::request_unprojected(
client,
WorkspaceRequestMethod::Get,
format!("/api/w/{workspace_id}/tickets/{ticket_path}/record"),
None,
)
};
if tokio::runtime::Handle::try_current().is_ok() {
return std::thread::spawn(request).join().map_err(|_| {
TicketError::Conflict("ticket REST request thread panicked".to_string())
})?;
}
request()
}
fn ticket_path(id: &TicketIdOrSlug) -> String {
let value = match id {
TicketIdOrSlug::Id(value)
@@ -738,6 +776,29 @@ impl WorkspaceHttpTicketBackend {
endpoint: String,
body: Option<serde_json::Value>,
) -> TicketResult<T> {
let mut value = Self::request_value(client, method, endpoint, body)?;
Self::canonicalize_ticket_references(&mut value);
serde_json::from_value(value)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
}
fn request_unprojected<T: serde::de::DeserializeOwned>(
client: Arc<dyn WorkspaceClient>,
method: WorkspaceRequestMethod,
endpoint: String,
body: Option<serde_json::Value>,
) -> TicketResult<T> {
let value = Self::request_value(client, method, endpoint, body)?;
serde_json::from_value(value)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
}
fn request_value(
client: Arc<dyn WorkspaceClient>,
method: WorkspaceRequestMethod,
endpoint: String,
body: Option<serde_json::Value>,
) -> TicketResult<Value> {
let request = match body {
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
@@ -756,11 +817,7 @@ impl WorkspaceHttpTicketBackend {
response.status
)));
}
let mut value: Value = serde_json::from_str(&response.body).map_err(|error| {
TicketError::Conflict(format!("decode ticket REST response: {error}"))
})?;
Self::canonicalize_ticket_references(&mut value);
serde_json::from_value(value)
serde_json::from_str(&response.body)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
}
@@ -810,9 +867,7 @@ impl WorkspaceHttpTicketBackend {
.and_then(Value::as_str)
.filter(|key| is_canonical_ticket_resource_key(key))
.map(ToOwned::to_owned)
.ok_or_else(|| {
TicketError::Conflict("required Ticket human key is unavailable".to_string())
})
.ok_or_else(|| TicketError::Conflict("required Ticket key is unavailable".to_string()))
}
fn request_unit(
@@ -889,7 +944,7 @@ impl WorkspaceHttpTicketBackend {
.is_some_and(is_canonical_ticket_resource_key)
{
return Err(TicketError::Conflict(
"required Ticket human key is unavailable".to_string(),
"required Ticket key is unavailable".to_string(),
));
}
Ok(TicketBackendOperationResult::Ticket(ticket))
@@ -1861,7 +1916,7 @@ provider = "github"
}
#[test]
fn workspace_http_backend_records_relation_with_authoritative_human_keys() {
fn workspace_http_backend_records_relation_with_authoritative_keys() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = thread::spawn(move || {
@@ -2000,6 +2055,46 @@ provider = "github"
assert_eq!(removed.target, "T-2");
}
#[test]
fn workspace_ticket_service_preserves_internal_identity_for_handoff() {
let temp = TempDir::new().unwrap();
let local = LocalTicketBackend::new(temp.path().join("tickets"));
let created = local.create(NewTicket::new("Ticket handoff")).unwrap();
let mut ticket = local.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
ticket.meta.resource_key = Some("T-548".to_string());
ticket.meta.workflow_state = TicketWorkflowState::Queued;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let response_body = serde_json::to_string(&ticket).unwrap();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with("GET /api/w/workspace-a/tickets/T-548/record HTTP/1.1"));
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
response_body.len(),
response_body
)
.unwrap();
});
let service = WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url),
)),
};
let handoff = service.ticket_handoff("T-548").unwrap();
server.join().unwrap();
assert_eq!(handoff.id, created.id);
assert_eq!(handoff.resource_key, "T-548");
assert_eq!(handoff.workflow_state, TicketWorkflowState::Queued);
}
#[test]
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
assert!(is_canonical_ticket_resource_key("T-482"));
@@ -6,7 +6,7 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use session_store::{LogEntry, collect_state};
use session_store::LogEntry;
use super::manage_worker::{WORKER_CONTROL_SERVICE_ID, WorkerControlService};
use crate::feature::{
@@ -61,7 +61,7 @@ pub struct WorkerObservationSubject {
#[derive(Debug, Clone)]
pub struct WorkerSessionCapture {
pub segment_id: String,
pub entries: Vec<agen::HistoryEntry<crate::SessionHistoryMetadata>>,
pub session: protocol::SessionSnapshot,
}
impl WorkerSessionCapture {
@@ -69,17 +69,9 @@ impl WorkerSessionCapture {
segment_id: impl Into<String>,
log_entries: &[LogEntry],
) -> Result<Self, String> {
let segment_id = segment_id.into();
let state = collect_state(log_entries);
let parsed_segment_id = segment_id.parse().unwrap_or_default();
let entries = crate::session_history::restore_history_entries(
state.session_id.unwrap_or_default(),
parsed_segment_id,
log_entries,
)?;
Ok(Self {
segment_id,
entries,
segment_id: segment_id.into(),
session: session_store::public_snapshot::project_current_session_snapshot(log_entries),
})
}
}
@@ -115,7 +107,7 @@ struct WorkspaceWorkerObservationListResponse {
#[derive(Debug, Deserialize)]
struct WorkspaceWorkerObservationCaptureResponse {
segment_id: String,
entries: Vec<serde_json::Value>,
session: protocol::SessionSnapshot,
}
pub struct WorkspaceClientWorkerObservationProvider {
@@ -173,26 +165,9 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
let body = workspace_response_body(response)?;
let response = serde_json::from_str::<WorkspaceWorkerObservationCaptureResponse>(&body)
.map_err(|error| WorkerObservationError::Unavailable(error.to_string()))?;
let entries = response
.entries
.into_iter()
.map(|entry| {
serde_json::from_value(entry)
.map_err(|error| WorkerObservationError::Unavailable(error.to_string()))
})
.collect::<Result<Vec<session_store::LogEntry>, _>>()?;
let state = collect_state(&entries);
let segment_id = response.segment_id;
let parsed_segment_id = segment_id.parse().unwrap_or_default();
let typed_entries = crate::session_history::restore_history_entries(
state.session_id.unwrap_or_default(),
parsed_segment_id,
&entries,
)
.map_err(WorkerObservationError::Unavailable)?;
Ok(WorkerSessionCapture {
segment_id,
entries: typed_entries,
segment_id: response.segment_id,
session: response.session,
})
}
}
@@ -420,16 +395,9 @@ impl WorkerObservationProvider for SpawnedSubWorkerObservationProvider {
.get_internal(name)
.ok_or(WorkerObservationError::NotFound)?;
let entries = record.session.entries();
let state = collect_state(&entries);
let typed_entries = crate::session_history::restore_history_entries(
state.session_id.unwrap_or_default(),
Default::default(),
&entries,
)
.map_err(WorkerObservationError::Unavailable)?;
Ok(WorkerSessionCapture {
segment_id: format!("subworker:{name}"),
entries: typed_entries,
session: session_store::public_snapshot::project_current_session_snapshot(&entries),
})
}
}
@@ -699,9 +667,9 @@ async fn latest_view(
.capture_worker_session(subject)
.await
.map_err(tool_error)?;
Ok(SessionCapture::from_history_entries(
Ok(SessionCapture::from_session_snapshot(
capture.segment_id,
capture.entries,
capture.session,
))
}
@@ -799,16 +767,43 @@ mod tests {
.clone()
.into_iter()
.enumerate()
.map(|(index, item)| {
let mut metadata = crate::SessionHistoryMetadata::legacy_unknown();
metadata.entry_id =
session_store::LoggedSessionHistoryEntryId(format!("fake-{index:08}"));
agen::HistoryEntry::new(item, metadata)
.filter_map(|(index, item)| {
let data = match item {
Item::Message { role, content, .. } => {
let role = match role {
Role::User => protocol::SessionMessageRole::User,
Role::Assistant => protocol::SessionMessageRole::Assistant,
Role::System => return None,
};
protocol::SessionSnapshotEntryData::Message {
role,
content: content
.into_iter()
.map(|part| match part {
agen::ContentPart::Text { text } => {
protocol::SessionContentPart::Text { text }
}
agen::ContentPart::Refusal { refusal } => {
protocol::SessionContentPart::Refusal { refusal }
}
})
.collect(),
}
}
_ => return None,
};
Some(protocol::SessionSnapshotEntry {
entry_id: format!("fake-{index:08}"),
timestamp: index as u64,
provenance: protocol::SessionEntryProvenance::LegacyUnknown,
derived_from: Vec::new(),
data,
})
})
.collect();
Ok(WorkerSessionCapture {
segment_id: "segment".to_string(),
entries,
session: protocol::SessionSnapshot { entries },
})
}
}
+1 -1
View File
@@ -161,7 +161,7 @@ impl From<HookTurnEndAction> for TurnEndAction {
///
/// Hook code can use this handle only when the Worker host includes it in an
/// event-specific context. The handle queues typed requests; the host drains the
/// queue, commits each entry through `LogEntry::SystemItem`, and only then makes
/// queue, commits each entry through `LogEntry::AnnotatedSystemItem`, and only then makes
/// the matching system message visible to the model. It deliberately exposes no
/// raw `agen::Item`, history writer, event sender, `Worker`, `Engine`, or
/// notification buffer.
+5 -5
View File
@@ -539,9 +539,9 @@ mod tests {
text: "done".into(),
}],
};
let assistant_entry = LogEntry::AssistantItem {
let assistant_entry = LogEntry::AnnotatedAssistantItem {
ts: 1,
item: assistant_item.clone(),
entry: crate::session_history::test_logged_history_entry(assistant_item.clone()),
};
let in_flight_guard = in_flight.snapshot_guard();
@@ -593,9 +593,9 @@ mod tests {
text: "done".into(),
}],
};
let assistant_entry = LogEntry::AssistantItem {
let assistant_entry = LogEntry::AnnotatedAssistantItem {
ts: 1,
item: assistant_item.clone(),
entry: crate::session_history::test_logged_history_entry(assistant_item.clone()),
};
in_flight.clear_for_committed_item_then(&assistant_item, || {
@@ -608,7 +608,7 @@ mod tests {
assert!(matches!(
entries_snapshot.as_slice(),
[LogEntry::AssistantItem { item, .. }] if item == &assistant_item
[LogEntry::AnnotatedAssistantItem { entry, .. }] if entry.item == assistant_item
));
assert!(in_flight_snapshot.is_empty());
}
+2 -2
View File
@@ -335,7 +335,7 @@ enum InternalWorkerSessionCommand {
/// task; protocol access is consumed only by the owning parent registry.
#[derive(Debug, Clone)]
pub(crate) struct InternalWorkerSessionSnapshot {
pub entries: Vec<LogEntry>,
pub session: protocol::SessionSnapshot,
pub status: WorkerStatus,
pub error: Option<String>,
pub in_flight: InFlightSnapshot,
@@ -402,7 +402,7 @@ impl InternalWorkerSessionHandle {
(entries, snapshot_from_guard(&guard))
};
InternalWorkerSessionSnapshot {
entries,
session: session_store::public_snapshot::project_current_session_snapshot(&entries),
status: match self.status() {
InternalWorkerSessionStatus::Running => WorkerStatus::Running,
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
+3 -4
View File
@@ -60,7 +60,7 @@ pub(crate) struct WorkerInterceptor {
pending_notifies: NotifyBuffer,
/// Submit-scoped stash of resolver-produced typed system items.
/// Drained inside `on_prompt_submit`, committed as
/// `LogEntry::SystemItem` entries through `log_writer`, and
/// `LogEntry::AnnotatedSystemItem` entries through `log_writer`, and
/// returned to the worker as `Item::system_message` via
/// `PromptAction::ContinueWith`. Populated by `Worker::run`
/// immediately before handing off to the worker.
@@ -71,7 +71,7 @@ pub(crate) struct WorkerInterceptor {
/// Workspace scope associated with Prompt projection provenance.
prompt_workspace_id: Option<String>,
/// Type-erased commit handle. The interceptor uses it to commit
/// `LogEntry::SystemItem` entries directly (sync) before
/// `LogEntry::AnnotatedSystemItem` entries directly (sync) before
/// returning the corresponding `Item::system_message`s up to the
/// worker. `None` in tests / `Worker::new` paths where no writer is
/// attached.
@@ -142,7 +142,7 @@ impl WorkerInterceptor {
self
}
/// Commit each `SystemItem` as its own `LogEntry::SystemItem`
/// Commit each `SystemItem` as its own `LogEntry::AnnotatedSystemItem`
/// entry through the attached writer (no-op when no writer is
/// wired). Sync — writes complete before the matching
/// `Item::system_message`s reach the worker via
@@ -540,7 +540,6 @@ mod tests {
entry: session_store::LogEntry,
) -> Result<(), session_store::StoreError> {
let item = match entry {
session_store::LogEntry::SystemItem { item, .. } => Some(item),
session_store::LogEntry::AnnotatedSystemItem { entry, .. } => Some(entry.item),
_ => None,
};
+1 -1
View File
@@ -5,7 +5,7 @@
//! `WorkerInterceptor::pending_history_appends`, which the Engine calls
//! at the head of each turn loop iteration. The drain renders each
//! pending entry into a typed `SystemItem` (with the `notify_wrapper`
//! prompt applied), commits a `LogEntry::SystemItem` per entry through
//! prompt applied), commits a `LogEntry::AnnotatedSystemItem` per entry through
//! the session-log sink, and returns the corresponding
//! `Item::system_message`s for the worker to append to its
//! persistent history.
+9 -11
View File
@@ -29,17 +29,12 @@ pub fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtoco
pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
match entry {
entry @ (LogEntry::SegmentStart { .. } | LogEntry::AnnotatedSegmentStart { .. }) => {
let value = serde_json::to_value(&entry).expect("LogEntry is Serialize");
Some(Event::SegmentRotated { entry: value })
}
LogEntry::UserInput { segments, .. } | LogEntry::AnnotatedUserInput { segments, .. } => {
Some(Event::UserMessage { segments })
}
LogEntry::SystemItem { item, .. } => {
let value = serde_json::to_value(&item).expect("SystemItem is Serialize");
Some(Event::SystemItem { item: value })
entry @ LogEntry::AnnotatedSegmentStart { .. } => {
let session =
session_store::public_snapshot::project_current_session_snapshot(&[entry]);
Some(Event::SegmentRotated { session })
}
LogEntry::AnnotatedUserInput { segments, .. } => Some(Event::UserMessage { segments }),
LogEntry::AnnotatedSystemItem { entry, .. } => {
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
Some(Event::SystemItem { item: value })
@@ -88,9 +83,12 @@ mod tests {
#[test]
fn user_input_log_entry_maps_to_user_message_event() {
let segments = vec![protocol::Segment::text("hello from log")];
let event = live_log_entry_event(LogEntry::UserInput {
let event = live_log_entry_event(LogEntry::AnnotatedUserInput {
ts: session_store::segment_log::now_millis(),
extensions: vec![],
history: vec![crate::session_history::test_logged_history_entry(
agen::Item::user_message("hello from log"),
)],
segments: segments.clone(),
})
.expect("UserInput must be live-relevant");
+1 -1
View File
@@ -77,7 +77,7 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: WorkerHandle)
let mut writer = JsonLineWriter::new(writer);
// Hold the in-flight stream lock while taking the session-log mirror
// snapshot. `LogEntry::AssistantItem` is mirror-only for live clients,
// snapshot. `LogEntry::AnnotatedAssistantItem` is mirror-only for live clients,
// so a finalized assistant block must be observed either as an already
// committed entry or as the still-present in-flight block. This lock
// order matches `append_entry` (in-flight clear before sink publish) and
+31 -23
View File
@@ -50,7 +50,7 @@ struct SinkInner {
/// Broadcast channel for live entry updates. The same `Sender`
/// survives session swaps so existing subscribers keep their
/// receiver — they observe the swap as a freshly broadcast
/// `LogEntry::SegmentStart` and reset their view accordingly.
/// `LogEntry::AnnotatedSegmentStart` and reset their view accordingly.
broadcast_tx: broadcast::Sender<LogEntry>,
}
@@ -89,9 +89,9 @@ impl SegmentLogSink {
///
/// Live broadcast fires for committed session-log entries that
/// socket clients must see in log order:
/// - `LogEntry::SegmentStart` → `Event::SegmentRotated` on the wire.
/// - `LogEntry::UserInput` → `Event::UserMessage`.
/// - `LogEntry::SystemItem` → `Event::SystemItem`.
/// - `LogEntry::AnnotatedSegmentStart` → `Event::SegmentRotated` on the wire.
/// - `LogEntry::AnnotatedUserInput` → `Event::UserMessage`.
/// - `LogEntry::AnnotatedSystemItem` → `Event::SystemItem`.
/// - `LogEntry::Invoke` → `Event::InvokeStart`.
/// Everything else (AssistantItem, ToolResult, TurnEnd,
/// RunCompleted, RunErrored, PausedTurnAbandoned, LlmUsage, Extension,
@@ -120,11 +120,8 @@ impl SegmentLogSink {
fn is_live_relevant(entry: &LogEntry) -> bool {
matches!(
entry,
LogEntry::SegmentStart { .. }
| LogEntry::AnnotatedSegmentStart { .. }
| LogEntry::UserInput { .. }
LogEntry::AnnotatedSegmentStart { .. }
| LogEntry::AnnotatedUserInput { .. }
| LogEntry::SystemItem { .. }
| LogEntry::AnnotatedSystemItem { .. }
| LogEntry::Invoke { .. }
)
@@ -132,7 +129,7 @@ impl SegmentLogSink {
/// Atomically swap the mirror to `[initial]` and broadcast the new
/// session-start entry. Used during compaction / fork: the new
/// `LogEntry::SegmentStart` is the first entry of the replacement
/// `LogEntry::AnnotatedSegmentStart` is the first entry of the replacement
/// session, and existing subscribers transition by replaying it
/// like any other live entry.
///
@@ -234,7 +231,7 @@ mod tests {
use session_store::segment_log::now_millis;
fn session_start() -> LogEntry {
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
ts: now_millis(),
session_id: uuid::Uuid::nil(),
system_prompt: None,
@@ -253,9 +250,12 @@ mod tests {
}
fn user_input(text: &str) -> LogEntry {
LogEntry::UserInput {
LogEntry::AnnotatedUserInput {
ts: now_millis(),
extensions: vec![],
history: vec![crate::session_history::test_logged_history_entry(
agen::Item::user_message(text),
)],
segments: vec![protocol::Segment::Text {
content: text.to_owned(),
}],
@@ -270,7 +270,10 @@ mod tests {
let (snapshot, mut rx) = sink.subscribe_with_snapshot();
assert_eq!(snapshot.len(), 2);
assert!(matches!(snapshot[0], LogEntry::SegmentStart { .. }));
assert!(matches!(
snapshot[0],
LogEntry::AnnotatedSegmentStart { .. }
));
assert!(matches!(
snapshot[1],
LogEntry::TurnEnd { turn_count: 1, .. }
@@ -279,13 +282,15 @@ mod tests {
}
fn notification_entry(text: &str) -> LogEntry {
LogEntry::SystemItem {
LogEntry::AnnotatedSystemItem {
ts: now_millis(),
item: session_store::SystemItem::Notification {
message: text.to_owned(),
body: format!("[Notification] {text}"),
prompt_provenance: None,
},
entry: crate::session_history::test_logged_system_entry(
session_store::SystemItem::Notification {
message: text.to_owned(),
body: format!("[Notification] {text}"),
prompt_provenance: None,
},
),
}
}
@@ -305,7 +310,7 @@ mod tests {
// for Event::UserMessage.
sink.publish(user_input("hi from log"));
match rx.try_recv() {
Ok(LogEntry::UserInput { segments, .. }) => {
Ok(LogEntry::AnnotatedUserInput { segments, .. }) => {
assert_eq!(segments.len(), 1);
}
other => panic!("expected UserInput, got {other:?}"),
@@ -314,7 +319,7 @@ mod tests {
// SystemItem is live-relevant.
sink.publish(notification_entry("hi"));
match rx.try_recv() {
Ok(LogEntry::SystemItem { .. }) => {}
Ok(LogEntry::AnnotatedSystemItem { .. }) => {}
other => panic!("expected SystemItem, got {other:?}"),
}
@@ -332,7 +337,7 @@ mod tests {
assert_eq!(snapshot.len(), 1);
match rx.try_recv() {
Ok(LogEntry::SystemItem { .. }) => {}
Ok(LogEntry::AnnotatedSystemItem { .. }) => {}
other => panic!("unexpected: {other:?}"),
}
assert!(rx.try_recv().is_err());
@@ -348,13 +353,16 @@ mod tests {
sink.reset_with_initial(session_start());
match rx.try_recv() {
Ok(LogEntry::SegmentStart { .. }) => {}
Ok(LogEntry::AnnotatedSegmentStart { .. }) => {}
other => panic!("expected SegmentStart broadcast, got {other:?}"),
}
let (post_snapshot, _) = sink.subscribe_with_snapshot();
assert_eq!(post_snapshot.len(), 1);
assert!(matches!(post_snapshot[0], LogEntry::SegmentStart { .. }));
assert!(matches!(
post_snapshot[0],
LogEntry::AnnotatedSegmentStart { .. }
));
}
#[test]
+134 -33
View File
@@ -8,6 +8,10 @@ use std::sync::Arc;
use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
use agen::{HistoryEntry, Item, Role};
use protocol::{
SessionContentPart, SessionEntryProvenance, SessionMessageRole, SessionSnapshot,
SessionSnapshotEntryData,
};
use serde::{Deserialize, Serialize};
const DEFAULT_SEARCH_LIMIT: usize = 20;
@@ -105,7 +109,7 @@ impl ToolPart {
#[derive(Debug, Clone)]
pub(crate) struct OverviewItem {
pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub origin: SessionEntryProvenance,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub label: String,
@@ -116,7 +120,7 @@ pub(crate) struct OverviewItem {
#[derive(Debug, Clone)]
pub(crate) struct ReferenceEntry {
pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub origin: SessionEntryProvenance,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
@@ -142,7 +146,7 @@ pub(crate) struct SearchOptions {
#[derive(Debug, Clone)]
pub(crate) struct SearchHit {
pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub origin: SessionEntryProvenance,
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
@@ -188,7 +192,7 @@ impl Default for ReadOptions {
#[derive(Debug, Clone)]
pub(crate) struct ReadEntry {
pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub origin: SessionEntryProvenance,
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
@@ -207,7 +211,7 @@ pub(crate) struct ReadResult {
pub(crate) struct SessionEntryEvidence {
pub segment_id: String,
pub entry_ref: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub origin: SessionEntryProvenance,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
@@ -216,35 +220,116 @@ pub(crate) struct SessionEntryEvidence {
pub excerpt: String,
}
#[derive(Debug, Clone)]
struct CapturedHistoryEntry {
item: Item,
entry_id: session_store::LoggedSessionHistoryEntryId,
origin: SessionEntryProvenance,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionCapture {
segment_id: String,
entries: Arc<Vec<HistoryEntry<SessionHistoryMetadata>>>,
entries: Arc<Vec<CapturedHistoryEntry>>,
overview: Vec<OverviewItem>,
index: Vec<ReferenceEntry>,
}
impl SessionCapture {
pub(crate) fn from_session_snapshot(
segment_id: impl Into<String>,
snapshot: SessionSnapshot,
) -> Self {
let entries = snapshot
.entries
.into_iter()
.filter_map(|entry| {
let item = match entry.data {
SessionSnapshotEntryData::UserInput { segments } => {
Item::user_message(protocol::Segment::flatten_to_text(&segments))
}
SessionSnapshotEntryData::Message { role, content } => {
let role = match role {
SessionMessageRole::User => Role::User,
SessionMessageRole::Assistant => Role::Assistant,
};
Item::Message {
id: None,
role,
content: content
.into_iter()
.map(|part| match part {
SessionContentPart::Text { text } => {
agen::ContentPart::Text { text }
}
SessionContentPart::Refusal { refusal } => {
agen::ContentPart::Refusal { refusal }
}
})
.collect(),
status: None,
}
}
SessionSnapshotEntryData::ToolCall {
call_id,
name,
arguments,
} => Item::tool_call(call_id, name, arguments),
SessionSnapshotEntryData::ToolResult {
call_id,
summary,
content,
is_error,
attachments: _,
} => Item::tool_result_item(call_id, summary, content, is_error),
// Observation deliberately excludes system items and
// controller errors from model-visible session evidence.
SessionSnapshotEntryData::SystemItem { .. }
| SessionSnapshotEntryData::RunError { .. } => return None,
};
Some(CapturedHistoryEntry {
item,
entry_id: session_store::LoggedSessionHistoryEntryId(entry.entry_id),
origin: entry.provenance,
})
})
.collect();
Self::from_captured_entries(segment_id, entries)
}
pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self {
let entries = items
.into_iter()
.enumerate()
.map(|(index, item)| {
let mut metadata = SessionHistoryMetadata::legacy_unknown();
metadata.entry_id =
session_store::LoggedSessionHistoryEntryId(format!("{index:08}"));
HistoryEntry::new(item, metadata)
.map(|(index, item)| CapturedHistoryEntry {
item,
entry_id: session_store::LoggedSessionHistoryEntryId(format!("{index:08}")),
origin: SessionEntryProvenance::LegacyUnknown,
})
.collect();
Self::from_history_entries(segment_id, entries)
Self::from_captured_entries(segment_id, entries)
}
pub(crate) fn from_history_entries(
segment_id: impl Into<String>,
entries: Vec<HistoryEntry<SessionHistoryMetadata>>,
) -> Self {
let entries = entries
.into_iter()
.map(|entry| CapturedHistoryEntry {
item: entry.item,
entry_id: entry.annotation.entry_id,
origin: public_provenance(&entry.annotation.origin),
})
.collect();
Self::from_captured_entries(segment_id, entries)
}
fn from_captured_entries(
segment_id: impl Into<String>,
entries: Vec<CapturedHistoryEntry>,
) -> Self {
let segment_id = segment_id.into();
let entries = Arc::new(entries);
let mut overview = Vec::new();
let mut index = Vec::new();
@@ -253,7 +338,7 @@ impl SessionCapture {
let entry_range = [idx as u64, idx as u64];
match item {
Item::Message { role, content, .. } => {
let Some(kind) = message_reference_kind(&entry.annotation.origin, role) else {
let Some(kind) = message_reference_kind(&entry.origin, role) else {
continue;
};
let text = content
@@ -263,10 +348,10 @@ impl SessionCapture {
.join("");
let label = format!("{} message", kind.as_str());
let summary = truncate_chars(&text, 240);
let id = SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id);
let id = SessionEntryRef::from_history_entry_id(&entry.entry_id);
index.push(ReferenceEntry {
id: id.clone(),
origin: entry.annotation.origin.clone(),
origin: entry.origin.clone(),
entry_range,
kind,
tool_part: None,
@@ -278,7 +363,7 @@ impl SessionCapture {
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
overview.push(OverviewItem {
id: id.clone(),
origin: entry.annotation.origin.clone(),
origin: entry.origin.clone(),
entry_range,
kind,
label,
@@ -292,8 +377,8 @@ impl SessionCapture {
} => {
let text = format!("{name}\n{arguments}");
index.push(ReferenceEntry {
id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
origin: entry.annotation.origin.clone(),
id: SessionEntryRef::from_history_entry_id(&entry.entry_id),
origin: entry.origin.clone(),
entry_range,
kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Input),
@@ -319,8 +404,8 @@ impl SessionCapture {
content.as_deref().unwrap_or_default(),
);
index.push(ReferenceEntry {
id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
origin: entry.annotation.origin.clone(),
id: SessionEntryRef::from_history_entry_id(&entry.entry_id),
origin: entry.origin.clone(),
entry_range,
kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Output),
@@ -360,7 +445,7 @@ impl SessionCapture {
Self {
segment_id,
entries,
entries: Arc::new(entries),
overview,
index,
}
@@ -543,25 +628,41 @@ impl SessionCapture {
}
}
fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance {
match origin {
WorkerHistoryProvenance::HumanInput { .. } => SessionEntryProvenance::HumanInput,
WorkerHistoryProvenance::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
WorkerHistoryProvenance::FlowInstruction { .. } => SessionEntryProvenance::FlowInstruction,
WorkerHistoryProvenance::BackendInstruction { .. } => {
SessionEntryProvenance::BackendInstruction
}
WorkerHistoryProvenance::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
WorkerHistoryProvenance::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
WorkerHistoryProvenance::DerivedSummary => SessionEntryProvenance::DerivedSummary,
WorkerHistoryProvenance::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
}
}
fn message_reference_kind(
origin: &WorkerHistoryProvenance,
origin: &SessionEntryProvenance,
provider_role: &Role,
) -> Option<ReferenceKind> {
match origin {
WorkerHistoryProvenance::HumanInput { .. }
| WorkerHistoryProvenance::WorkerInput { .. } => Some(ReferenceKind::User),
WorkerHistoryProvenance::ModelOutput { .. } => Some(ReferenceKind::Assistant),
WorkerHistoryProvenance::ToolOutput { .. } => Some(ReferenceKind::Tool),
WorkerHistoryProvenance::LegacyUnknown => match provider_role {
SessionEntryProvenance::HumanInput | SessionEntryProvenance::WorkerInput => {
Some(ReferenceKind::User)
}
SessionEntryProvenance::ModelOutput => Some(ReferenceKind::Assistant),
SessionEntryProvenance::ToolOutput => Some(ReferenceKind::Tool),
SessionEntryProvenance::LegacyUnknown => match provider_role {
Role::User => Some(ReferenceKind::User),
Role::Assistant => Some(ReferenceKind::Assistant),
Role::System => None,
},
// Flow/backend/system content remains out of the observation surface
// even when represented with a provider user/system role.
WorkerHistoryProvenance::FlowInstruction { .. }
| WorkerHistoryProvenance::BackendInstruction { .. }
| WorkerHistoryProvenance::DerivedSummary => None,
SessionEntryProvenance::FlowInstruction
| SessionEntryProvenance::BackendInstruction
| SessionEntryProvenance::DerivedSummary => None,
}
}
@@ -658,13 +759,13 @@ mod tests {
assert_eq!(overview.len(), 1);
assert!(matches!(
overview[0].origin,
WorkerHistoryProvenance::HumanInput { .. }
SessionEntryProvenance::HumanInput
));
let evidence = capture.evidence_for(overview[0].id.as_str()).unwrap();
assert!(evidence.excerpt.ends_with("remember my preference"));
assert!(matches!(
evidence.origin,
WorkerHistoryProvenance::HumanInput { .. }
SessionEntryProvenance::HumanInput
));
}
+27 -37
View File
@@ -5,7 +5,6 @@
//! retained only as explicit `LegacyUnknown` entries.
use agen::{HistoryEntry, Item};
use protocol::Segment;
use session_store::{
LogEntry, LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedWorkerSubject, SegmentId,
@@ -18,6 +17,32 @@ pub type WorkerHistoryProvenance = LoggedSessionHistoryOrigin;
pub type SessionHistoryDerivation = LoggedHistoryDerivation;
pub type WorkerSubjectSnapshot = LoggedWorkerSubject;
#[cfg(test)]
pub(crate) fn test_logged_history_entry(item: impl Into<Item>) -> LoggedHistoryEntry {
LoggedHistoryEntry {
item: session_store::LoggedItem::from(item.into()),
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
}
}
#[cfg(test)]
pub(crate) fn test_logged_system_entry(
item: session_store::SystemItem,
) -> session_store::LoggedSystemHistoryEntry {
session_store::LoggedSystemHistoryEntry {
item,
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
}
}
pub(crate) fn worker_subject(session_id: SessionId) -> WorkerSubjectSnapshot {
WorkerSubjectSnapshot {
workspace_id: None,
@@ -53,10 +78,6 @@ pub(crate) fn to_logged_history_entry(
}
}
fn legacy_entry(item: Item) -> HistoryEntry<SessionHistoryMetadata> {
HistoryEntry::new(item, SessionHistoryMetadata::legacy_unknown())
}
fn from_logged(entry: &LoggedHistoryEntry) -> HistoryEntry<SessionHistoryMetadata> {
HistoryEntry::new(Item::from(entry.item.clone()), entry.metadata.clone())
}
@@ -74,32 +95,15 @@ pub(crate) fn restore_history_entries(
LogEntry::AnnotatedSegmentStart { history: seed, .. } => {
history = seed.iter().map(from_logged).collect();
}
LogEntry::SegmentStart { history: seed, .. } => {
history = seed
.iter()
.cloned()
.map(Item::from)
.map(legacy_entry)
.collect();
}
LogEntry::AnnotatedUserInput { history: input, .. } => {
history.extend(input.iter().map(from_logged))
}
LogEntry::UserInput { segments, .. } => history.push(legacy_entry(Item::user_message(
Segment::flatten_to_text(segments),
))),
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => history.push(from_logged(entry)),
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
history.push(legacy_entry(Item::from(item.clone())));
}
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
entry.item.to_history_item(),
entry.metadata.clone(),
)),
LogEntry::SystemItem { item, .. } => {
history.push(legacy_entry(item.to_history_item()));
}
_ => {}
}
}
@@ -110,23 +114,9 @@ pub(crate) fn restore_history_entries(
mod tests {
use super::*;
use agen::llm_client::RequestConfig;
use protocol::Segment;
use session_store::LogEntry;
#[test]
fn legacy_user_role_is_not_inferred_as_human_authority() {
let entries = vec![LogEntry::UserInput {
ts: 1,
segments: vec![Segment::text("legacy")],
extensions: Vec::new(),
}];
let restored =
restore_history_entries(SessionId::now_v7(), SegmentId::now_v7(), &entries).unwrap();
assert!(matches!(
restored[0].annotation.origin,
WorkerHistoryProvenance::LegacyUnknown
));
}
#[test]
fn typed_flow_and_unknown_caller_input_round_trip_without_role_inference() {
let session_id = SessionId::now_v7();
+15 -1
View File
@@ -278,7 +278,21 @@ mod tests {
fn snapshot(entries: Vec<serde_json::Value>) -> Event {
Event::Snapshot {
entries,
session: protocol::SessionSnapshot {
entries: entries
.into_iter()
.enumerate()
.map(|(index, value)| protocol::SessionSnapshotEntry {
entry_id: format!("test-{index}"),
timestamp: index as u64,
provenance: protocol::SessionEntryProvenance::LegacyUnknown,
derived_from: Vec::new(),
data: protocol::SessionSnapshotEntryData::RunError {
message: value.to_string(),
},
})
.collect(),
},
greeting: Greeting {
worker_name: "server".into(),
cwd: "/tmp".into(),
+30 -21
View File
@@ -112,8 +112,12 @@ impl InternalSpawnedWorkerRecord {
fn stop_summary(&self) -> SubWorkerStopSummary {
let mut counts = BTreeMap::<String, u64>::new();
for entry in self.session.entries() {
if let session_store::LogEntry::AssistantItem {
item: LoggedItem::ToolCall { name, .. },
if let session_store::LogEntry::AnnotatedAssistantItem {
entry:
session_store::LoggedHistoryEntry {
item: LoggedItem::ToolCall { name, .. },
..
},
..
} = entry
{
@@ -806,11 +810,7 @@ fn internal_worker_snapshot(
InternalWorkerSnapshot {
worker,
revision,
entries: snapshot
.entries
.into_iter()
.filter_map(|entry| serde_json::to_value(entry).ok())
.collect(),
session: snapshot.session,
status: snapshot.status,
error: snapshot.error,
in_flight: snapshot.in_flight,
@@ -1028,11 +1028,16 @@ mod tests {
&& worker.parent_session_id.as_deref() == Some("parent-session")
&& matches!(*event, Event::TextDone { ref text } if text == "answer")
));
record.session.publish_test_entry(LogEntry::UserInput {
ts: 1,
segments: vec![protocol::Segment::text("question")],
extensions: Vec::new(),
});
record
.session
.publish_test_entry(LogEntry::AnnotatedUserInput {
ts: 1,
segments: vec![protocol::Segment::text("question")],
history: vec![crate::session_history::test_logged_history_entry(
agen::Item::user_message("question"),
)],
extensions: Vec::new(),
});
let committed = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
.await
.unwrap()
@@ -1045,7 +1050,7 @@ mod tests {
let snapshots = registry.internal_worker_snapshots();
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].revision, 2);
assert_eq!(snapshots[0].entries.len(), 1);
assert_eq!(snapshots[0].session.entries.len(), 1);
record.session.emit_test_text_delta("partial");
let streamed = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
@@ -1163,14 +1168,18 @@ mod tests {
let (mut record, _events) = record("child", InternalWorkerVisibility::ParentClient).await;
record.change_tracker = Some(tracker);
for (index, name) in ["Read", "Read", "Grep"].into_iter().enumerate() {
record.session.publish_test_entry(LogEntry::AssistantItem {
ts: index as u64,
item: LoggedItem::ToolCall {
call_id: format!("call-{index}"),
name: name.to_string(),
arguments: "{}".to_string(),
},
});
record
.session
.publish_test_entry(LogEntry::AnnotatedAssistantItem {
ts: index as u64,
entry: crate::session_history::test_logged_history_entry(
LoggedItem::ToolCall {
call_id: format!("call-{index}"),
name: name.to_string(),
arguments: "{}".to_string(),
},
),
});
}
registry.start_protocol_forwarding(record.clone());
install_record(&registry, record);
+6 -6
View File
@@ -960,9 +960,7 @@ mod tests {
use crate::WorkspaceId;
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::types::ContentPart;
use agen::llm_client::{ClientError, LlmClient, Request};
use agen::{Item, Role};
use async_trait::async_trait;
use futures::Stream;
use manifest::{AuthRef, ModelManifest, SchemeKind, WorkerManifest};
@@ -1252,9 +1250,11 @@ extract_threshold = 4000
)
.await
.unwrap();
assert!(first_capture.entries.iter().map(|entry| &entry.item).any(|item| {
matches!(item, Item::Message { role: Role::Assistant, content, .. } if content.iter().any(|part| matches!(part, ContentPart::Text { text } if text.contains("reviewed"))))
}));
assert!(
serde_json::to_string(&first_capture.session)
.unwrap()
.contains("reviewed")
);
let send = (crate::spawn::comm_tools::sub_worker_send_tool(registry.clone()))().1;
send.execute(
@@ -1274,7 +1274,7 @@ extract_threshold = 4000
)
.await
.unwrap();
assert!(latest_capture.entries.len() > first_capture.entries.len());
assert!(latest_capture.session.entries.len() > first_capture.session.entries.len());
fail_requests.store(true, Ordering::SeqCst);
send.execute(
+51 -42
View File
@@ -900,7 +900,6 @@ where
self.state.increment_entries();
if let Some(in_flight) = &self.in_flight {
let committed_item = match &entry {
LogEntry::AssistantItem { item, .. } => Some(item.clone()),
LogEntry::AnnotatedAssistantItem { entry, .. } => Some(entry.item.clone()),
_ => None,
};
@@ -1207,11 +1206,11 @@ pub struct Worker<C: LlmClient, St: Store> {
memory_task: Option<JoinHandle<()>>,
/// Typed user submissions in submit order. K-th entry corresponds to
/// the K-th `Item::user_message` in `worker.history()` (modulo seed
/// history loaded via `SegmentStart.history`, whose original segments
/// history loaded via `AnnotatedSegmentStart.history`, whose original segments
/// are not preserved). Populated from log on `restore_from_manifest`,
/// appended after `save_user_input` on each `run`. Pre-`Event::Snapshot`
/// this fed `WorkerSharedState.user_segments`; the new wire format
/// carries typed atoms via `LogEntry::UserInput { segments }` so
/// carries typed atoms via `LogEntry::AnnotatedUserInput { segments }` so
/// this remains purely an in-memory tracker for compact alignment.
user_segments: Vec<Vec<Segment>>,
/// Worker-side session-log mirror + broadcast sink. Populated alongside
@@ -1221,7 +1220,8 @@ pub struct Worker<C: LlmClient, St: Store> {
sink: SegmentLogSink,
/// `true` once `wire_history_persistence` has installed the
/// `Engine::on_history_append` callback that commits each appended
/// item as a singular `LogEntry::AssistantItem` / `ToolResult`
/// item as a singular `LogEntry::AnnotatedAssistantItem` /
/// `AnnotatedToolResult`
/// directly through the writer. Tests that drive `Worker::new` without
/// going through the controller leave this `false`; `persist_turn`
/// then walks the post-`history_before` slice inline so entries
@@ -1345,15 +1345,16 @@ impl<C: LlmClient + 'static, St: Store + Clone + 'static> Worker<C, St> {
}
/// Wire `Engine::on_history_append` to commit each appended item
/// directly as a singular `LogEntry::AssistantItem` / `ToolResult`
/// directly as a singular `LogEntry::AnnotatedAssistantItem` /
/// `AnnotatedToolResult`
/// through the writer. The controller calls this once per spawned
/// Worker after the worker is built; tests that drive `Worker::new` may
/// opt in to the same wiring or leave it off (in which case
/// `persist_turn`'s inline fallback writes entries at turn end).
///
/// `user_message` items are skipped because they are committed
/// up-front via `commit_entry(LogEntry::UserInput { segments })`.
/// `role:system` items are committed as typed `LogEntry::SystemItem`
/// up-front via `commit_entry(LogEntry::AnnotatedUserInput { segments })`.
/// `role:system` items are committed as typed `LogEntry::AnnotatedSystemItem`
/// entries by their producers (for example `WorkerInterceptor` and
/// interrupted-turn prep) before they reach the worker's history, so this
/// callback would otherwise double-write them.
@@ -1941,8 +1942,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
}
let input = match entries.get(target.user_input_entry_index) {
Some(LogEntry::UserInput { segments, .. })
| Some(LogEntry::AnnotatedUserInput { segments, .. }) => segments.clone(),
Some(LogEntry::AnnotatedUserInput { segments, .. }) => segments.clone(),
_ => {
return Err(RewindError::Invalid(
"rewind target is no longer a user message".into(),
@@ -2081,8 +2081,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
/// Cheap clone via `Option<Clone>`.
/// Snapshot of the typed user segments tracked alongside worker
/// history. The K-th entry corresponds to the K-th `Item::user_message`
/// derived from `LogEntry::UserInput` entries (post-compaction); seed
/// history loaded via `SegmentStart.history` does not contribute,
/// derived from `LogEntry::AnnotatedUserInput` entries (post-compaction); seed
/// history loaded via `AnnotatedSegmentStart.history` does not contribute,
/// which is acceptable because the original segments are unrecoverable.
pub fn user_segments(&self) -> &[Vec<Segment>] {
&self.user_segments
@@ -3533,12 +3533,13 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
// slice from `history_before` inline so the test's
// `restore`-style assertions still see entries on disk.
if !self.history_persistence_wired {
let new_items: Vec<Item> = self.session.history().entries()[history_before..]
let new_entries: Vec<_> = self.session.history().entries()[history_before..]
.iter()
.map(|entry| entry.item.clone())
.map(to_logged_history_entry)
.collect();
let ts = segment_log::now_millis();
for item in &new_items {
for history_entry in new_entries {
let item = Item::from(history_entry.item.clone());
if item.is_user_message() {
continue;
}
@@ -3551,7 +3552,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
) {
continue;
}
let entry = session_store::classify_history_item(item, ts);
let entry = session_store::classify_logged_history_entry(history_entry, ts);
self.commit_entry(entry)?;
}
}
@@ -6257,8 +6258,7 @@ fn build_rewind_targets(segment_id: uuid::Uuid, entries: &[LogEntry]) -> Vec<Rew
let mut targets = Vec::new();
for (entry_index, entry) in entries.iter().enumerate() {
let (segments, ts) = match entry {
LogEntry::UserInput { segments, ts, .. }
| LogEntry::AnnotatedUserInput { segments, ts, .. } => (segments, ts),
LogEntry::AnnotatedUserInput { segments, ts, .. } => (segments, ts),
_ => continue,
};
turn_index += 1;
@@ -6300,8 +6300,7 @@ fn rewind_truncate_entries(entries: &[LogEntry], user_input_entry_index: usize)
fn suffix_has_tool_side_effects(entries: &[LogEntry]) -> bool {
entries.iter().any(|entry| match entry {
LogEntry::ToolResult { .. } | LogEntry::AnnotatedToolResult { .. } => true,
LogEntry::AssistantItem { item, .. } => logged_item_is_tool_call(item),
LogEntry::AnnotatedToolResult { .. } => true,
LogEntry::AnnotatedAssistantItem { entry, .. } => logged_item_is_tool_call(&entry.item),
_ => false,
})
@@ -7636,7 +7635,7 @@ mod build_summary_prompt_tests {
);
assert!(checkpoint.is_none());
let mut replacement_entries = vec![LogEntry::SegmentStart {
let mut replacement_entries = vec![LogEntry::AnnotatedSegmentStart {
ts: segment_log::now_millis(),
session_id: uuid::Uuid::nil(),
system_prompt: None,
@@ -7964,9 +7963,12 @@ mod build_summary_prompt_tests {
);
append_test_entry(
worker,
LogEntry::UserInput {
LogEntry::AnnotatedUserInput {
ts: ts + 1,
extensions: vec![],
history: vec![crate::session_history::test_logged_history_entry(
Item::user_message(text),
)],
segments: vec![text_segment(text)],
},
);
@@ -7986,16 +7988,18 @@ mod build_summary_prompt_tests {
append_user_turn(&worker, 20, "second message");
append_test_entry(
&worker,
LogEntry::ToolResult {
LogEntry::AnnotatedToolResult {
ts: 30,
item: session_store::LoggedItem::ToolResult {
call_id: "call-1".into(),
summary: "wrote a file".into(),
content: None,
attachments: Vec::new(),
disposition: Default::default(),
is_error: false,
},
entry: crate::session_history::test_logged_history_entry(
session_store::LoggedItem::ToolResult {
call_id: "call-1".into(),
summary: "wrote a file".into(),
content: None,
attachments: Vec::new(),
disposition: Default::default(),
is_error: false,
},
),
},
);
@@ -8029,16 +8033,18 @@ mod build_summary_prompt_tests {
append_user_turn(&worker, 20, "second message");
append_test_entry(
&worker,
LogEntry::ToolResult {
LogEntry::AnnotatedToolResult {
ts: 30,
item: session_store::LoggedItem::ToolResult {
call_id: "call-1".into(),
summary: "wrote a file".into(),
content: None,
attachments: Vec::new(),
disposition: Default::default(),
is_error: false,
},
entry: crate::session_history::test_logged_history_entry(
session_store::LoggedItem::ToolResult {
call_id: "call-1".into(),
summary: "wrote a file".into(),
content: None,
attachments: Vec::new(),
disposition: Default::default(),
is_error: false,
},
),
},
);
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
@@ -8456,9 +8462,9 @@ mod build_summary_prompt_tests {
worker.wire_history_persistence();
let dangling_call = Item::tool_call("call-1", "SideEffect", "{}");
worker
.commit_entry(LogEntry::AssistantItem {
.commit_entry(LogEntry::AnnotatedAssistantItem {
ts: segment_log::now_millis(),
item: dangling_call.clone().into(),
entry: crate::session_history::test_logged_history_entry(dangling_call.clone()),
})
.unwrap();
worker.set_history_for_test(vec![dangling_call]);
@@ -8863,9 +8869,12 @@ mod build_summary_prompt_tests {
);
worker.set_history_for_test(vec![evidence.clone()]);
worker
.commit_entry(LogEntry::UserInput {
.commit_entry(LogEntry::AnnotatedUserInput {
ts: segment_log::now_millis(),
extensions: vec![],
history: vec![crate::session_history::test_logged_history_entry(
evidence.clone(),
)],
segments: vec![text_segment(
"The cancellation regression must leave this evidence available for retry.",
)],
+20 -18
View File
@@ -25,6 +25,17 @@ use worker::{Worker, WorkerController};
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
fn annotated(item: Item) -> session_store::LoggedHistoryEntry {
session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::from(item),
metadata: session_store::LoggedSessionHistoryMetadata {
entry_id: session_store::LoggedSessionHistoryEntryId::new(),
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
},
}
}
#[derive(Clone)]
struct MockClient {
responses: Arc<Vec<Vec<LlmEvent>>>,
@@ -210,7 +221,6 @@ fn system_texts_in_sink_session_start(
.into_iter()
.map(|entry| entry.item)
.collect::<Vec<_>>(),
session_store::LogEntry::SegmentStart { history, .. } => history,
_ => continue,
};
return history
@@ -310,17 +320,14 @@ permission = "write"
// Simulate a foreign writer appending to the same segment. This bumps
// the on-disk entry count past the Worker's own append tally without
// updating the Worker's `entries_written`.
store
.append(
session_id,
source_segment_id,
&LogEntry::UserInput {
ts: 9999,
segments: vec![protocol::Segment::text("interloper")],
extensions: vec![],
},
)
.unwrap();
session_store::save_user_input(
&store,
session_id,
source_segment_id,
vec![protocol::Segment::text("interloper")],
vec![annotated(Item::user_message("interloper"))],
)
.unwrap();
// Next run triggers ensure_segment_head, which sees the drift.
worker.run_text("second").await.unwrap();
@@ -348,11 +355,6 @@ permission = "write"
session_id: seg_session,
forked_from: Some(origin),
..
}
| LogEntry::SegmentStart {
session_id: seg_session,
forked_from: Some(origin),
..
} => {
assert_eq!(*seg_session, session_id);
assert_eq!(origin.segment_id, source_segment_id);
@@ -366,7 +368,7 @@ permission = "write"
assert_eq!(source_after.len(), source_len_before + 1);
assert!(matches!(
source_after.last(),
Some(LogEntry::UserInput { .. })
Some(LogEntry::AnnotatedUserInput { .. })
));
}
+24 -43
View File
@@ -35,29 +35,16 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
LogEntry::AnnotatedSegmentStart { history, .. } => {
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
}
LogEntry::SegmentStart { history, .. } => {
items.extend(history.into_iter().map(Item::from));
}
LogEntry::AnnotatedUserInput { history, .. } => {
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
}
LogEntry::UserInput { segments, .. } => {
let text = protocol::Segment::flatten_to_text(&segments);
items.push(Item::user_message(text));
}
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => {
items.push(Item::from(entry.item));
}
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
items.push(Item::from(item));
}
LogEntry::AnnotatedSystemItem { entry, .. } => {
items.push(entry.item.to_history_item());
}
LogEntry::SystemItem { item, .. } => {
items.push(item.to_history_item());
}
_ => {}
}
}
@@ -67,7 +54,6 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
fn system_item(entry: &LogEntry) -> Option<&session_store::SystemItem> {
match entry {
LogEntry::AnnotatedSystemItem { entry, .. } => Some(&entry.item),
LogEntry::SystemItem { item, .. } => Some(item),
_ => None,
}
}
@@ -839,26 +825,26 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
loop {
let event = reader.next::<Event>().await.unwrap().unwrap();
match event {
Event::Snapshot { entries, .. } => {
// Walk the entries, find a `LogEntry::UserInput` and
// confirm its segments flatten to our submitted text.
let mut found = false;
for value in &entries {
let entry: session_store::LogEntry =
serde_json::from_value(value.clone()).expect("LogEntry deserialise");
if let session_store::LogEntry::UserInput { segments, .. }
| session_store::LogEntry::AnnotatedUserInput { segments, .. } = entry
{
let text = protocol::Segment::flatten_to_text(&segments);
if text == "hello in-flight" {
found = true;
break;
}
Event::Snapshot { session, .. } => {
let found = session.entries.iter().any(|entry| match &entry.data {
protocol::SessionSnapshotEntryData::UserInput { segments } => {
protocol::Segment::flatten_to_text(segments) == "hello in-flight"
}
}
protocol::SessionSnapshotEntryData::Message {
role: protocol::SessionMessageRole::User,
content,
} => content.iter().any(|part| {
matches!(
part,
protocol::SessionContentPart::Text { text }
if text == "hello in-flight"
)
}),
_ => false,
});
assert!(
found,
"snapshot must carry the in-flight UserInput entry: {entries:?}"
"snapshot must carry the in-flight UserInput entry: {session:?}"
);
return;
}
@@ -1095,7 +1081,7 @@ async fn run_with_paste_segment_inlines_content_and_emits_typed_user_message() {
// Mixed input: plain text + a paste chip + trailing text. Worker must
// flatten this into one user-message string (paste content inlined,
// no `[Clipboard ...]` label leaking to the LLM); the committed
// `LogEntry::UserInput` must carry the typed segments unchanged so
// `LogEntry::AnnotatedUserInput` must carry the typed segments unchanged so
// socket clients can derive `Event::UserMessage` and re-render the chip.
let segments = vec![
protocol::Segment::text("see "),
@@ -1130,7 +1116,7 @@ async fn run_with_paste_segment_inlines_content_and_emits_typed_user_message() {
_ => {}
},
entry = entry_rx.recv() => match entry {
Ok(session_store::LogEntry::UserInput { segments, .. } | session_store::LogEntry::AnnotatedUserInput { segments, .. }) => {
Ok(session_store::LogEntry::AnnotatedUserInput { segments, .. }) => {
user_input_segments = Some(segments);
if saw_turn_end {
break;
@@ -2410,17 +2396,12 @@ async fn snapshot_contains_user_input(handle: &WorkerHandle, needle: &str) -> bo
loop {
let event = reader.next::<Event>().await.unwrap().unwrap();
match event {
Event::Snapshot { entries, .. } => {
return entries.into_iter().any(|value| {
let entry: session_store::LogEntry =
serde_json::from_value(value).expect("LogEntry deserialise");
match entry {
session_store::LogEntry::UserInput { segments, .. }
| session_store::LogEntry::AnnotatedUserInput { segments, .. } => {
protocol::Segment::flatten_to_text(&segments).contains(needle)
}
_ => false,
Event::Snapshot { session, .. } => {
return session.entries.into_iter().any(|entry| match entry.data {
protocol::SessionSnapshotEntryData::UserInput { segments } => {
protocol::Segment::flatten_to_text(&segments).contains(needle)
}
_ => false,
});
}
Event::Alert(_) => continue,
@@ -203,12 +203,12 @@ async fn session_start_state_captures_rendered_prompt() {
.unwrap();
let first = entries.first().expect("at least one entry");
match first {
LogEntry::SegmentStart { system_prompt, .. } => {
LogEntry::AnnotatedSegmentStart { system_prompt, .. } => {
let sp = system_prompt.as_deref().expect("system prompt set");
assert!(sp.starts_with("hello"));
assert!(sp.contains(&pwd.display().to_string()));
}
other => panic!("expected SegmentStart as first entry, got {other:?}"),
other => panic!("expected AnnotatedSegmentStart as first entry, got {other:?}"),
}
}
+31 -2
View File
@@ -105,8 +105,13 @@ pub fn token_hash(token: &str) -> String {
}
pub fn new_user_code() -> String {
let hex = Uuid::now_v7().simple().to_string().to_ascii_uppercase();
format!("{}-{}", &hex[0..4], &hex[4..8])
user_code_from_uuid(Uuid::now_v7())
}
fn user_code_from_uuid(id: Uuid) -> String {
let hex = id.simple().to_string().to_ascii_uppercase();
let random_tail = &hex[24..32];
format!("{}-{}", &random_tail[..4], &random_tail[4..])
}
pub fn parse_bearer(headers: &HeaderMap) -> Option<String> {
@@ -207,3 +212,27 @@ pub fn auth_error(code: &str, message: &str) -> Error {
message: message.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_code_uses_the_uuid_random_tail() {
let id = Uuid::parse_str("01234567-89ab-7cde-8123-456789abcdef").expect("UUID");
assert_eq!(user_code_from_uuid(id), "89AB-CDEF");
}
#[test]
fn user_code_preserves_the_human_readable_format() {
let code = new_user_code();
assert_eq!(code.len(), 9);
assert_eq!(code.as_bytes()[4], b'-');
assert!(
code.chars()
.enumerate()
.all(|(index, value)| index == 4 || value.is_ascii_hexdigit())
);
assert_eq!(code, code.to_ascii_uppercase());
}
}
+9
View File
@@ -4449,6 +4449,13 @@ mod tests {
.resolve_profile("builtin:companion", root.path(), "embedded-test-companion")
.unwrap();
assert!(companion.feature.manage_workdir.enabled);
assert!(companion.feature.sub_worker.enabled);
assert!(!companion.feature.worker.enabled);
let coder = archive
.resolve_profile("builtin:coder", root.path(), "embedded-test-coder")
.unwrap();
assert!(coder.feature.sub_worker.enabled);
assert!(!coder.feature.worker.enabled);
}
#[test]
@@ -4475,6 +4482,8 @@ mod tests {
.resolve_profile("builtin:coder", root.path(), "remote-test-worker")
.unwrap();
assert_eq!(manifest.worker.name, "remote-test-worker");
assert!(manifest.feature.sub_worker.enabled);
assert!(!manifest.feature.worker.enabled);
}
#[test]
+311 -65
View File
@@ -5458,6 +5458,36 @@ fn resolve_workspace_ticket_reference(
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(reference.to_string())).into())
}
fn resolve_workspace_ticket_identity(
api: &WorkspaceApi,
workspace_id: &str,
reference: &str,
) -> ApiResult<String> {
let ticket_id = resolve_workspace_ticket_reference(api, workspace_id, reference)?;
let ticket = browser_ticket_backend(api)?
.show(ticket_id.clone().into())
.map_err(Error::from)?;
if ticket.meta.id != ticket_id {
return Err(Error::InvalidInput(
"resolved Ticket identity does not match Ticket authority".to_string(),
)
.into());
}
Ok(ticket_id)
}
fn resolve_workspace_worker_ticket_assignment(
api: &WorkspaceApi,
workspace_id: &str,
assignment: &mut Option<CreateWorkspaceWorkerTicketAssignmentRequest>,
) -> ApiResult<()> {
if let Some(assignment) = assignment {
assignment.ticket_id =
resolve_workspace_ticket_identity(api, workspace_id, &assignment.ticket_id)?;
}
Ok(())
}
#[derive(Debug, serde::Deserialize)]
struct MergeRequestListHttpQuery {
state: Option<String>,
@@ -6418,9 +6448,18 @@ fn canonical_ticket_resource_key(resource_key: &str) -> Option<&str> {
.then_some(resource_key)
}
fn ticket_notification_content(resource_key: &str, current_state: &str) -> String {
fn ticket_notification_content(
resource_key: &str,
previous_state: &str,
current_state: &str,
) -> String {
if previous_state == current_state {
return format!(
"Ticket {resource_key} has new activity while {current_state}. Reread the current Ticket before acting."
);
}
format!(
"Ticket {resource_key} changed to {current_state}. Reread the current Ticket before acting."
"Ticket {resource_key} changed state from {previous_state} to {current_state}. Reread the current Ticket before acting."
)
}
@@ -6498,7 +6537,7 @@ fn notify_ticket_recipients(
api: &WorkspaceApi,
workspace_id: &str,
ticket_id: &str,
_previous_state: &str,
previous_state: &str,
current_state: &str,
source: Option<RuntimeWorkerRef>,
) {
@@ -6532,7 +6571,7 @@ fn notify_ticket_recipients(
recipients.sort();
recipients.dedup();
let content = ticket_notification_content(resource_key, current_state);
let content = ticket_notification_content(resource_key, previous_state, current_state);
for recipient in recipients {
if source.as_ref().is_some_and(|source| source == &recipient) {
continue;
@@ -8217,6 +8256,11 @@ async fn spawn_known_worker(
) -> ApiResult<Json<BrowserCreateWorkerResponse>> {
validate_workspace_scope(&api, &path.workspace_id)?;
let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?;
resolve_workspace_worker_ticket_assignment(
&api,
&path.workspace_id,
&mut request.ticket_assignment,
)?;
let relation = if request.ticket_assignment.is_some() {
"assigned"
} else {
@@ -8513,7 +8557,7 @@ async fn scoped_capture_worker_observation_session(
message: "worker protocol closed before the session snapshot".to_string(),
})
})?;
let protocol::Event::Snapshot { entries, .. } = event else {
let protocol::Event::Snapshot { session, .. } = event else {
return Err(ApiError::from(Error::RuntimeOperationFailed {
runtime_id: target.runtime_id.clone(),
code: "worker_observation_missing_snapshot".to_string(),
@@ -8522,7 +8566,7 @@ async fn scoped_capture_worker_observation_session(
};
Ok(Json(serde_json::json!({
"segment_id": format!("runtime:{}:worker:{}", target.runtime_id, target.worker_id),
"entries": entries,
"session": session,
})))
}
@@ -9261,9 +9305,8 @@ fn cleanup_working_directory_for_runtime(
result.diagnostics,
));
};
let record = workdir_record_from_summary(&api, runtime_id, &working_directory.summary);
api.store.upsert_workdir_registry(&record)?;
let mut summary = working_directory.summary;
persist_workdir_cleanup_observation(&api, runtime_id, &summary)?;
apply_workdir_occupancy_projection(&api, &mut summary)?;
Ok(Json(BrowserWorkingDirectoryDetailResponse {
workspace_id: api.config.workspace_id.clone(),
@@ -10533,35 +10576,48 @@ async fn post_device_login_start(
State(api): State<ServerAuthApi>,
Json(request): Json<DeviceLoginStartRequest>,
) -> ApiResult<Json<DeviceLoginStartResponse>> {
const MAX_CODE_ALLOCATION_ATTEMPTS: usize = 8;
let auth = auth_public_config(&api.config);
let device_code = mint_secret("yoi_device");
let user_code = new_user_code();
let verification_uri = format!(
"{}/login/device",
auth.public_base_url.trim_end_matches('/')
);
let verification_uri_complete = format!("{verification_uri}?user_code={user_code}");
api.store.create_device_login_flow(&DeviceLoginFlowRecord {
device_code: device_code.clone(),
user_code: user_code.clone(),
verification_uri: verification_uri.clone(),
client_name: request.client_name,
user_id: None,
api_token_id: None,
issued_access_token: None,
created_at: crate::auth::now_rfc3339(),
expires_at: rfc3339_after(Duration::minutes(10)),
approved_at: None,
consumed_at: None,
})?;
Ok(Json(DeviceLoginStartResponse {
device_code,
user_code,
verification_uri,
verification_uri_complete,
expires_in: 600,
interval: 5,
}))
for _ in 0..MAX_CODE_ALLOCATION_ATTEMPTS {
let device_code = mint_secret("yoi_device");
let user_code = new_user_code();
let verification_uri_complete = format!("{verification_uri}?user_code={user_code}");
let flow = DeviceLoginFlowRecord {
device_code: device_code.clone(),
user_code: user_code.clone(),
verification_uri: verification_uri.clone(),
client_name: request.client_name.clone(),
user_id: None,
api_token_id: None,
issued_access_token: None,
created_at: crate::auth::now_rfc3339(),
expires_at: rfc3339_after(Duration::minutes(10)),
approved_at: None,
consumed_at: None,
};
if !api.store.try_create_device_login_flow(&flow)? {
continue;
}
return Ok(Json(DeviceLoginStartResponse {
device_code,
user_code,
verification_uri,
verification_uri_complete,
expires_in: 600,
interval: 5,
}));
}
Err(auth_error(
"device_login_code_allocation_failed",
"could not allocate a unique device login user code",
)
.into())
}
async fn post_device_login_approve(
@@ -14154,11 +14210,7 @@ fn sync_runtime_workdir_observations(
api.store.upsert_workdir_registry(&updated)?;
}
} else {
record.materialization_status =
workdir_status_from_runtime_miss(result.diagnostics.as_slice()).to_string();
record.cleanliness = "unknown".to_string();
record.updated_at = now_registry_timestamp();
api.store.upsert_workdir_registry(&record)?;
persist_workdir_runtime_miss(api, record, result.diagnostics.as_slice())?;
}
}
Err(_) => {
@@ -14172,17 +14224,54 @@ fn sync_runtime_workdir_observations(
Ok(response.diagnostics)
}
fn workdir_status_from_runtime_miss(diagnostics: &[RuntimeDiagnostic]) -> &'static str {
if diagnostics
fn persist_workdir_cleanup_observation(
api: &WorkspaceApi,
runtime_id: &str,
summary: &WorkingDirectorySummary,
) -> ApiResult<()> {
if summary.status == WorkingDirectoryStatusKind::NotFound {
api.store.delete_workdir_registry(
&api.config.workspace_id,
summary.working_directory_id.as_str(),
)?;
} else {
let record = workdir_record_from_summary(api, runtime_id, summary);
api.store.upsert_workdir_registry(&record)?;
}
Ok(())
}
fn workdir_runtime_miss_is_not_found(diagnostics: &[RuntimeDiagnostic]) -> bool {
diagnostics
.iter()
.any(|diagnostic| diagnostic.code == "working_directory_not_found")
{
}
fn workdir_status_from_runtime_miss(diagnostics: &[RuntimeDiagnostic]) -> &'static str {
if workdir_runtime_miss_is_not_found(diagnostics) {
"not_found"
} else {
"unknown"
}
}
fn persist_workdir_runtime_miss(
api: &WorkspaceApi,
mut record: WorkdirRegistryRecord,
diagnostics: &[RuntimeDiagnostic],
) -> ApiResult<()> {
if workdir_runtime_miss_is_not_found(diagnostics) {
api.store
.delete_workdir_registry(&api.config.workspace_id, record.workdir_id.as_str())?;
} else {
record.materialization_status = "unknown".to_string();
record.cleanliness = "unknown".to_string();
record.updated_at = now_registry_timestamp();
api.store.upsert_workdir_registry(&record)?;
}
Ok(())
}
fn sync_all_runtime_workdir_observations(api: &WorkspaceApi) -> Vec<RuntimeDiagnostic> {
let mut diagnostics = Vec::new();
let runtimes = api.runtime.list_runtimes(api.config.max_records.min(200));
@@ -16753,7 +16842,7 @@ mod tests {
)
.await
.unwrap();
assert!(capture["entries"].is_array());
assert!(capture["session"]["entries"].is_array());
let revoked = api
.store
@@ -16980,22 +17069,24 @@ mod tests {
#[test]
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!(
workdir_status_from_runtime_miss(&[RuntimeDiagnostic {
code: "working_directory_not_found".to_string(),
severity: DiagnosticSeverity::Warning,
message: "missing".to_string(),
}]),
workdir_status_from_runtime_miss(&typed_not_found),
"not_found"
);
assert_eq!(
workdir_status_from_runtime_miss(&[RuntimeDiagnostic {
code: "some_other_not_found".to_string(),
severity: DiagnosticSeverity::Warning,
message: "not a typed workdir miss".to_string(),
}]),
"unknown"
);
assert!(workdir_runtime_miss_is_not_found(&typed_not_found));
let unrelated = [RuntimeDiagnostic {
code: "some_other_not_found".to_string(),
severity: DiagnosticSeverity::Warning,
message: "not a typed workdir miss".to_string(),
}];
assert_eq!(workdir_status_from_runtime_miss(&unrelated), "unknown");
assert!(!workdir_runtime_miss_is_not_found(&unrelated));
}
struct DeterministicExecutionBackend {
@@ -17684,6 +17775,49 @@ mod tests {
assert_ne!(csrf_accepted.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn device_login_start_can_retry_while_previous_flow_is_pending() {
let workspace = tempfile::tempdir().unwrap();
let api = test_api(workspace.path()).await;
let app = build_router(api.clone());
let mut starts = Vec::new();
for _ in 0..2 {
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/api/auth/device-login/start")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"client_name":"retry-test"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
starts.push(
serde_json::from_slice::<serde_json::Value>(&body)
.expect("device login start response"),
);
}
assert_ne!(starts[0]["device_code"], starts[1]["device_code"]);
assert_ne!(starts[0]["user_code"], starts[1]["user_code"]);
for start in starts {
let device_code = start["device_code"].as_str().expect("device code");
let user_code = start["user_code"].as_str().expect("user code");
assert_eq!(user_code.len(), 9);
assert!(
api.store
.get_device_login_flow_by_device_code(device_code)
.expect("stored device login flow")
.is_some()
);
}
}
#[tokio::test]
async fn direct_workspace_router_enforces_origin_on_browser_auth_mutations() {
let workspace = tempfile::tempdir().unwrap();
@@ -18243,16 +18377,19 @@ mod tests {
}
#[test]
fn ticket_notification_projection_exposes_only_resource_key_and_current_state() {
fn ticket_notification_projection_distinguishes_state_changes_from_new_activity() {
const INTERNAL_ID: &str = "00001KZ9SR97B";
for current_state in ["queued", "inprogress"] {
let content = ticket_notification_content("T-429", current_state);
assert_eq!(
content,
format!(
"Ticket T-429 changed to {current_state}. Reread the current Ticket before acting."
)
);
let state_change = ticket_notification_content("T-429", "ready", "queued");
assert_eq!(
state_change,
"Ticket T-429 changed state from ready to queued. Reread the current Ticket before acting."
);
let new_activity = ticket_notification_content("T-429", "inprogress", "inprogress");
assert_eq!(
new_activity,
"Ticket T-429 has new activity while inprogress. Reread the current Ticket before acting."
);
for content in [state_change, new_activity] {
assert!(!content.contains(INTERNAL_ID));
for forbidden in [
"workspace_id",
@@ -18427,15 +18564,23 @@ mod tests {
}
let inputs = execution.take_inputs();
let expected_states = ["queued", "inprogress", "inprogress", "inprogress"];
let expected_states = [
("ready", "queued"),
("inprogress", "inprogress"),
("inprogress", "inprogress"),
("inprogress", "inprogress"),
];
assert_eq!(inputs.len(), expected_states.len());
for ((recipient, content), current_state) in inputs.iter().zip(expected_states) {
for ((recipient, content), (previous_state, current_state)) in
inputs.iter().zip(expected_states)
{
assert_eq!(recipient.worker_id.to_string(), orchestrator.worker_id);
assert!(!content.contains(&ticket.id));
assert_eq!(
content,
&ticket_notification_content(
ticket.resource_key.as_deref().unwrap(),
previous_state,
current_state,
)
);
@@ -19554,6 +19699,7 @@ mod tests {
notifications[0].1,
ticket_notification_content(
ticket_ref.resource_key.as_deref().unwrap(),
TicketWorkflowState::Queued.as_str(),
TicketWorkflowState::Queued.as_str()
)
);
@@ -20186,6 +20332,25 @@ mod tests {
.unwrap(),
ticket_id
);
assert_eq!(
resolve_workspace_ticket_identity(&api, TEST_WORKSPACE_ID, &ticket_resource_key)
.unwrap(),
ticket_id
);
assert_eq!(
resolve_workspace_ticket_identity(&api, TEST_WORKSPACE_ID, &ticket_id).unwrap(),
ticket_id
);
let mut assignment = Some(CreateWorkspaceWorkerTicketAssignmentRequest {
ticket_id: ticket_resource_key.clone(),
operation_id: "ticket-key-assignment".to_string(),
});
resolve_workspace_worker_ticket_assignment(&api, TEST_WORKSPACE_ID, &mut assignment)
.unwrap();
assert_eq!(assignment.unwrap().ticket_id, ticket_id);
let missing =
resolve_workspace_ticket_identity(&api, TEST_WORKSPACE_ID, "T-999999").unwrap_err();
assert_eq!(missing.into_response().status(), StatusCode::NOT_FOUND);
let path = || ScopedRecordPath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
id: ticket_id.clone(),
@@ -21308,6 +21473,87 @@ mod tests {
.unwrap();
}
#[tokio::test]
async fn confirmed_runtime_miss_removes_registry_record_but_unknown_is_retained() {
let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await;
seed_cleanup_workdir(&api, "deleted-workdir", "present", "clean");
let deleted = api
.store
.get_workdir_registry(TEST_WORKSPACE_ID, "deleted-workdir")
.unwrap()
.unwrap();
persist_workdir_runtime_miss(
&api,
deleted,
&[RuntimeDiagnostic {
code: "working_directory_not_found".to_string(),
severity: DiagnosticSeverity::Warning,
message: "missing".to_string(),
}],
)
.unwrap();
assert!(
api.store
.get_workdir_registry(TEST_WORKSPACE_ID, "deleted-workdir")
.unwrap()
.is_none()
);
seed_cleanup_workdir(&api, "unknown-workdir", "present", "clean");
let unknown = api
.store
.get_workdir_registry(TEST_WORKSPACE_ID, "unknown-workdir")
.unwrap()
.unwrap();
persist_workdir_runtime_miss(
&api,
unknown,
&[RuntimeDiagnostic {
code: "runtime_unavailable".to_string(),
severity: DiagnosticSeverity::Warning,
message: "temporarily unavailable".to_string(),
}],
)
.unwrap();
assert_eq!(
api.store
.get_workdir_registry(TEST_WORKSPACE_ID, "unknown-workdir")
.unwrap()
.unwrap()
.materialization_status,
"unknown"
);
}
#[tokio::test]
async fn cleanup_not_found_observation_removes_registry_record() {
let workspace = tempfile::tempdir().unwrap();
init_clean_git_workspace(workspace.path());
let api = test_api(workspace.path()).await;
let working_directory_id = "cleanup-existing";
seed_cleanup_workdir(&api, working_directory_id, "present", "clean");
let record = api
.store
.get_workdir_registry(TEST_WORKSPACE_ID, working_directory_id)
.unwrap()
.unwrap();
let mut summary = workdir_summary_from_record(&record);
summary.status = WorkingDirectoryStatusKind::NotFound;
persist_workdir_cleanup_observation(&api, "runtime-test", &summary).unwrap();
assert!(
api.store
.get_workdir_registry(TEST_WORKSPACE_ID, working_directory_id)
.unwrap()
.is_none()
);
}
fn seed_cleanup_link(api: &WorkspaceApi, runtime_worker_id: &str, workdir_id: &str) {
let runtime_worker_id = runtime_worker_id.parse::<u64>().unwrap();
api.store
+67 -6
View File
@@ -996,7 +996,7 @@ pub trait ControlPlaneStore: Send + Sync {
fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()>;
fn resolve_api_token(&self, token_hash: &str) -> Result<Option<ApiTokenRecord>>;
fn mark_api_token_used(&self, token_hash: &str, used_at: &str) -> Result<()>;
fn create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<()>;
fn try_create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<bool>;
fn get_device_login_flow_by_user_code(
&self,
user_code: &str,
@@ -3123,14 +3123,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<()> {
fn try_create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<bool> {
self.with_conn(|conn| {
conn.execute(
let changed = conn.execute(
r#"INSERT INTO device_login_flows (device_code, user_code, verification_uri, client_name, user_id, api_token_id, issued_access_token, created_at, expires_at, approved_at, consumed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"#,
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(user_code) DO NOTHING"#,
params![record.device_code, record.user_code, record.verification_uri, record.client_name, record.user_id, record.api_token_id, record.issued_access_token, record.created_at, record.expires_at, record.approved_at, record.consumed_at],
)?;
Ok(())
Ok(changed == 1)
})
}
@@ -13524,7 +13525,67 @@ CREATE TABLE ticket_assignment_operations (
approved_at: None,
consumed_at: None,
};
store.create_device_login_flow(&flow).unwrap();
assert!(store.try_create_device_login_flow(&flow).unwrap());
let conflicting_flow = DeviceLoginFlowRecord {
device_code: "device-2".into(),
user_code: flow.user_code.clone(),
verification_uri: flow.verification_uri.clone(),
client_name: Some("other-cli".into()),
user_id: None,
api_token_id: None,
issued_access_token: None,
created_at: "2026-08-22T00:00:01Z".into(),
expires_at: "2026-08-22T00:10:01Z".into(),
approved_at: None,
consumed_at: None,
};
assert!(
!store
.try_create_device_login_flow(&conflicting_flow)
.expect("user code collision")
);
assert!(
store
.get_device_login_flow_by_device_code("device-2")
.expect("read conflicting flow")
.is_none()
);
let fresh_flow = DeviceLoginFlowRecord {
device_code: "device-3".into(),
user_code: "DCBA-5678".into(),
verification_uri: flow.verification_uri.clone(),
client_name: Some("retry-cli".into()),
user_id: None,
api_token_id: None,
issued_access_token: None,
created_at: "2026-08-22T00:00:02Z".into(),
expires_at: "2026-08-22T00:10:02Z".into(),
approved_at: None,
consumed_at: None,
};
assert!(
store
.try_create_device_login_flow(&fresh_flow)
.expect("fresh user code")
);
let device_code_conflict = DeviceLoginFlowRecord {
device_code: flow.device_code.clone(),
user_code: "ABCD-9999".into(),
verification_uri: flow.verification_uri.clone(),
client_name: Some("invalid-cli".into()),
user_id: None,
api_token_id: None,
issued_access_token: None,
created_at: "2026-08-22T00:00:03Z".into(),
expires_at: "2026-08-22T00:10:03Z".into(),
approved_at: None,
consumed_at: None,
};
assert!(
store
.try_create_device_login_flow(&device_code_conflict)
.is_err()
);
store
.approve_device_login_flow(
"device",
+7 -56
View File
@@ -22,7 +22,7 @@ use cli_connection::{
};
use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login};
use memory_lint::{LintCliOptions, LintStatus};
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use session_store::SegmentId;
use tui::{LaunchMode, LaunchOptions};
@@ -1220,65 +1220,16 @@ async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> {
)
.await
.map_err(|error| ParseError(error.to_string()))?;
save_backend_token(backend_url, &token)?;
println!("Saved Backend API token for {backend_url}");
Ok(())
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct BackendTokenFile {
#[serde(default)]
tokens: BTreeMap<String, BackendTokenEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BackendTokenEntry {
token_type: String,
access_token: String,
}
fn save_backend_token(backend_url: &str, access_token: &str) -> Result<(), ParseError> {
let path = backend_token_path().ok_or_else(|| {
ParseError("HOME or XDG_CONFIG_HOME is required to save Backend token".to_string())
})?;
let mut file = if path.is_file() {
let contents = fs::read_to_string(&path)
.map_err(|error| ParseError(format!("failed to read {}: {error}", path.display())))?;
serde_json::from_str::<BackendTokenFile>(&contents)
.map_err(|error| ParseError(format!("failed to parse {}: {error}", path.display())))?
} else {
BackendTokenFile::default()
};
file.tokens.insert(
backend_url.trim_end_matches('/').to_string(),
BackendTokenEntry {
token_type: "Bearer".to_string(),
access_token: access_token.to_string(),
},
let token_path = client::save_backend_token(backend_url, "Bearer", &token)
.map_err(|error| ParseError(error.to_string()))?;
println!(
"Saved Backend API token for {} in {}",
client::BackendOrigin::parse(backend_url).map_err(|error| ParseError(error.to_string()))?,
token_path.display()
);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
ParseError(format!("failed to create {}: {error}", parent.display()))
})?;
}
let serialized = serde_json::to_string_pretty(&file)
.map_err(|error| ParseError(format!("failed to serialize Backend token file: {error}")))?;
fs::write(&path, format!("{serialized}\n"))
.map_err(|error| ParseError(format!("failed to write {}: {error}", path.display())))?;
Ok(())
}
fn backend_token_path() -> Option<PathBuf> {
yoi_config_dir().map(|dir| dir.join("backend-tokens.json"))
}
fn yoi_config_dir() -> Option<PathBuf> {
if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") {
return Some(PathBuf::from(home).join("yoi"));
}
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("yoi"))
}
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError(
@@ -19,7 +19,7 @@ The Workspace Server owns one control-plane SQLite database. Schema changes are
Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration:
- rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity;
- rebuilds Ticket, Objective, assignment, Artifact, and resource-key tables with Workspace-scoped composite identity;
- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references;
- materializes assignment-specific Worker tombstones for pre-v39 historical assignments whose valid Worker UUID no longer has a matching live registry row (including Workers deleted by the legacy cleanup path and Workers moved between Runtimes); a Worker ID that resolves only in another Workspace remains a preflight error;
- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; parent delete/Runtime-move triggers record exact Workspace-scoped tombstones, and startup accepts a missing live parent only when that tombstone exists, so an unrelated same ID in another Workspace cannot change the result; reservation operation ids remain intentionally unconstrained until their resources exist;
-1
View File
@@ -10,7 +10,6 @@ import "./base.dcdl" // {
web = { enabled = true; };
sub_worker = { enabled = true; };
flow = { enabled = true; };
worker = { enabled = true; };
ticket = { enabled = true; thread = true; };
merge_request = {
show = true;
-1
View File
@@ -8,7 +8,6 @@ import "./base.dcdl" // {
memory = { enabled = true; };
web = { enabled = true; };
sub_worker = { enabled = true; };
worker = { enabled = true; };
manage_workdir = { enabled = true; };
ticket = { enabled = true; authoring = true; thread = true; };
};
+1 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
"test": "deno test --allow-read=src,test --allow-env=LOG,VSCODE_TEXTMATE_DEBUG src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},
+22 -12
View File
@@ -9,9 +9,9 @@
"npm:@codemirror/view@6.43.8": "6.43.8",
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
"npm:@lezer/highlight@1.2.3": "1.2.3",
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7",
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7",
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0",
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
"npm:clsx@2.1.1": "2.1.1",
"npm:cookie@0.6.0": "0.6.0",
"npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10",
@@ -23,7 +23,8 @@
"npm:svelte-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3",
"npm:svelte@5.45.6": "5.45.6",
"npm:typescript@5.9.3": "5.9.3",
"npm:vite@7.2.7": "7.2.7"
"npm:vite@7.2.7": "7.2.7_yaml@2.9.0",
"npm:yaml@2.9.0": "2.9.0"
},
"jsr": {
"@std/assert@1.0.19": {
@@ -433,13 +434,13 @@
"acorn"
]
},
"@sveltejs/adapter-static@3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7": {
"@sveltejs/adapter-static@3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0": {
"integrity": "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==",
"dependencies": [
"@sveltejs/kit"
]
},
"@sveltejs/kit@2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7": {
"@sveltejs/kit@2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
"integrity": "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg==",
"dependencies": [
"@standard-schema/spec",
@@ -465,7 +466,7 @@
],
"bin": true
},
"@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_vite@7.2.7": {
"@sveltejs/vite-plugin-svelte-inspector@5.0.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
"integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
"dependencies": [
"@sveltejs/vite-plugin-svelte",
@@ -474,7 +475,7 @@
"vite"
]
},
"@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.45.6_vite@7.2.7": {
"@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
"dependencies": [
"@sveltejs/vite-plugin-svelte-inspector",
@@ -966,7 +967,7 @@
"vfile-message"
]
},
"vite@7.2.7": {
"vite@7.2.7_yaml@2.9.0": {
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"dependencies": [
"esbuild",
@@ -974,14 +975,18 @@
"picomatch",
"postcss",
"rollup",
"tinyglobby"
"tinyglobby",
"yaml"
],
"optionalDependencies": [
"fsevents"
],
"optionalPeers": [
"yaml"
],
"bin": true
},
"vitefu@1.1.2_vite@7.2.7": {
"vitefu@1.1.2_vite@7.2.7__yaml@2.9.0_yaml@2.9.0": {
"integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==",
"dependencies": [
"vite"
@@ -993,6 +998,10 @@
"w3c-keyname@2.2.8": {
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
},
"yaml@2.9.0": {
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"bin": true
},
"zimmerframe@1.1.4": {
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
},
@@ -1024,7 +1033,8 @@
"packageJson": {
"dependencies": [
"npm:@fontsource/ibm-plex-mono@5.3.0",
"npm:gen-interface-jp@0.8.0"
"npm:gen-interface-jp@0.8.0",
"npm:yaml@2.9.0"
]
}
}
+2 -1
View File
@@ -5,6 +5,7 @@
"type": "module",
"dependencies": {
"@fontsource/ibm-plex-mono": "5.3.0",
"gen-interface-jp": "0.8.0"
"gen-interface-jp": "0.8.0",
"yaml": "2.9.0"
}
}
+31 -3
View File
@@ -73,11 +73,39 @@ export type InFlightBlock = { "kind": "text", text: string, finished?: boolean,
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, commands?: Array<CommandSnapshot>, };
export type SessionEntryProvenance = "human_input" | "worker_input" | "flow_instruction" | "backend_instruction" | "model_output" | "tool_output" | "derived_summary" | "legacy_unknown";
export type SessionMessageRole = "user" | "assistant";
export type SessionContentPart = { "kind": "text", text: string, } | { "kind": "refusal", refusal: string, };
export type SessionToolAttachment = { media_type: string,
/**
* Base64-encoded durable attachment body. Public snapshots preserve the
* committed multimodal value instead of replacing it with placeholder text.
*/
data_base64: string, };
export type SessionSnapshotEntryData = { "kind": "user_input", segments: Array<Segment>, } | { "kind": "message", role: SessionMessageRole, content: Array<SessionContentPart>, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array<SessionToolAttachment>, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_error", message: string, };
export type SessionSnapshotEntry = {
/**
* Stable identity from durable history metadata, or a deterministic
* identity derived from the legacy segment and log position.
*/
entry_id: string,
/**
* Timestamp copied from the durable log record that commits this entry.
*/
timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array<string>, } & ({ "kind": "user_input", segments: Array<Segment>, } | { "kind": "message", role: SessionMessageRole, content: Array<SessionContentPart>, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array<SessionToolAttachment>, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_error", message: string, });
export type SessionSnapshot = { entries: Array<SessionSnapshotEntry>, };
export type InternalWorkerKind = "sub_worker" | { "service": { kind: string, } };
export type InternalWorkerRef = { session_id: string, name: string, parent_session_id?: string | null, kind: InternalWorkerKind, };
export type InternalWorkerSnapshot = { worker: InternalWorkerRef, revision: number, entries: Array<unknown>, status: WorkerStatus, error?: string | null, in_flight?: InFlightSnapshot, internal_workers?: Array<InternalWorkerSnapshot>, };
export type InternalWorkerSnapshot = { worker: InternalWorkerRef, revision: number, session: SessionSnapshot, status: WorkerStatus, error?: string | null, in_flight?: InFlightSnapshot, internal_workers?: Array<InternalWorkerSnapshot>, };
export type Greeting = { worker_name: string, cwd: string, provider: string, model: string, scope_summary: string, tools: Array<string>,
/**
@@ -193,7 +221,7 @@ summary: string,
* Full tool output. Absent when the tool chose to return
* summary-only, or when the result was pruned.
*/
output?: string | null, disposition?: ToolResultDisposition | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { entries: Array<unknown>, greeting: Greeting, status: WorkerStatus,
output?: string | null, disposition?: ToolResultDisposition | null, is_error: boolean, } } | { "event": "usage", "data": { input_tokens: number | null, output_tokens: number | null, cache_read_input_tokens?: number | null, } } | { "event": "run_end", "data": { result: RunResult, } } | { "event": "error", "data": { code: ErrorCode, message: string, } } | { "event": "snapshot", "data": { session: SessionSnapshot, greeting: Greeting, status: WorkerStatus,
/**
* Unfinished model output that has already streamed in the current
* run but is not yet represented by committed snapshot entries.
@@ -203,4 +231,4 @@ in_flight?: InFlightSnapshot,
* Parent-owned Internal Worker sessions visible to this client.
* Service-private Internal Workers are deliberately excluded.
*/
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { entries: Array<unknown>, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
internal_workers?: Array<InternalWorkerSnapshot>, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { session: SessionSnapshot, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array<CompletionEntry>, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array<RewindTarget>, } } | { "event": "rewind_applied", "data": { session: SessionSnapshot, input: Array<Segment>, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_done", "data": { lifecycle: CompactionLifecycle, } } | { "event": "compact_failed", "data": { lifecycle: CompactionLifecycle, } } | { "event": "shutdown" };
@@ -8,6 +8,7 @@
};
let { item }: Props = $props();
let detailOpen = $state(false);
let nowMs = $state(Date.now());
$effect(() => {
@@ -55,22 +56,20 @@
line.kind !== 'activity' && line.kind !== 'task_reminder' && line.kind !== 'run_stats';
}
function toolSummary(line: ConsoleLine): { label: string; suffix: string; rest: string } {
const [firstLine = '', ...rest] = line.body.split('\n');
const [label, suffix = ''] = firstLine.split(' — ', 2);
return {
label,
suffix,
rest: rest.join('\n')
};
function toolLabel(line: ConsoleLine): string {
return line.toolCallLabel ?? line.toolCall?.name ?? line.title;
}
function toolStatus(line: ConsoleLine): string {
return line.toolStatus ?? line.toolCall?.state ?? '';
}
function shouldRenderMarkdown(line: ConsoleLine): boolean {
return line.kind === 'user' || line.kind === 'assistant' || line.kind === 'system';
}
function bodyTextAfterToolSummary(line: ConsoleLine): string {
return toolSummary(line).rest;
function toolBodyText(line: ConsoleLine): string {
return detailOpen ? (line.expandedBody ?? line.body) : line.body;
}
</script>
@@ -107,20 +106,27 @@
</div>
{:else if item.kind === 'tool'}
<div class="tool-summary">
<span class="tool-label">{toolSummary(item).label}</span>
<span class="tool-separator"></span>
<span class={`tool-suffix ${item.toolCall?.state ?? ''}`}>{toolSummary(item).suffix}</span>
<span class="tool-label">{toolLabel(item)}</span>
<span class={`tool-status ${item.toolCall?.state ?? ''}`}>{toolStatus(item)}</span>
{#if item.detail}
<button
type="button"
class="tool-detail-button"
aria-expanded={detailOpen}
onclick={() => (detailOpen = !detailOpen)}
>detail</button>
{/if}
</div>
{/if}
{#if item.compaction}
<!-- rendered as one lifecycle item above -->
{:else if item.kind === 'tool'}
{#if bodyTextAfterToolSummary(item)}
{#if toolBodyText(item)}
<p class="console-plain-text">
{#if isBashTool(item)}
<AnsiText text={bodyTextAfterToolSummary(item)} />
<AnsiText text={toolBodyText(item)} />
{:else}
{bodyTextAfterToolSummary(item)}
{toolBodyText(item)}
{/if}
</p>
{/if}
@@ -147,11 +153,10 @@
{/each}
</div>
{/if}
{#if item.detail}
<details class="message-detail">
<summary>detail</summary>
{#if item.detail && detailOpen}
<div class="message-detail" role="region" aria-label={`${toolLabel(item)} detail`}>
<p>{item.detail}</p>
</details>
</div>
{/if}
</li>
@@ -306,48 +311,46 @@
.tool-summary {
display: flex;
align-items: baseline;
gap: 0;
gap: 0.5rem;
color: var(--text-muted);
font-size: 0.88rem;
font-weight: 750;
}
.tool-label {
flex: 0 0 auto;
color: var(--tui-cyan);
white-space: nowrap;
}
.tool-separator {
flex: 0 0 auto;
white-space: nowrap;
}
.tool-suffix {
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
overflow: hidden;
color: var(--tui-cyan);
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-separator,
.tool-suffix {
.tool-status {
flex: 0 0 auto;
color: var(--tui-dark-gray);
font-size: 0.72rem;
white-space: nowrap;
}
.tool-state-error .tool-suffix {
.tool-state-error .tool-status {
color: var(--tui-red);
}
.tool-state-running .tool-suffix,
.tool-state-streaming_args .tool-suffix,
.tool-state-pending .tool-suffix {
.tool-state-running .tool-status,
.tool-state-streaming_args .tool-status,
.tool-state-pending .tool-status {
color: var(--tui-yellow);
}
.tool-state-done .tool-suffix {
.tool-state-done .tool-status {
color: var(--tui-dark-gray);
}
.console-line.error-line .tool-status {
color: var(--tui-red);
}
.message-heading {
display: flex;
align-items: center;
@@ -412,13 +415,47 @@
color: var(--code);
}
.tool-detail-button {
margin-inline-start: auto;
border: 1px solid var(--line);
border-radius: 0.35rem;
padding: 0.08rem 0.35rem;
background: var(--bg-raised);
color: var(--text-muted);
cursor: pointer;
font: inherit;
font-size: 0.68rem;
font-weight: 750;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
}
.console-line:hover .tool-detail-button,
.tool-detail-button:focus-visible,
.tool-detail-button[aria-expanded='true'] {
opacity: 1;
pointer-events: auto;
}
.message-detail {
margin-top: 0.35rem;
border-left: 2px solid var(--line);
padding-left: 0.6rem;
color: var(--text-muted);
font-size: 0.84rem;
}
.message-detail summary {
cursor: pointer;
font-weight: 800;
.message-detail p {
margin: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
@media (hover: none) {
.tool-detail-button {
opacity: 1;
pointer-events: auto;
}
}
</style>
@@ -43,11 +43,84 @@ function consoleLine(id: string, kind: ConsoleLine["kind"]): ConsoleLine {
};
}
function canonicalSession(logEntries: unknown[]): Event extends {
event: "snapshot";
data: infer D;
} ? D extends { session: infer S } ? S : never : never {
const entries: Record<string, unknown>[] = [];
let sequence = 0;
const itemEntry = (item: Record<string, unknown>) => {
const kind = item["kind"];
if (kind === "reasoning" || item["role"] === "system") return;
entries.push({
entry_id: `legacy-test-${sequence++}`,
provenance: "legacy_unknown",
...item,
});
};
for (const raw of logEntries) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
const entry = raw as Record<string, unknown>;
switch (entry["kind"]) {
case "segment_start":
case "annotated_segment_start":
entries.length = 0;
for (const history of Array.isArray(entry["history"]) ? entry["history"] : []) {
const value = history as Record<string, unknown>;
itemEntry((value["item"] as Record<string, unknown> | undefined) ?? value);
}
break;
case "user_input":
case "annotated_user_input":
entries.push({
entry_id: `legacy-test-${sequence++}`,
provenance: "legacy_unknown",
kind: "user_input",
segments: entry["segments"] ?? [],
});
break;
case "assistant_item":
case "tool_result":
case "annotated_assistant_item":
case "annotated_tool_result": {
const annotated = entry["entry"] as Record<string, unknown> | undefined;
itemEntry((annotated?.["item"] as Record<string, unknown> | undefined) ??
(entry["item"] as Record<string, unknown>));
break;
}
case "system_item":
case "annotated_system_item": {
const annotated = entry["entry"] as Record<string, unknown> | undefined;
const item = (annotated?.["item"] as Record<string, unknown> | undefined) ??
(entry["item"] as Record<string, unknown>);
entries.push({
entry_id: `legacy-test-${sequence++}`,
provenance: "legacy_unknown",
kind: "system_item",
item_kind: item?.["kind"] ?? "system_item",
content: item?.["body"] ?? item?.["message"] ?? "",
data: item,
});
break;
}
case "run_errored":
entries.push({
entry_id: `legacy-test-${sequence++}`,
provenance: "legacy_unknown",
kind: "run_error",
message: entry["message"] ?? "Worker run failed.",
});
break;
}
}
return { entries } as never;
}
function snapshotEvent(cwd: string, entries: unknown[] = []): Event {
return {
event: "snapshot",
data: {
entries,
session: canonicalSession(entries),
greeting: {
worker_name: "Worker",
cwd,
@@ -138,7 +211,7 @@ Deno.test("segment rotation retains a live error beside the real SegmentStart hi
event: {
event: "segment_rotated",
data: {
entry: {
session: canonicalSession([{
kind: "segment_start",
ts: 5,
session_id: "session-1",
@@ -149,7 +222,7 @@ Deno.test("segment rotation retains a live error beside the real SegmentStart hi
role: "user",
content: [{ kind: "text", text: "retained conversation" }],
}],
},
}]),
},
} satisfies Event,
},
@@ -165,6 +238,108 @@ Deno.test("segment rotation retains a live error beside the real SegmentStart hi
);
});
Deno.test("reload snapshot projects provenance-annotated history entries", () => {
const metadata = {
entry_id: "history-entry-1",
origin: {
kind: "model_output",
worker: {
workspace_id: "workspace-secret",
runtime_id: "runtime-secret",
worker_id: "worker-secret",
},
},
};
const annotated = (item: unknown) => ({ item, metadata });
const projection = projectConsole([{
eventId: "annotated-reload",
event: snapshotEvent("/repo", [
{
kind: "annotated_segment_start",
ts: 1,
session_id: "session-1",
system_prompt: null,
config: {},
history: [annotated({
kind: "message",
role: "assistant",
content: [{ kind: "text", text: "older committed reply" }],
})],
},
{
kind: "annotated_user_input",
ts: 2,
segments: [{ kind: "text", content: "latest user message" }],
history: [annotated({
kind: "message",
role: "user",
content: [{ kind: "text", text: "latest user message" }],
})],
},
{
kind: "annotated_assistant_item",
ts: 3,
entry: annotated({
kind: "message",
role: "assistant",
content: [{ kind: "text", text: "latest committed reply" }],
}),
},
{
kind: "annotated_assistant_item",
ts: 4,
entry: annotated({
kind: "tool_call",
call_id: "annotated-call",
name: "Read",
arguments: '{"file_path":"/repo/a.md"}',
}),
},
{
kind: "annotated_tool_result",
ts: 5,
entry: annotated({
kind: "tool_result",
call_id: "annotated-call",
summary: "Read 1 line from /repo/a.md",
content: "1→content",
is_error: false,
}),
},
{
kind: "annotated_system_item",
ts: 6,
entry: annotated({
kind: "notification",
message: "Worker completed",
}),
},
]),
}]);
assertEquals(
projection.lines.map((line) =>
`${line.kind}:${line.toolCallLabel ?? line.body}`
),
[
"assistant:older committed reply",
"user:latest user message",
"assistant:latest committed reply",
"tool:Read(1 file)",
"system:Worker completed",
],
);
const visible = JSON.stringify(projection.lines);
assert(
!visible.includes("workspace-secret"),
"history metadata must not enter Console rows",
);
assert(
!visible.includes("runtime-secret"),
"history origin must remain non-visible metadata",
);
});
Deno.test("workerConsoleHref encodes runtime and worker target authority", () => {
assert(
workerConsoleHref({
@@ -313,26 +488,25 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () =>
!toolLines[0].streaming,
"completed tool call should not remain streaming",
);
assert(
toolLines[0].body.includes("$ pwd"),
"Bash command should be summarized",
);
assertEquals(toolLines[0].toolCallLabel, "Bash($ pwd)");
assertEquals(toolLines[0].toolStatus, "done");
assert(
toolLines[0].body.includes("/repo"),
"tool result should be folded into the Call block",
);
assert(
toolLines[0].body.includes("line9"),
"Bash result preview should include the ninth output line",
"Bash preview should include the ninth output line",
);
assert(
!toolLines[0].body.includes("line10") &&
!toolLines[0].body.includes("line12"),
"Bash result preview should be capped at ten display lines",
toolLines[0].body.includes("… +3 more lines"),
"Bash preview should retain its line cap",
);
assert(
toolLines[0].body.includes("… +3 more lines"),
"Bash result preview should show omitted output count",
toolLines[0].expandedBody?.includes("line12") === true &&
!toolLines[0].expandedBody?.includes("more lines"),
"Bash detail should show every returned output line",
);
assert(
toolLines[0].detail?.includes("id: call-1"),
@@ -421,7 +595,8 @@ Deno.test("projectConsole streams distinct Bash stdout and stderr through termin
]);
const [line] = projection.lines.filter((line) => line.kind === "tool");
assert(line.body.includes("Bash — failed (exit 7)"), line.body);
assertEquals(line.toolCallLabel, "Bash($ long-command)");
assertEquals(line.toolStatus, "failed (exit 7)");
assert(!line.body.includes("elapsed"), line.body);
assert(!line.body.includes("stdout:"), line.body);
assert(line.body.includes("ready\n"), line.body);
@@ -463,7 +638,8 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]);
const [line] = projection.lines.filter((line) => line.kind === "tool");
assert(line.body.includes("Bash — running…"), line.body);
assertEquals(line.toolCallLabel, "Bash($ slow)");
assertEquals(line.toolStatus, "running…");
assert(!line.body.includes("elapsed"), line.body);
assert(!line.body.includes("stdout:"), line.body);
assert(line.body.includes("[… earlier stdout omitted]\ntail\n"), line.body);
@@ -474,7 +650,7 @@ Deno.test("snapshot restores bounded in-flight Bash command output", () => {
assertEquals(line.streaming, true);
});
Deno.test("projectConsole caps default tool request and result previews", () => {
Deno.test("projectConsole caps default preview but keeps complete detail body", () => {
const projection = projectConsole([
{
eventId: "70",
@@ -508,19 +684,100 @@ Deno.test("projectConsole caps default tool request and result previews", () =>
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · CustomTool");
assertEquals(line.body.split("\n").length, 7);
assert(line.body.includes("CustomTool — done"), "tool state should be shown");
assertEquals(line.toolCallLabel, 'CustomTool("first":"one","second":"two","third":"three","fourth":"four")');
assertEquals(line.toolStatus, "done");
assertEquals(line.body.split("\n").length, 3);
assert(
line.body.includes('"first": "one"'),
"request preview should be shown",
line.body.includes("out1") && line.body.includes("… +3 more lines"),
"normal display should retain the capped response preview",
);
assert(!line.body.includes("first"), "request arguments should stay in the Call signature and detail");
assert(
line.detail?.includes("arguments:\nfirst: one") === true &&
line.detail?.includes("fourth: four") === true,
"detail metadata should render complete request arguments as YAML",
);
assert(
line.expandedBody?.includes("out5") === true &&
!line.expandedBody?.includes("more lines"),
"detail body should contain the complete result",
);
assert(line.body.includes("out1"), "result preview should be shown");
assert(!line.body.includes("third"), "request preview should be capped");
assert(!line.body.includes("out3"), "result preview should be capped");
assert(line.body.includes("… +"), "overflow marker should be shown");
});
Deno.test("projectConsole shows Grep query and caps result preview to five entries", () => {
Deno.test("projectConsole renders JSON tool responses as YAML", () => {
const projection = projectConsole([
{
eventId: "json-call",
event: {
event: "tool_call_done",
data: {
id: "json-tool",
name: "CustomTool",
arguments: "{}",
},
} satisfies Event,
},
{
eventId: "json-result",
event: {
event: "tool_result",
data: {
id: "json-tool",
summary: "json completed",
output: JSON.stringify({
status: "ok",
items: [{ id: 1 }, { id: 2 }],
}),
is_error: false,
},
} satisfies Event,
},
{
eventId: "invalid-json-call",
event: {
event: "tool_call_done",
data: {
id: "invalid-json-tool",
name: "CustomTool",
arguments: "{}",
},
} satisfies Event,
},
{
eventId: "invalid-json-result",
event: {
event: "tool_result",
data: {
id: "invalid-json-tool",
summary: "invalid json",
output: '{"status": broken}',
is_error: false,
},
} satisfies Event,
},
]);
const toolLines = projection.lines.filter((line) => line.kind === "tool");
const jsonLine = toolLines.find((line) => line.id.includes("json-tool"));
const invalidLine = toolLines.find((line) => line.id.includes("invalid-json-tool"));
assert(jsonLine, "JSON tool line should be projected");
assert(invalidLine, "invalid JSON tool line should be projected");
assert(
jsonLine.expandedBody?.includes("status: ok") === true &&
jsonLine.expandedBody?.includes(" - id: 2") === true,
"detail body should serialize parsed JSON as YAML",
);
assert(
jsonLine.body.includes("more lines"),
"normal preview should cap the pretty-printed JSON",
);
assert(
invalidLine.expandedBody?.includes('{"status": broken}') === true,
"invalid JSON-looking output should remain unchanged",
);
});
Deno.test("projectConsole caps Grep preview but keeps complete detail body", () => {
const projection = projectConsole([
{
eventId: "72",
@@ -549,17 +806,19 @@ Deno.test("projectConsole shows Grep query and caps result preview to five entri
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Grep");
assert(
line.body.includes("Grep — 6 matches"),
"Grep summary should be shown",
);
assert(line.body.includes("query: needle"), "Grep query should be shown");
assertEquals(line.toolCallLabel, "Grep(needle)");
assertEquals(line.toolStatus, "done");
assert(line.body.includes("hit1"), "first result should be shown");
assert(line.body.includes("hit5"), "fifth result should be shown");
assert(!line.body.includes("hit6"), "sixth result should be capped");
assert(!line.body.includes("hit6"), "normal preview should retain its result cap");
assert(
line.body.includes("… +1 more results"),
"overflow marker should be shown",
"preview should show the omitted result count",
);
assert(
line.expandedBody?.includes("hit6") === true &&
!line.expandedBody?.includes("more results"),
"detail body should show every Grep result",
);
});
@@ -594,17 +853,15 @@ Deno.test("projectConsole keeps Grep error detail in the body", () => {
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Grep");
assert(
line.body.includes("Grep — Failed"),
"error suffix should stay short",
);
assertEquals(line.toolCallLabel, "Grep(needle)");
assertEquals(line.toolStatus, "error");
assert(
line.body.includes(message),
"error detail should remain visible in the body",
);
assert(
!line.body.includes(`Grep — ${message}`),
"error detail should not be repeated in the suffix",
!line.toolCallLabel?.includes(message),
"error detail should not be repeated in the Call signature",
);
});
@@ -770,7 +1027,7 @@ Deno.test("compaction service activity stays nested in one lifecycle item", () =
);
});
Deno.test("snapshot normalizes orphaned running compaction to interrupted", () => {
Deno.test("snapshot excludes storage-only compaction extension records", () => {
const projection = projectConsole([{
eventId: "snapshot",
observedAtMs: 9_000,
@@ -794,10 +1051,7 @@ Deno.test("snapshot normalizes orphaned running compaction to interrupted", () =
}]),
}]);
assertEquals(projection.lines.length, 1);
assertEquals(projection.lines[0].compaction?.state, "interrupted");
assertEquals(projection.lines[0].compaction?.endedAtMs, 9_000);
assertEquals(projection.lines[0].streaming, false);
assertEquals(projection.lines.length, 0);
});
Deno.test("createConsoleProjector ignores stale compaction revisions", () => {
@@ -879,9 +1133,10 @@ Deno.test("projectConsole keeps streaming tool call updates in the same Call blo
assertEquals(toolLines.length, 1);
assertEquals(toolLines[0].title, "Call · Read");
assert(toolLines[0].streaming, "streaming tool call should remain streaming");
assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
assertEquals(toolLines[0].toolStatus, "reading…");
assert(
toolLines[0].body.includes("/tmp/a.md") &&
toolLines[0].body.includes("Read — reading"),
toolLines[0].body.includes("/tmp/a.md"),
"Read call should render aggregate progress and path without content",
);
});
@@ -1018,10 +1273,8 @@ Deno.test("projectConsole aggregates Read calls without showing file content", (
const toolLines = projection.lines.filter((line) => line.kind === "tool");
assertEquals(toolLines.length, 1);
assertEquals(toolLines[0].title, "Call · Read");
assert(
toolLines[0].body.includes("Read — 2 files read"),
"aggregate count should be shown",
);
assertEquals(toolLines[0].toolCallLabel, "Read(2 files)");
assertEquals(toolLines[0].toolStatus, "done");
assert(
toolLines[0].body.includes("/tmp/a.md"),
"first path should be listed",
@@ -1073,7 +1326,9 @@ Deno.test("projectConsole renders Edit calls with structured diff lines", () =>
const [line] = projection.lines.filter((line) => line.kind === "tool");
assertEquals(line.title, "Call · Edit");
assert(line.body.includes("diff: -1 +2"), "diff summary should be shown");
assertEquals(line.toolCallLabel, "Edit(/tmp/a.md)");
assertEquals(line.toolStatus, "done");
assertEquals(line.body, "ok");
assertEquals(line.diff?.map((row) => row.kind), [
"context",
"remove",
@@ -1155,7 +1410,7 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
event: {
event: "snapshot",
data: {
entries: [
session: canonicalSession([
{
kind: "segment_start",
ts: 1,
@@ -1217,7 +1472,7 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
message: "Compacting…",
},
},
],
]),
greeting: {
worker_name: "Worker",
cwd: "/repo",
@@ -1242,14 +1497,13 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
assertEquals(projection.status, "running");
assertEquals(
projection.lines.map((line) =>
`${line.kind}:${line.body}:${line.streaming}`
`${line.kind}:${line.toolCallLabel ? `${line.toolCallLabel}\n${line.body}` : line.body}:${line.streaming}`
),
[
"user:seed user:false",
"user:new user:false",
"assistant:assistant reply:false",
"tool:Read1 file read\n /tmp/a.md:false",
"status:Compacting…:true",
"tool:Read(1 file)\n /tmp/a.md:false",
"in_flight:partial:true",
],
);
@@ -1261,7 +1515,7 @@ Deno.test("projectConsole restores system items from snapshot entries", () => {
event: {
event: "snapshot",
data: {
entries: [{
session: canonicalSession([{
kind: "system_item",
ts: 1,
item: {
@@ -1269,7 +1523,7 @@ Deno.test("projectConsole restores system items from snapshot entries", () => {
message: "Worker completed",
body: "Child Worker coder-1 completed.",
},
}],
}]),
greeting: {
worker_name: "Worker",
cwd: "/repo",
@@ -1305,7 +1559,7 @@ Deno.test("projectConsole reseeds visible rows from segment rotation", () => {
event: {
event: "segment_rotated",
data: {
entry: {
session: canonicalSession([{
kind: "segment_start",
ts: 10,
session_id: "00000000-0000-0000-0000-000000000001",
@@ -1318,7 +1572,7 @@ Deno.test("projectConsole reseeds visible rows from segment rotation", () => {
content: [{ kind: "text", text: "after rotation seed" }],
},
],
},
}]),
},
} satisfies Event,
},
@@ -1476,25 +1730,26 @@ Deno.test("projectConsole relativizes known tool path displays from snapshot cwd
},
]);
const bodies = projection.lines.filter((line) => line.kind === "tool").map((
line,
) => line.body);
assertEquals(bodies[0], "Read — 1 file read\n src/main.rs");
const toolLines = projection.lines.filter((line) => line.kind === "tool");
const bodies = toolLines.map((line) => line.body);
assertEquals(toolLines[0].toolCallLabel, "Read(1 file)");
assertEquals(bodies[0], " src/main.rs");
assert(
projection.lines[0].detail?.includes("from src/main.rs"),
"Read summary detail path should be relative",
);
assert(
bodies.some((body) =>
body.includes("Writeout.txt") && body.includes("Wrote out.txt")
toolLines.some((line) =>
line.toolCallLabel === "Write(out.txt)" && line.body.includes("Wrote out.txt")
),
"Write header and known result path should be relative",
"Write signature and known result path should be relative",
);
assert(
bodies.some((body) =>
body.includes("Editsrc/main.rs") && body.includes("Edited src/main.rs")
toolLines.some((line) =>
line.toolCallLabel === "Edit(src/main.rs)" &&
line.body.includes("Edited src/main.rs")
),
"Edit header and known result path should be relative",
"Edit signature and known result path should be relative",
);
assert(
bodies.some((body) =>
@@ -1689,7 +1944,7 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections"
kind: "sub_worker",
},
revision: 4,
entries: [{
session: canonicalSession([{
kind: "assistant_item",
ts: 1,
item: {
@@ -1708,7 +1963,7 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections"
content: "content",
is_error: false,
},
}],
}]),
status: "idle",
in_flight: {
blocks: [{
@@ -1850,18 +2105,21 @@ Deno.test("snapshot restores TaskStore state from system history", () => {
`[Session TaskStore snapshot]\n\n\`\`\`json\n{\n "tasks": [{"taskid": 3, "status": "pending", "subject": "Restored", "description": "From compaction"}]\n}\n\`\`\``;
const event = snapshotEvent("/repo");
if (event.event !== "snapshot") throw new Error("snapshot fixture expected");
event.data.entries = [{
kind: "segment_start",
ts: 1,
session_id: "00000000-0000-0000-0000-000000000001",
system_prompt: null,
config: {},
history: [{
kind: "message",
role: "system",
content: [{ kind: "text", text: taskSnapshot }],
event.data.session = {
entries: [{
entry_id: "task-reminder-1",
timestamp: 1,
provenance: "backend_instruction",
kind: "system_item",
item_kind: "task_reminder",
content: taskSnapshot,
data: {
kind: "task_reminder",
body: taskSnapshot,
source: "automatic",
},
}],
}];
};
const projection = projectConsole([{ eventId: "task-snapshot", event }]);
assertEquals(projection.tasks, [{
+186 -124
View File
@@ -11,6 +11,7 @@ import type {
InternalWorkerSnapshot,
Segment,
} from "$lib/generated/protocol";
import { stringify as stringifyYaml } from "yaml";
import { workspaceRoute } from "$lib/workspace/api/http";
import {
applyRunActivityEvent,
@@ -86,6 +87,9 @@ export type ConsoleLine = {
kind: ConsoleLineKind;
title: string;
body: string;
expandedBody?: string;
toolCallLabel?: string;
toolStatus?: string;
detail?: string;
compaction?: ConsoleCompaction;
diff?: ConsoleDiffLine[];
@@ -646,9 +650,9 @@ function projectInternalWorkerSnapshot(
eventId: string,
cwd: string | null,
): InternalWorkerProjection {
const console = snapshotProjectionFromEntries(
const console = snapshotProjectionFromSession(
`${eventId}:internal:${snapshot.worker.session_id}:snapshot`,
snapshot.entries,
snapshot.session,
cwd,
);
console.status = snapshot.status;
@@ -901,9 +905,9 @@ export function applyProtocolEvent(
case "snapshot": {
next.status = event.data.status;
next.cwd = event.data.greeting.cwd;
const snapshot = snapshotProjectionFromEntries(
const snapshot = snapshotProjectionFromSession(
envelope.eventId,
event.data.entries,
event.data.session,
next.cwd,
);
next.lines = snapshot.lines;
@@ -1004,9 +1008,9 @@ export function applyProtocolEvent(
break;
case "segment_rotated": {
const retainedErrors = next.lines.filter((line) => line.kind === "error");
const segment = snapshotProjectionFromEntries(
const segment = snapshotProjectionFromSession(
envelope.eventId,
[event.data.entry],
event.data.session,
next.cwd,
);
next.lines = [...segment.lines, ...retainedErrors];
@@ -1376,7 +1380,10 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
title: item.title.startsWith("Call · Tool result")
? item.title
: `Call · ${toolCall.name}`,
body: renderToolCall(toolCall),
body: renderToolResponse(toolCall),
expandedBody: renderToolResponse(toolCall, true),
toolCallLabel: toolCallSignature(toolCall),
toolStatus: toolCallStatus(toolCall),
detail: toolCallDetail(toolCall),
diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined,
streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal,
@@ -1384,7 +1391,7 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine {
};
}
function renderToolCall(toolCall: ToolCallView): string {
function renderToolResponse(toolCall: ToolCallView, expanded = false): string {
switch (toolCall.name) {
case "Read":
return renderReadTool(toolCall);
@@ -1395,14 +1402,54 @@ function renderToolCall(toolCall: ToolCallView): string {
case "Glob":
return renderSearchTool(toolCall);
case "Grep":
return renderGrepTool(toolCall);
return renderGrepTool(toolCall, expanded);
case "Bash":
return renderBashTool(toolCall);
return renderBashTool(toolCall, expanded);
default:
return renderDefaultTool(toolCall);
return renderDefaultTool(toolCall, expanded);
}
}
function toolCallSignature(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall);
switch (toolCall.name) {
case "Read":
return `Read(${readPath(toolCall)})`;
case "Write":
case "Edit": {
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
return `${toolCall.name}(${path})`;
}
case "Glob":
return `Glob(${stringField(args, "pattern") ?? genericCallArguments(toolCall)})`;
case "Grep":
return `Grep(${stringField(args, "pattern") ?? genericCallArguments(toolCall)})`;
case "Bash": {
const command = stringField(args, "command");
return `Bash(${command ? `$ ${singleLine(command)}` : genericCallArguments(toolCall)})`;
}
default:
return `${toolCall.name}(${genericCallArguments(toolCall)})`;
}
}
function genericCallArguments(toolCall: ToolCallView): string {
const raw = toolCall.arguments ?? toolCall.argsStream;
if (!raw.trim()) return "";
const parsed = parseJson(raw);
if (parsed === undefined) return singleLine(raw);
const serialized = JSON.stringify(parsed) ?? "null";
return isRecord(parsed) ? serialized.slice(1, -1) : serialized;
}
function singleLine(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function toolCallStatus(toolCall: ToolCallView): string {
return toolCall.name === "Bash" ? commandStateSuffix(toolCall) : stateSuffix(toolCall.state);
}
function aggregateReadToolLines(lines: ConsoleLine[]): ConsoleLine[] {
const result: ConsoleLine[] = [];
let index = 0;
@@ -1434,9 +1481,6 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
const paths = calls.map(readPath);
const visiblePaths = inProgress ? paths.slice(-3) : paths;
const body = compactLines([
inProgress
? `Read — reading (${count} file${plural(count)}…)`
: `Read — ${count} file${plural(count)} read`,
visiblePaths.map((path) => ` ${path}`).join("\n"),
inProgress && paths.length > visiblePaths.length
? ` … (${paths.length - visiblePaths.length} earlier)`
@@ -1447,6 +1491,8 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine {
kind: "tool",
title: "Call · Read",
body,
toolCallLabel: `Read(${count} file${plural(count)})`,
toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done",
detail: calls.map(readDetail).join("\n\n"),
eventId: group.at(-1)?.eventId,
source: "event",
@@ -1483,32 +1529,16 @@ function readDetail(toolCall: ToolCallView): string {
]);
}
function renderReadTool(toolCall: ToolCallView): string {
return `Read — ${readPath(toolCall)} (${stateSuffix(toolCall.state)})`;
function renderReadTool(_toolCall: ToolCallView): string {
return "";
}
function renderWriteTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall);
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
const content = stringField(args, "content");
return compactLines([
`Write — ${path} (${stateSuffix(toolCall.state)})`,
cappedSection(content, 5),
knownToolResultText(toolCall),
]);
return knownToolResultText(toolCall) ?? "";
}
function renderEditTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall);
const path = displayPath(stringField(args, "file_path") ?? "?", toolCall.cwd);
const diff = editDiff(toolCall) ?? [];
const removes = diff.filter((line) => line.kind === "remove").length;
const adds = diff.filter((line) => line.kind === "add").length;
return compactLines([
`Edit — ${path} (${stateSuffix(toolCall.state)})`,
diff.length > 0 ? `diff: -${removes} +${adds}` : undefined,
knownToolResultText(toolCall),
]);
return knownToolResultText(toolCall) ?? "";
}
function editDiff(toolCall: ToolCallView): ConsoleDiffLine[] | undefined {
@@ -1591,52 +1621,20 @@ function lcsTable(oldLines: string[], newLines: string[]): number[][] {
}
function renderSearchTool(toolCall: ToolCallView): string {
const summary = toolCall.summary?.trim();
return compactLines([
`${toolCall.name}${toolHeaderSuffix(toolCall, summary)}`,
knownToolResultText(toolCall),
]);
return knownToolResultText(toolCall) ?? "";
}
function renderGrepTool(toolCall: ToolCallView): string {
const summary = toolCall.summary?.trim();
return compactLines([
`Grep — ${toolHeaderSuffix(toolCall, summary)}`,
grepQueryText(toolCall),
cappedResultSection(knownToolResultText(toolCall), 5),
]);
function renderGrepTool(toolCall: ToolCallView, expanded: boolean): string {
const result = knownToolResultText(toolCall);
return expanded ? result ?? "" : cappedResultSection(result, 5) ?? "";
}
function toolHeaderSuffix(
toolCall: ToolCallView,
summary?: string,
): string {
if (toolCall.state === "error") {
return "Failed";
function renderBashTool(toolCall: ToolCallView, expanded: boolean): string {
if (["done", "error"].includes(toolCall.state)) {
const result = resultText(toolCall);
return expanded ? result ?? "" : cappedDisplaySection(result, 10) ?? "";
}
return summary ? firstLine(summary) : stateSuffix(toolCall.state);
}
function grepQueryText(toolCall: ToolCallView): string | undefined {
const args = parsedArgs(toolCall);
const pattern = stringField(args, "pattern");
if (pattern) {
return `query: ${pattern}`;
}
const renderedArgs = argsText(toolCall);
return renderedArgs ? `query:\n${renderedArgs}` : undefined;
}
function renderBashTool(toolCall: ToolCallView): string {
const args = parsedArgs(toolCall);
const command = stringField(args, "command");
return compactLines([
`Bash — ${commandStateSuffix(toolCall)}`,
command ? `$ ${command}` : argsText(toolCall),
["done", "error"].includes(toolCall.state)
? cappedDisplaySection(resultText(toolCall), 10)
: renderLiveCommandOutput(toolCall.command),
]);
return renderLiveCommandOutput(toolCall.command) ?? "";
}
function commandStateSuffix(toolCall: ToolCallView): string {
@@ -1690,12 +1688,9 @@ function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined
]);
}
function renderDefaultTool(toolCall: ToolCallView): string {
return compactLines([
`${toolCall.name}${stateSuffix(toolCall.state)}`,
cappedDisplaySection(argsText(toolCall), 3),
cappedDisplaySection(resultText(toolCall), 3),
]);
function renderDefaultTool(toolCall: ToolCallView, expanded: boolean): string {
const result = resultText(toolCall);
return expanded ? result ?? "" : cappedDisplaySection(result, 3) ?? "";
}
function toolCallDetail(toolCall: ToolCallView): string {
@@ -1713,10 +1708,30 @@ function toolCallDetail(toolCall: ToolCallView): string {
}
function resultText(toolCall: ToolCallView): string | undefined {
if (toolCall.output) {
return toolCall.output;
const text = toolCall.output || toolCall.summary;
return text ? formatJsonResponseAsYaml(text) : undefined;
}
function formatJsonResponseAsYaml(text: string): string {
const trimmed = text.trim();
if (
!(
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
)
) {
return text;
}
try {
const parsed: unknown = JSON.parse(trimmed);
if (parsed === null || typeof parsed !== "object") {
return text;
}
return stringifyYaml(parsed).trimEnd();
} catch {
return text;
}
return toolCall.summary;
}
function knownToolResultText(toolCall: ToolCallView): string | undefined {
@@ -1803,7 +1818,7 @@ function argsText(toolCall: ToolCallView): string {
return "";
}
const parsed = parseJson(raw);
return parsed === undefined ? raw : jsonPreview(parsed);
return parsed === undefined ? raw : stringifyYaml(parsed).trimEnd();
}
function parsedArgs(
@@ -1830,21 +1845,6 @@ function compactLines(lines: Array<string | undefined | null | false>): string {
return lines.filter((line): line is string => Boolean(line)).join("\n");
}
function cappedSection(
value: string | undefined,
cap: number,
): string | undefined {
if (!value) {
return undefined;
}
const lines = value.split(/\r?\n/);
const shown = lines.slice(0, cap);
if (lines.length > cap) {
shown.push(`… +${lines.length - cap} more lines`);
}
return shown.join("\n");
}
function cappedDisplaySection(
value: string | undefined,
maxLines: number,
@@ -1925,9 +1925,9 @@ function applyTaskSystemItem(
if (typeof body === "string") applyTaskSnapshot(projection, body);
}
function snapshotProjectionFromEntries(
function snapshotProjectionFromSession(
eventId: string,
entries: unknown[],
snapshot: unknown,
cwd: string | null,
): ConsoleProjection {
const projection: ConsoleProjection = {
@@ -1942,58 +1942,74 @@ function snapshotProjectionFromEntries(
internalWorkers: [],
removedInternalWorkers: {},
};
const entries = isRecord(snapshot) ? arrayField(snapshot, "entries") : [];
entries.forEach((entry, index) =>
applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry)
applySessionEntry(projection, `${eventId}-snapshot-${index}`, entry)
);
return projection;
}
function applyLogEntry(
function applySessionEntry(
projection: ConsoleProjection,
eventId: string,
entry: unknown,
fallbackEventId: string,
value: unknown,
): void {
if (!isRecord(entry)) return;
switch (stringField(entry, "kind")) {
case "segment_start":
arrayField(entry, "history").forEach((item, index) =>
applyLoggedItem(projection, `${eventId}-history-${index}`, item)
);
break;
if (!isRecord(value)) return;
const eventId = stringField(value, "entry_id") ?? fallbackEventId;
switch (stringField(value, "kind")) {
case "user_input":
projection.lines.push(
line(
eventId,
"user",
"User",
segmentsToText(arrayField(entry, "segments") as Segment[]),
segmentsToText(arrayField(value, "segments") as Segment[]),
),
);
break;
case "system_item":
projection.lines.push(systemItemLine(eventId, entry["item"]));
applyTaskSystemItem(projection, entry["item"]);
case "message":
applyLoggedItem(projection, eventId, {
kind: "message",
role: value["role"],
content: value["content"],
});
break;
case "tool_call":
applyLoggedItem(projection, eventId, {
kind: "tool_call",
call_id: value["call_id"],
name: value["name"],
arguments: value["arguments"],
});
break;
case "assistant_item":
case "tool_result":
applyLoggedItem(projection, eventId, entry["item"]);
applyLoggedItem(projection, eventId, {
kind: "tool_result",
call_id: value["call_id"],
summary: value["summary"],
content: value["content"],
is_error: value["is_error"],
});
break;
case "run_errored":
case "system_item": {
const item = value["data"];
projection.lines.push(systemItemLine(eventId, item ?? value));
applyTaskSystemItem(projection, item);
break;
}
case "run_error":
projection.lines.push(
line(
eventId,
"error",
"Run error",
stringField(entry, "message") ?? "Worker run failed.",
stringField(value, "message") ?? "Worker run failed.",
undefined,
false,
true,
),
);
break;
case "extension":
applyExtensionEntry(projection, eventId, entry);
break;
default:
break;
}
@@ -2056,6 +2072,52 @@ function compactMessageForState(
}
}
function applyLoggedUserInput(
projection: ConsoleProjection,
eventId: string,
entry: Record<string, unknown>,
): void {
let body = segmentsToText(arrayField(entry, "segments") as Segment[]);
if (!body && stringField(entry, "kind") === "annotated_user_input") {
body = loggedUserText(arrayField(entry, "history"));
}
projection.lines.push(line(eventId, "user", "User", body));
}
function loggedUserText(history: unknown[]): string {
for (const historyEntry of history) {
if (!isRecord(historyEntry) || !isRecord(historyEntry["item"])) continue;
const item = historyEntry["item"];
if (
stringField(item, "kind") !== "message" ||
stringField(item, "role") !== "user"
) {
continue;
}
return loggedContentText(arrayField(item, "content"));
}
return "";
}
function applyLoggedHistoryEntry(
projection: ConsoleProjection,
eventId: string,
historyEntry: unknown,
): void {
if (!isRecord(historyEntry)) return;
applyLoggedItem(projection, eventId, historyEntry["item"]);
}
function applyLoggedSystemEntry(
projection: ConsoleProjection,
eventId: string,
historyEntry: unknown,
): void {
if (!isRecord(historyEntry)) return;
projection.lines.push(systemItemLine(eventId, historyEntry["item"]));
applyTaskSystemItem(projection, historyEntry["item"]);
}
function applyLoggedItem(
projection: ConsoleProjection,
eventId: string,
@@ -251,7 +251,8 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
ticketDetailLoad.includes("/repositories") &&
ticketDetailPage.includes('mutate("state", "/state"') &&
ticketDetailPage.includes("async function queueTicket") &&
ticketDetailPage.includes("`${ticketPath}/queue`") &&
ticketDetailPage.includes("const path = ticketPath") &&
ticketDetailPage.includes("`${path}/queue`") &&
!ticketDetailPage.includes("/merge-request/merge") &&
ticketDetailPage.includes("mergeRequest.selector_from") &&
ticketDetailPage.includes("mergeRequest.review_status") &&
@@ -402,7 +403,7 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
consoleLine.includes("item.kind === 'tool'") &&
consoleLine.includes("{#if isBashTool(item)}") &&
consoleLine.includes(
"<AnsiText text={bodyTextAfterToolSummary(item)} />",
"<AnsiText text={toolBodyText(item)} />",
) &&
consoleLine.includes(
".console-line.tool-bash .console-plain-text",
@@ -419,6 +420,30 @@ Deno.test("Worker Console renders markdown only for message rows", async () => {
);
});
Deno.test("Worker Console expands uncapped tool body from the hover detail action", async () => {
const consoleLine = await Deno.readTextFile(
new URL("./ConsoleLineItem.svelte", import.meta.url),
);
assert(
consoleLine.includes(
"return detailOpen ? (line.expandedBody ?? line.body) : line.body",
) &&
consoleLine.includes("line.toolCallLabel ?? line.toolCall?.name") &&
consoleLine.includes('class={`tool-status') &&
consoleLine.includes('class="tool-detail-button"') &&
consoleLine.includes("aria-expanded={detailOpen}") &&
consoleLine.includes("detailOpen = !detailOpen") &&
consoleLine.includes("item.detail && detailOpen") &&
consoleLine.includes('role="region"') &&
consoleLine.includes(".console-line:hover .tool-detail-button") &&
consoleLine.includes(".tool-detail-button:focus-visible") &&
consoleLine.includes("@media (hover: none)") &&
!consoleLine.includes('<details class="message-detail">'),
"Normal tool display should keep its preview while detail reveals the uncapped body and existing metadata",
);
});
Deno.test("Worker Console renders Edit diffs without preformatted template gaps", async () => {
const consoleLine = await Deno.readTextFile(
new URL("./ConsoleLineItem.svelte", import.meta.url),
@@ -36,7 +36,7 @@
const initialData = untrack(() => data);
const loadedTicket = initialData.ticket.data;
if (!loadedTicket) throw new Error(initialData.ticket.error ?? "ticket load failed");
const loadedRepositories = initialData.repositories.data;
const loadedRepositories = $derived(data.repositories.data);
type QueueOutcome = {
requested_ticket: string;
@@ -62,6 +62,8 @@
let manualRuntimeId = $state("");
let manualWorkerId = $state("");
let cancellationReason = $state("");
let routeTicketSnapshot = `${initialData.ticketId}:${loadedTicket.item_revision}`;
let routeGeneration = 0;
const coderAssignment = $derived(
ticket.assignments.find((assignment) => assignment.role === "coder") ?? null,
);
@@ -95,13 +97,43 @@
function applyTicket(updatedTicket: TicketDetail): void {
ticket = updatedTicket;
editTitle = ticket.title;
editBody = ticket.body;
repositoryId = ticket.repository_id ?? "";
refSelector = ticket.ref_selector ?? "";
nextState = ticket.state;
editTitle = updatedTicket.title;
editBody = updatedTicket.body;
repositoryId = updatedTicket.repository_id ?? "";
refSelector = updatedTicket.ref_selector ?? "";
nextState = updatedTicket.state;
}
function resetTicketView(updatedTicket: TicketDetail): void {
applyTicket(updatedTicket);
editing = false;
transitionReason = "";
threadRole = "comment";
threadBody = "";
resolution = "";
busy = null;
errorMessage = null;
queueMessage = null;
readyOperationKey = null;
manualRuntimeId = "";
manualWorkerId = "";
cancellationReason = "";
}
$effect(() => {
const incomingTicketId = data.ticketId;
const incomingTicket = data.ticket.data;
if (!incomingTicket) return;
const incomingSnapshot = `${incomingTicketId}:${incomingTicket.item_revision}`;
untrack(() => {
if (incomingSnapshot === routeTicketSnapshot) return;
routeTicketSnapshot = incomingSnapshot;
routeGeneration += 1;
resetTicketView(incomingTicket);
});
});
async function mutate(
action: string,
suffix: string,
@@ -109,40 +141,51 @@
method = "POST",
): Promise<boolean> {
if (busy) return false;
const generation = routeGeneration;
const path = `${ticketPath}${suffix}`;
busy = action;
errorMessage = null;
try {
const path = `${ticketPath}${suffix}`;
const response = await workspaceApiJsonWithBody<TicketDetail>(path, {
method,
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (generation !== routeGeneration) return false;
applyTicket(response);
return true;
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
if (generation === routeGeneration) {
errorMessage = error instanceof Error ? error.message : String(error);
}
return false;
} finally {
busy = null;
if (generation === routeGeneration) busy = null;
}
}
async function queueTicket(): Promise<void> {
if (busy) return;
const generation = routeGeneration;
const path = ticketPath;
busy = "queue";
errorMessage = null;
queueMessage = null;
try {
const outcome = await workspaceApiJsonWithBody<QueueOutcome>(
`${ticketPath}/queue`,
`${path}/queue`,
{ method: "POST", body: JSON.stringify({}) },
);
if (generation !== routeGeneration) return;
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
if (generation !== routeGeneration) return;
queueMessage = `Queued ${outcome.queued_tickets.length} Ticket(s): ${outcome.queued_tickets.join(", ")}`;
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
applyTicket(updatedTicket);
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
if (generation === routeGeneration) {
errorMessage = error instanceof Error ? error.message : String(error);
}
} finally {
busy = null;
if (generation === routeGeneration) busy = null;
}
}
@@ -152,11 +195,13 @@
principal: Record<string, string>,
): Promise<void> {
if (busy) return;
const generation = routeGeneration;
const path = ticketPath;
busy = action;
errorMessage = null;
try {
await workspaceApiJsonWithBody(
`${ticketPath}/assignments/${role}`,
`${path}/assignments/${role}`,
{
method: "PUT",
body: JSON.stringify({
@@ -166,11 +211,16 @@
}),
},
);
applyTicket(await workspaceApiJson<TicketDetail>(ticketPath));
if (generation !== routeGeneration) return;
const updatedTicket = await workspaceApiJson<TicketDetail>(path);
if (generation !== routeGeneration) return;
applyTicket(updatedTicket);
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
if (generation === routeGeneration) {
errorMessage = error instanceof Error ? error.message : String(error);
}
} finally {
busy = null;
if (generation === routeGeneration) busy = null;
}
}
@@ -0,0 +1,50 @@
import { assert, assertStringIncludes } from "jsr:@std/assert";
const pageSource = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
import.meta.url,
),
);
Deno.test("ticket detail synchronizes reused route data", () => {
const effectStart = pageSource.indexOf("$effect(() => {");
assert(effectStart >= 0, "ticket detail must react to reused route props");
const effectSource = pageSource.slice(effectStart);
for (
const token of [
"data.ticketId",
"data.ticket.data",
"incomingTicket.item_revision",
"routeGeneration += 1",
"resetTicketView(incomingTicket)",
]
) {
assertStringIncludes(effectSource, token);
}
});
Deno.test("ticket detail fences stale mutation responses", () => {
for (
const operation of [
"async function mutate(",
"async function queueTicket(",
"async function mutateAssignment(",
]
) {
const operationStart = pageSource.indexOf(operation);
assert(operationStart >= 0, `missing ${operation}`);
const nextOperation = pageSource.indexOf(
"\n async function ",
operationStart + 1,
);
const operationSource = pageSource.slice(
operationStart,
nextOperation === -1 ? undefined : nextOperation,
);
assertStringIncludes(operationSource, "const generation = routeGeneration");
assertStringIncludes(operationSource, "generation !== routeGeneration");
assertStringIncludes(operationSource, "generation === routeGeneration");
}
});