Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e84a9d3f9b | ||
|
|
a7bf5ceac3 | ||
|
|
74139aeb7e | ||
|
|
0cae4fd05c | ||
|
|
adb684a6bf | ||
|
|
8493472983 | ||
|
|
ebb272324c | ||
|
|
c0290512b3 | ||
|
|
4ec56fe41e | ||
|
|
f8a7c46cf9 | ||
|
|
2bab8a9bb6 | ||
|
|
89e6a6215a | ||
|
|
88be87e03e | ||
|
|
32fdd076bf | ||
|
|
402ae0d466 |
Generated
+1
@@ -637,6 +637,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
|||||||
name = "client"
|
name = "client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
"manifest",
|
"manifest",
|
||||||
"protocol",
|
"protocol",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
manifest = { workspace = true }
|
manifest = { workspace = true }
|
||||||
ticket = { workspace = true }
|
ticket = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,768 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use reqwest::{Method, StatusCode, Url, redirect};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::env;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const TOKEN_FILE_NAME: &str = "backend-tokens.json";
|
||||||
|
const MAX_REDIRECTS: usize = 10;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct BackendOrigin(String);
|
||||||
|
|
||||||
|
impl BackendOrigin {
|
||||||
|
pub fn parse(input: &str) -> Result<Self, BackendApiClientError> {
|
||||||
|
let url = Url::parse(input.trim()).map_err(|error| {
|
||||||
|
BackendApiClientError::InvalidBackendOrigin(format!(
|
||||||
|
"Backend URL is not a valid absolute URL: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !url.path().bytes().all(|byte| byte == b'/')
|
||||||
|
|| url.query().is_some()
|
||||||
|
|| url.fragment().is_some()
|
||||||
|
{
|
||||||
|
return Err(BackendApiClientError::InvalidBackendOrigin(
|
||||||
|
"Backend URL must contain only an origin, without a path, query, or fragment"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Self::from_url(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_url(mut url: Url) -> Result<Self, BackendApiClientError> {
|
||||||
|
if !matches!(url.scheme(), "http" | "https") {
|
||||||
|
return Err(BackendApiClientError::InvalidBackendOrigin(
|
||||||
|
"Backend URL scheme must be http or https".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !url.username().is_empty() || url.password().is_some() {
|
||||||
|
return Err(BackendApiClientError::InvalidBackendOrigin(
|
||||||
|
"Backend URL must not contain user information".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if url.host().is_none() {
|
||||||
|
return Err(BackendApiClientError::InvalidBackendOrigin(
|
||||||
|
"Backend URL must contain a host".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let default_port = match url.scheme() {
|
||||||
|
"http" => 80,
|
||||||
|
"https" => 443,
|
||||||
|
_ => unreachable!("validated Backend URL scheme"),
|
||||||
|
};
|
||||||
|
if url.port() == Some(default_port) {
|
||||||
|
url.set_port(None).map_err(|()| {
|
||||||
|
BackendApiClientError::InvalidBackendOrigin(
|
||||||
|
"Backend URL contains an invalid port".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
url.set_path("");
|
||||||
|
url.set_query(None);
|
||||||
|
url.set_fragment(None);
|
||||||
|
let normalized = url.as_str().trim_end_matches('/').to_string();
|
||||||
|
Ok(Self(normalized))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, path_and_query: &str) -> Result<Url, BackendApiClientError> {
|
||||||
|
if !path_and_query.starts_with('/') || path_and_query.starts_with("//") {
|
||||||
|
return Err(BackendApiClientError::InvalidRequestPath(
|
||||||
|
"Backend API request path must start with one `/`".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Url::parse(&format!("{}{path_and_query}", self.0)).map_err(|error| {
|
||||||
|
BackendApiClientError::InvalidRequestPath(format!(
|
||||||
|
"Backend API request path is invalid: {error}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for BackendOrigin {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_tuple("BackendOrigin").field(&self.0).finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for BackendOrigin {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct BackendAccessToken(String);
|
||||||
|
|
||||||
|
impl fmt::Debug for BackendAccessToken {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("BackendAccessToken([REDACTED])")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct BackendApiClient {
|
||||||
|
origin: BackendOrigin,
|
||||||
|
access_token: BackendAccessToken,
|
||||||
|
asynchronous: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for BackendApiClient {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("BackendApiClient")
|
||||||
|
.field("origin", &self.origin)
|
||||||
|
.field("access_token", &self.access_token)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackendApiClient {
|
||||||
|
pub fn from_stored_token(base_url: &str) -> Result<Self, BackendApiClientError> {
|
||||||
|
let path = backend_token_file_path()?;
|
||||||
|
Self::from_token_file(base_url, &path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_token_file(base_url: &str, path: &Path) -> Result<Self, BackendApiClientError> {
|
||||||
|
let origin = BackendOrigin::parse(base_url)?;
|
||||||
|
let token_file = read_token_file(path)?;
|
||||||
|
let entry = token_file.tokens.get(origin.as_str()).ok_or_else(|| {
|
||||||
|
BackendApiClientError::TokenEntryMissing {
|
||||||
|
origin: origin.clone(),
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
validate_token_entry(entry, &origin, path)?;
|
||||||
|
Self::new(origin, BackendAccessToken(entry.access_token.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(
|
||||||
|
origin: BackendOrigin,
|
||||||
|
access_token: BackendAccessToken,
|
||||||
|
) -> Result<Self, BackendApiClientError> {
|
||||||
|
let asynchronous = reqwest::Client::builder()
|
||||||
|
.redirect(redirect_policy(origin.clone()))
|
||||||
|
.build()
|
||||||
|
.map_err(BackendApiClientError::Http)?;
|
||||||
|
Ok(Self {
|
||||||
|
origin,
|
||||||
|
access_token,
|
||||||
|
asynchronous,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn origin(&self) -> &BackendOrigin {
|
||||||
|
&self.origin
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path_and_query: &str,
|
||||||
|
) -> Result<reqwest::RequestBuilder, BackendApiClientError> {
|
||||||
|
let url = self.origin.url(path_and_query)?;
|
||||||
|
Ok(self
|
||||||
|
.asynchronous
|
||||||
|
.request(method, url)
|
||||||
|
.bearer_auth(&self.access_token.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn blocking_request(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path_and_query: &str,
|
||||||
|
) -> Result<reqwest::blocking::RequestBuilder, BackendApiClientError> {
|
||||||
|
let url = self.origin.url(path_and_query)?;
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.redirect(redirect_policy(self.origin.clone()))
|
||||||
|
.build()
|
||||||
|
.map_err(BackendApiClientError::Http)?;
|
||||||
|
Ok(client
|
||||||
|
.request(method, url)
|
||||||
|
.bearer_auth(&self.access_token.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn authorization_header_value(&self) -> String {
|
||||||
|
format!("Bearer {}", self.access_token.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_status(&self, status: StatusCode) -> Result<(), BackendApiClientError> {
|
||||||
|
match status {
|
||||||
|
StatusCode::UNAUTHORIZED => Err(BackendApiClientError::Unauthorized {
|
||||||
|
origin: self.origin.clone(),
|
||||||
|
}),
|
||||||
|
StatusCode::FORBIDDEN => Err(BackendApiClientError::Forbidden {
|
||||||
|
origin: self.origin.clone(),
|
||||||
|
}),
|
||||||
|
status if !status.is_success() => Err(BackendApiClientError::BackendStatus {
|
||||||
|
origin: self.origin.clone(),
|
||||||
|
status: status.as_u16(),
|
||||||
|
}),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn from_access_token_for_test(
|
||||||
|
base_url: &str,
|
||||||
|
access_token: &str,
|
||||||
|
) -> Result<Self, BackendApiClientError> {
|
||||||
|
Self::new(
|
||||||
|
BackendOrigin::parse(base_url)?,
|
||||||
|
BackendAccessToken(access_token.to_string()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redirect_policy(origin: BackendOrigin) -> redirect::Policy {
|
||||||
|
redirect::Policy::custom(move |attempt| {
|
||||||
|
if attempt.previous().len() >= MAX_REDIRECTS {
|
||||||
|
return attempt.error("Backend request exceeded the redirect limit");
|
||||||
|
}
|
||||||
|
match BackendOrigin::from_url(attempt.url().clone()) {
|
||||||
|
Ok(target_origin) if target_origin == origin => attempt.follow(),
|
||||||
|
Ok(target_origin) => attempt.error(format!(
|
||||||
|
"Backend request refused a cross-origin redirect from {origin} to {target_origin}"
|
||||||
|
)),
|
||||||
|
Err(error) => attempt.error(error.to_string()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum BackendApiClientError {
|
||||||
|
InvalidBackendOrigin(String),
|
||||||
|
InvalidRequestPath(String),
|
||||||
|
ConfigDirectoryUnavailable,
|
||||||
|
TokenFileMissing {
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
TokenFileMalformed {
|
||||||
|
path: PathBuf,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
TokenEntryMissing {
|
||||||
|
origin: BackendOrigin,
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
TokenExpired {
|
||||||
|
origin: BackendOrigin,
|
||||||
|
expired_at: String,
|
||||||
|
},
|
||||||
|
Http(reqwest::Error),
|
||||||
|
Unauthorized {
|
||||||
|
origin: BackendOrigin,
|
||||||
|
},
|
||||||
|
Forbidden {
|
||||||
|
origin: BackendOrigin,
|
||||||
|
},
|
||||||
|
BackendStatus {
|
||||||
|
origin: BackendOrigin,
|
||||||
|
status: u16,
|
||||||
|
},
|
||||||
|
Io {
|
||||||
|
path: PathBuf,
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for BackendApiClientError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidBackendOrigin(message) | Self::InvalidRequestPath(message) => {
|
||||||
|
f.write_str(message)
|
||||||
|
}
|
||||||
|
Self::ConfigDirectoryUnavailable => f.write_str(
|
||||||
|
"cannot locate the client configuration directory for backend-tokens.json",
|
||||||
|
),
|
||||||
|
Self::TokenFileMissing { path } => write!(
|
||||||
|
f,
|
||||||
|
"Backend token file {} is missing; run `yoi login --backend <BACKEND>` first",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
Self::TokenFileMalformed { path, message } => write!(
|
||||||
|
f,
|
||||||
|
"Backend token file {} is malformed: {message}; run `yoi login --backend <BACKEND>` again",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
Self::TokenEntryMissing { origin, path } => write!(
|
||||||
|
f,
|
||||||
|
"no Backend token for {origin} exists in {}; login URLs are matched by normalized origin, so run `yoi login --backend {origin}`",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
Self::TokenExpired { origin, expired_at } => write!(
|
||||||
|
f,
|
||||||
|
"Backend token for {origin} expired at {expired_at}; run `yoi login --backend {origin}` again"
|
||||||
|
),
|
||||||
|
Self::Http(error) => write!(f, "Backend request failed: {error}"),
|
||||||
|
Self::Unauthorized { origin } => write!(
|
||||||
|
f,
|
||||||
|
"Backend {origin} returned HTTP 401 for the saved token; it may be expired or revoked, so run `yoi login --backend {origin}` again"
|
||||||
|
),
|
||||||
|
Self::Forbidden { origin } => write!(
|
||||||
|
f,
|
||||||
|
"Backend {origin} returned HTTP 403; the saved token is authenticated but is not authorized for this operation"
|
||||||
|
),
|
||||||
|
Self::BackendStatus { origin, status } => {
|
||||||
|
write!(f, "Backend {origin} returned HTTP {status}")
|
||||||
|
}
|
||||||
|
Self::Io { path, source } => {
|
||||||
|
write!(f, "failed to access {}: {source}", path.display())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for BackendApiClientError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Http(error) => Some(error),
|
||||||
|
Self::Io { source, .. } => Some(source),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct BackendTokenFile {
|
||||||
|
tokens: BTreeMap<String, BackendTokenEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct BackendTokenEntry {
|
||||||
|
token_type: String,
|
||||||
|
access_token: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
expires_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_backend_token(
|
||||||
|
base_url: &str,
|
||||||
|
token_type: &str,
|
||||||
|
access_token: &str,
|
||||||
|
) -> Result<PathBuf, BackendApiClientError> {
|
||||||
|
save_backend_token_with_expiry(base_url, token_type, access_token, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_backend_token_with_expiry(
|
||||||
|
base_url: &str,
|
||||||
|
token_type: &str,
|
||||||
|
access_token: &str,
|
||||||
|
expires_at: Option<String>,
|
||||||
|
) -> Result<PathBuf, BackendApiClientError> {
|
||||||
|
let path = backend_token_file_path()?;
|
||||||
|
save_backend_token_to_file(base_url, token_type, access_token, expires_at, &path)?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_backend_token_to_file(
|
||||||
|
base_url: &str,
|
||||||
|
token_type: &str,
|
||||||
|
access_token: &str,
|
||||||
|
expires_at: Option<String>,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<(), BackendApiClientError> {
|
||||||
|
let origin = BackendOrigin::parse(base_url)?;
|
||||||
|
let mut token_file = if path.exists() {
|
||||||
|
read_token_file(&path)?
|
||||||
|
} else {
|
||||||
|
BackendTokenFile {
|
||||||
|
tokens: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let entry = BackendTokenEntry {
|
||||||
|
token_type: token_type.to_string(),
|
||||||
|
access_token: access_token.to_string(),
|
||||||
|
expires_at,
|
||||||
|
};
|
||||||
|
validate_token_entry(&entry, &origin, path)?;
|
||||||
|
token_file.tokens.insert(origin.to_string(), entry);
|
||||||
|
write_token_file(path, &token_file)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backend_token_file_path() -> Result<PathBuf, BackendApiClientError> {
|
||||||
|
if let Some(config_home) = env::var_os("XDG_CONFIG_HOME") {
|
||||||
|
return Ok(PathBuf::from(config_home).join("yoi").join(TOKEN_FILE_NAME));
|
||||||
|
}
|
||||||
|
let Some(home) = env::var_os("HOME") else {
|
||||||
|
return Err(BackendApiClientError::ConfigDirectoryUnavailable);
|
||||||
|
};
|
||||||
|
Ok(PathBuf::from(home)
|
||||||
|
.join(".config")
|
||||||
|
.join("yoi")
|
||||||
|
.join(TOKEN_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_token_file(path: &Path) -> Result<BackendTokenFile, BackendApiClientError> {
|
||||||
|
let bytes = fs::read(path).map_err(|source| {
|
||||||
|
if source.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
BackendApiClientError::TokenFileMissing {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
BackendApiClientError::Io {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let raw: BackendTokenFile = serde_json::from_slice(&bytes).map_err(|error| {
|
||||||
|
BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: error.to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
normalize_token_file(raw, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_token_file(
|
||||||
|
token_file: BackendTokenFile,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<BackendTokenFile, BackendApiClientError> {
|
||||||
|
let mut normalized = BTreeMap::new();
|
||||||
|
for (raw_origin, entry) in token_file.tokens {
|
||||||
|
let origin = BackendOrigin::parse(&raw_origin).map_err(|error| {
|
||||||
|
BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: format!("token key `{raw_origin}` is invalid: {error}"),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if normalized.insert(origin.to_string(), entry).is_some() {
|
||||||
|
return Err(BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: format!("more than one token entry normalizes to `{origin}`"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(BackendTokenFile { tokens: normalized })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_token_entry(
|
||||||
|
entry: &BackendTokenEntry,
|
||||||
|
origin: &BackendOrigin,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<(), BackendApiClientError> {
|
||||||
|
if !entry.token_type.eq_ignore_ascii_case("Bearer") {
|
||||||
|
return Err(BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: format!("token for `{origin}` does not use the Bearer token type"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if entry.access_token.trim().is_empty()
|
||||||
|
|| entry.access_token.contains('\r')
|
||||||
|
|| entry.access_token.contains('\n')
|
||||||
|
{
|
||||||
|
return Err(BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: format!("token for `{origin}` is empty or contains an invalid line break"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if reqwest::header::HeaderValue::from_str(&format!("Bearer {}", entry.access_token)).is_err() {
|
||||||
|
return Err(BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: format!("token for `{origin}` cannot be represented as an HTTP header"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(expires_at) = entry.expires_at.as_deref() {
|
||||||
|
let expiration = DateTime::parse_from_rfc3339(expires_at).map_err(|error| {
|
||||||
|
BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: format!("token for `{origin}` has invalid expires_at: {error}"),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if expiration <= Utc::now() {
|
||||||
|
return Err(BackendApiClientError::TokenExpired {
|
||||||
|
origin: origin.clone(),
|
||||||
|
expired_at: expires_at.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_token_file(
|
||||||
|
path: &Path,
|
||||||
|
token_file: &BackendTokenFile,
|
||||||
|
) -> Result<(), BackendApiClientError> {
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.ok_or(BackendApiClientError::ConfigDirectoryUnavailable)?;
|
||||||
|
fs::create_dir_all(parent).map_err(|source| BackendApiClientError::Io {
|
||||||
|
path: parent.to_path_buf(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
let payload = serde_json::to_vec_pretty(token_file).map_err(|error| {
|
||||||
|
BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: error.to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let temp_path = parent.join(format!(".{TOKEN_FILE_NAME}.tmp-{}", std::process::id()));
|
||||||
|
let mut options = OpenOptions::new();
|
||||||
|
options.write(true).create(true).truncate(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
options.mode(0o600);
|
||||||
|
}
|
||||||
|
let mut file = options
|
||||||
|
.open(&temp_path)
|
||||||
|
.map_err(|source| BackendApiClientError::Io {
|
||||||
|
path: temp_path.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
file.write_all(&payload)
|
||||||
|
.and_then(|()| file.write_all(b"\n"))
|
||||||
|
.and_then(|()| file.sync_all())
|
||||||
|
.map_err(|source| BackendApiClientError::Io {
|
||||||
|
path: temp_path.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
fs::rename(&temp_path, path).map_err(|source| BackendApiClientError::Io {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn temp_path(label: &str) -> PathBuf {
|
||||||
|
let nonce = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
env::temp_dir().join(format!(
|
||||||
|
"yoi-client-{label}-{}-{nonce}.json",
|
||||||
|
std::process::id()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_fixture(path: &Path, value: serde_json::Value) {
|
||||||
|
fs::write(path, serde_json::to_vec(&value).unwrap()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_origin_normalizes_safe_equivalents() {
|
||||||
|
let variants = [
|
||||||
|
"HTTP://Example.COM",
|
||||||
|
"http://example.com/",
|
||||||
|
"http://example.com:80////",
|
||||||
|
];
|
||||||
|
for variant in variants {
|
||||||
|
assert_eq!(
|
||||||
|
BackendOrigin::parse(variant).unwrap().as_str(),
|
||||||
|
"http://example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
BackendOrigin::parse("https://EXAMPLE.com:443/")
|
||||||
|
.unwrap()
|
||||||
|
.as_str(),
|
||||||
|
"https://example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
BackendOrigin::parse("https://example.com:8443/")
|
||||||
|
.unwrap()
|
||||||
|
.as_str(),
|
||||||
|
"https://example.com:8443"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_origin_rejects_unsafe_authority_changes() {
|
||||||
|
for invalid in [
|
||||||
|
"ftp://example.com",
|
||||||
|
"https://user@example.com",
|
||||||
|
"https://example.com/api",
|
||||||
|
"https://example.com/?query=1",
|
||||||
|
"https://example.com/#fragment",
|
||||||
|
] {
|
||||||
|
assert!(BackendOrigin::parse(invalid).is_err(), "accepted {invalid}");
|
||||||
|
}
|
||||||
|
assert_ne!(
|
||||||
|
BackendOrigin::parse("http://localhost:8787").unwrap(),
|
||||||
|
BackendOrigin::parse("http://127.0.0.1:8787").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_lookup_distinguishes_missing_malformed_mismatch_and_expired() {
|
||||||
|
let missing = temp_path("missing");
|
||||||
|
assert!(matches!(
|
||||||
|
BackendApiClient::from_token_file("http://localhost:8787", &missing),
|
||||||
|
Err(BackendApiClientError::TokenFileMissing { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let malformed = temp_path("malformed");
|
||||||
|
fs::write(&malformed, b"not json").unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
BackendApiClient::from_token_file("http://localhost:8787", &malformed),
|
||||||
|
Err(BackendApiClientError::TokenFileMalformed { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let mismatch = temp_path("mismatch");
|
||||||
|
write_fixture(
|
||||||
|
&mismatch,
|
||||||
|
serde_json::json!({"tokens": {"http://localhost:8787": {
|
||||||
|
"token_type": "Bearer", "access_token": "secret"
|
||||||
|
}}}),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
BackendApiClient::from_token_file("http://127.0.0.1:8787", &mismatch),
|
||||||
|
Err(BackendApiClientError::TokenEntryMissing { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let expired = temp_path("expired");
|
||||||
|
write_fixture(
|
||||||
|
&expired,
|
||||||
|
serde_json::json!({"tokens": {"http://localhost:8787": {
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"access_token": "secret",
|
||||||
|
"expires_at": "2000-01-01T00:00:00Z"
|
||||||
|
}}}),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
BackendApiClient::from_token_file("http://localhost:8787", &expired),
|
||||||
|
Err(BackendApiClientError::TokenExpired { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
for path in [malformed, mismatch, expired] {
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_write_and_lookup_share_origin_normalization() {
|
||||||
|
let path = temp_path("normalized-write");
|
||||||
|
save_backend_token_to_file(
|
||||||
|
"HTTP://Example.COM:80////",
|
||||||
|
"Bearer",
|
||||||
|
"normalized-secret",
|
||||||
|
None,
|
||||||
|
&path,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let contents = fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(contents.contains("\"http://example.com\""));
|
||||||
|
let client = BackendApiClient::from_token_file("http://example.com/", &path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client.authorization_header_value(),
|
||||||
|
"Bearer normalized-secret"
|
||||||
|
);
|
||||||
|
fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_debug_and_errors_never_include_token_value() {
|
||||||
|
let client = BackendApiClient::from_access_token_for_test(
|
||||||
|
"http://localhost:8787",
|
||||||
|
"never-print-this-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!format!("{client:?}").contains("never-print-this-token"));
|
||||||
|
assert!(
|
||||||
|
!BackendApiClientError::Unauthorized {
|
||||||
|
origin: client.origin().clone()
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
.contains("never-print-this-token")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authenticated_requests_follow_only_same_origin_redirects() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let origin = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
for response in [
|
||||||
|
"HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||||
|
] {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut request = vec![0; 4096];
|
||||||
|
let read = stream.read(&mut request).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
||||||
|
assert!(request.contains("authorization: bearer redirect-secret\r\n"));
|
||||||
|
stream.write_all(response.as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let client =
|
||||||
|
BackendApiClient::from_access_token_for_test(&origin, "redirect-secret").unwrap();
|
||||||
|
let response = client
|
||||||
|
.blocking_request(Method::GET, "/start")
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
handle.join().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authenticated_requests_reject_cross_origin_redirects_without_leaking_token() {
|
||||||
|
let source = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let target = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
target.set_nonblocking(true).unwrap();
|
||||||
|
let source_origin = format!("http://{}", source.local_addr().unwrap());
|
||||||
|
let target_origin = format!("http://{}", target.local_addr().unwrap());
|
||||||
|
let location = format!("{target_origin}/capture");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = source.accept().unwrap();
|
||||||
|
let mut request = vec![0; 4096];
|
||||||
|
let read = stream.read(&mut request).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
||||||
|
assert!(request.contains("authorization: bearer redirect-secret\r\n"));
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||||
|
);
|
||||||
|
stream.write_all(response.as_bytes()).unwrap();
|
||||||
|
});
|
||||||
|
let client =
|
||||||
|
BackendApiClient::from_access_token_for_test(&source_origin, "redirect-secret")
|
||||||
|
.unwrap();
|
||||||
|
let error = client
|
||||||
|
.blocking_request(Method::GET, "/start")
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.unwrap_err();
|
||||||
|
let message = error.to_string();
|
||||||
|
assert!(message.contains("redirect"));
|
||||||
|
assert!(!message.contains("redirect-secret"));
|
||||||
|
handle.join().unwrap();
|
||||||
|
thread::sleep(Duration::from_millis(20));
|
||||||
|
assert!(matches!(
|
||||||
|
target.accept(),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_diagnostics_distinguish_unauthorized_and_forbidden() {
|
||||||
|
let client =
|
||||||
|
BackendApiClient::from_access_token_for_test("http://localhost:8787", "secret")
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
client.check_status(StatusCode::UNAUTHORIZED),
|
||||||
|
Err(BackendApiClientError::Unauthorized { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
client.check_status(StatusCode::FORBIDDEN),
|
||||||
|
Err(BackendApiClientError::Forbidden { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::BackendOrigin;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -9,9 +10,11 @@ pub struct BackendAuthTarget {
|
|||||||
|
|
||||||
impl BackendAuthTarget {
|
impl BackendAuthTarget {
|
||||||
pub fn new(base_url: impl Into<String>) -> Self {
|
pub fn new(base_url: impl Into<String>) -> Self {
|
||||||
Self {
|
let base_url = base_url.into();
|
||||||
base_url: base_url.into(),
|
let base_url = BackendOrigin::parse(&base_url)
|
||||||
}
|
.map(|origin| origin.to_string())
|
||||||
|
.unwrap_or(base_url);
|
||||||
|
Self { base_url }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_url(&self, path: &str) -> String {
|
fn api_url(&self, path: &str) -> String {
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
use crate::{BackendApiClient, BackendApiClientError};
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use protocol::stream::{decode_event, encode_method};
|
use protocol::stream::{decode_event, encode_method};
|
||||||
use protocol::{ErrorCode, Event, Method};
|
use protocol::{ErrorCode, Event, Method};
|
||||||
|
use reqwest::Method as HttpMethod;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_tungstenite::connect_async;
|
use tokio_tungstenite::connect_async;
|
||||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||||
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
|
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||||
|
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
|
||||||
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
|
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
|
||||||
pub use workspace_api::{
|
pub use workspace_api::{
|
||||||
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
|
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
|
||||||
@@ -113,6 +118,7 @@ pub struct BackendRuntimeClient {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BackendRuntimeClientError {
|
pub enum BackendRuntimeClientError {
|
||||||
InvalidTarget(String),
|
InvalidTarget(String),
|
||||||
|
Api(BackendApiClientError),
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +126,7 @@ impl fmt::Display for BackendRuntimeClientError {
|
|||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::InvalidTarget(message) => f.write_str(message),
|
Self::InvalidTarget(message) => f.write_str(message),
|
||||||
|
Self::Api(error) => write!(f, "{error}"),
|
||||||
Self::Http(error) => write!(f, "{error}"),
|
Self::Http(error) => write!(f, "{error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +134,12 @@ impl fmt::Display for BackendRuntimeClientError {
|
|||||||
|
|
||||||
impl std::error::Error for BackendRuntimeClientError {}
|
impl std::error::Error for BackendRuntimeClientError {}
|
||||||
|
|
||||||
|
impl From<BackendApiClientError> for BackendRuntimeClientError {
|
||||||
|
fn from(error: BackendApiClientError) -> Self {
|
||||||
|
Self::Api(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<reqwest::Error> for BackendRuntimeClientError {
|
impl From<reqwest::Error> for BackendRuntimeClientError {
|
||||||
fn from(error: reqwest::Error) -> Self {
|
fn from(error: reqwest::Error) -> Self {
|
||||||
Self::Http(error)
|
Self::Http(error)
|
||||||
@@ -137,7 +150,7 @@ pub async fn list_backend_workers(
|
|||||||
target: &BackendRuntimeListTarget,
|
target: &BackendRuntimeListTarget,
|
||||||
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
|
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
|
||||||
validate_list_target(target)?;
|
validate_list_target(target)?;
|
||||||
let http = reqwest::Client::new();
|
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
if let Some(runtime_id) = target.runtime_id.as_deref() {
|
if let Some(runtime_id) = target.runtime_id.as_deref() {
|
||||||
let path = backend_runtime_workers_path(
|
let path = backend_runtime_workers_path(
|
||||||
target
|
target
|
||||||
@@ -146,12 +159,9 @@ pub async fn list_backend_workers(
|
|||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
runtime_id,
|
runtime_id,
|
||||||
);
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let response = api.request(HttpMethod::GET, &path)?.send().await?;
|
||||||
return Ok(http
|
api.check_status(response.status())?;
|
||||||
.get(url)
|
return Ok(response
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||||
.await?);
|
.await?);
|
||||||
}
|
}
|
||||||
@@ -162,12 +172,9 @@ pub async fn list_backend_workers(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
);
|
);
|
||||||
let runtime_url = join_base_and_path(&target.base_url, &runtime_path);
|
let response = api.request(HttpMethod::GET, &runtime_path)?.send().await?;
|
||||||
let runtimes = http
|
api.check_status(response.status())?;
|
||||||
.get(runtime_url)
|
let runtimes = response
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.json::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
|
.json::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -181,29 +188,43 @@ pub async fn list_backend_workers(
|
|||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
&runtime.runtime_id,
|
&runtime.runtime_id,
|
||||||
);
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let response = match api.request(HttpMethod::GET, &path)?.send().await {
|
||||||
match http
|
Ok(response) => response,
|
||||||
.get(url)
|
Err(error) => {
|
||||||
.send()
|
diagnostics.push(BackendDiagnostic {
|
||||||
.await
|
code: "runtime_worker_list_failed".to_string(),
|
||||||
.and_then(|response| response.error_for_status())
|
severity: BackendDiagnosticSeverity::Error,
|
||||||
{
|
message: format!(
|
||||||
Ok(response) => {
|
"failed to list workers for runtime {}: {error}",
|
||||||
let response = response
|
runtime.runtime_id
|
||||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
),
|
||||||
.await?;
|
});
|
||||||
diagnostics.extend(response.diagnostics);
|
continue;
|
||||||
items.extend(response.items);
|
|
||||||
}
|
}
|
||||||
Err(error) => diagnostics.push(BackendDiagnostic {
|
};
|
||||||
|
if matches!(
|
||||||
|
response.status(),
|
||||||
|
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
|
||||||
|
) {
|
||||||
|
api.check_status(response.status())?;
|
||||||
|
}
|
||||||
|
if !response.status().is_success() {
|
||||||
|
diagnostics.push(BackendDiagnostic {
|
||||||
code: "runtime_worker_list_failed".to_string(),
|
code: "runtime_worker_list_failed".to_string(),
|
||||||
severity: BackendDiagnosticSeverity::Error,
|
severity: BackendDiagnosticSeverity::Error,
|
||||||
message: format!(
|
message: format!(
|
||||||
"failed to list workers for runtime {}: {error}",
|
"failed to list workers for runtime {}: Backend returned HTTP {}",
|
||||||
runtime.runtime_id
|
runtime.runtime_id,
|
||||||
|
response.status().as_u16()
|
||||||
),
|
),
|
||||||
}),
|
});
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
let response = response
|
||||||
|
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||||
|
.await?;
|
||||||
|
diagnostics.extend(response.diagnostics);
|
||||||
|
items.extend(response.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(BackendRuntimeListResponse {
|
Ok(BackendRuntimeListResponse {
|
||||||
@@ -224,7 +245,7 @@ pub async fn list_backend_stopped_workers(
|
|||||||
"stopped worker listing requires a runtime id".to_string(),
|
"stopped worker listing requires a runtime id".to_string(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let http = reqwest::Client::new();
|
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let path = backend_runtime_workers_path(
|
let path = backend_runtime_workers_path(
|
||||||
target
|
target
|
||||||
.workspace_id
|
.workspace_id
|
||||||
@@ -232,12 +253,12 @@ pub async fn list_backend_stopped_workers(
|
|||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
runtime_id,
|
runtime_id,
|
||||||
);
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped"));
|
let response = api
|
||||||
Ok(http
|
.request(HttpMethod::GET, &format!("{path}?status=stopped"))?
|
||||||
.get(url)
|
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?;
|
||||||
.error_for_status()?
|
api.check_status(response.status())?;
|
||||||
|
Ok(response
|
||||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
@@ -246,33 +267,33 @@ pub async fn restore_backend_worker(
|
|||||||
target: &BackendRuntimeTarget,
|
target: &BackendRuntimeTarget,
|
||||||
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
||||||
validate_target(target)?;
|
validate_target(target)?;
|
||||||
let http = reqwest::Client::new();
|
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let path = backend_runtime_worker_restore_path(
|
let path = backend_runtime_worker_restore_path(
|
||||||
&target.workspace_id,
|
&target.workspace_id,
|
||||||
&target.runtime_id,
|
&target.runtime_id,
|
||||||
&target.worker_id,
|
&target.worker_id,
|
||||||
);
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let response = api
|
||||||
Ok(http
|
.request(HttpMethod::POST, &path)?
|
||||||
.post(url)
|
|
||||||
.json(&serde_json::json!({}))
|
.json(&serde_json::json!({}))
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?;
|
||||||
.error_for_status()?
|
api.check_status(response.status())?;
|
||||||
.json::<BackendWorkerRestoreResponse>()
|
Ok(response.json::<BackendWorkerRestoreResponse>().await?)
|
||||||
.await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BackendRuntimeClient {
|
impl BackendRuntimeClient {
|
||||||
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
|
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
|
||||||
validate_target(&target)?;
|
validate_target(&target)?;
|
||||||
|
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let (event_tx, rx) = mpsc::unbounded_channel();
|
let (event_tx, rx) = mpsc::unbounded_channel();
|
||||||
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
let protocol_target = target.clone();
|
let protocol_target = target.clone();
|
||||||
let protocol_event_tx = event_tx.clone();
|
let protocol_event_tx = event_tx.clone();
|
||||||
let protocol_task = tokio::spawn(async move {
|
let protocol_task = tokio::spawn(async move {
|
||||||
run_worker_protocol_transport(protocol_target, command_rx, protocol_event_tx).await;
|
run_worker_protocol_transport(protocol_target, api, command_rx, protocol_event_tx)
|
||||||
|
.await;
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -317,11 +338,21 @@ impl Drop for BackendRuntimeClient {
|
|||||||
|
|
||||||
async fn run_worker_protocol_transport(
|
async fn run_worker_protocol_transport(
|
||||||
target: BackendRuntimeTarget,
|
target: BackendRuntimeTarget,
|
||||||
|
api: BackendApiClient,
|
||||||
mut commands: mpsc::UnboundedReceiver<Method>,
|
mut commands: mpsc::UnboundedReceiver<Method>,
|
||||||
tx: mpsc::UnboundedSender<Event>,
|
tx: mpsc::UnboundedSender<Event>,
|
||||||
) {
|
) {
|
||||||
let url = protocol_ws_url(&target);
|
let request = match protocol_ws_request(&target, &api) {
|
||||||
match connect_async(&url).await {
|
Ok(request) => request,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = tx.send(diagnostic_event(format!(
|
||||||
|
"Backend protocol request could not be constructed for {}: {error}",
|
||||||
|
target.display_label()
|
||||||
|
)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match connect_async(request).await {
|
||||||
Ok((ws, _)) => {
|
Ok((ws, _)) => {
|
||||||
let (mut sink, mut stream) = ws.split();
|
let (mut sink, mut stream) = ws.split();
|
||||||
loop {
|
loop {
|
||||||
@@ -387,10 +418,8 @@ async fn run_worker_protocol_transport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
let message = protocol_connect_error_message(&target, &api, &error);
|
||||||
"Backend protocol WebSocket connect failed for {}: {error}",
|
let _ = tx.send(diagnostic_event(message));
|
||||||
target.display_label()
|
|
||||||
)));
|
|
||||||
while commands.recv().await.is_some() {
|
while commands.recv().await.is_some() {
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
let _ = tx.send(diagnostic_event(format!(
|
||||||
"Backend protocol command was not sent because command stream is unavailable for {}",
|
"Backend protocol command was not sent because command stream is unavailable for {}",
|
||||||
@@ -401,6 +430,29 @@ async fn run_worker_protocol_transport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn protocol_connect_error_message(
|
||||||
|
target: &BackendRuntimeTarget,
|
||||||
|
api: &BackendApiClient,
|
||||||
|
error: &tokio_tungstenite::tungstenite::Error,
|
||||||
|
) -> String {
|
||||||
|
if let tokio_tungstenite::tungstenite::Error::Http(response) = error {
|
||||||
|
if let Ok(status) = reqwest::StatusCode::from_u16(response.status().as_u16()) {
|
||||||
|
if matches!(
|
||||||
|
status,
|
||||||
|
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
|
||||||
|
) {
|
||||||
|
if let Err(error) = api.check_status(status) {
|
||||||
|
return error.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"Backend protocol WebSocket connect failed for {}: {error}",
|
||||||
|
target.display_label()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn diagnostic_event(message: impl Into<String>) -> Event {
|
fn diagnostic_event(message: impl Into<String>) -> Event {
|
||||||
Event::Error {
|
Event::Error {
|
||||||
code: ErrorCode::Internal,
|
code: ErrorCode::Internal,
|
||||||
@@ -496,6 +548,19 @@ fn backend_runtime_worker_restore_path(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn protocol_ws_request(
|
||||||
|
target: &BackendRuntimeTarget,
|
||||||
|
api: &BackendApiClient,
|
||||||
|
) -> Result<tokio_tungstenite::tungstenite::http::Request<()>, String> {
|
||||||
|
let mut request = protocol_ws_url(target)
|
||||||
|
.into_client_request()
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let value = HeaderValue::from_str(&api.authorization_header_value())
|
||||||
|
.map_err(|_| "saved Backend token is not a valid Authorization header".to_string())?;
|
||||||
|
request.headers_mut().insert(AUTHORIZATION, value);
|
||||||
|
Ok(request)
|
||||||
|
}
|
||||||
|
|
||||||
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
|
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
|
||||||
let path = format!(
|
let path = format!(
|
||||||
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
|
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
|
||||||
@@ -557,6 +622,26 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_request_attaches_saved_bearer_authorization() {
|
||||||
|
let target = BackendRuntimeTarget::new(
|
||||||
|
"http://127.0.0.1:8787/",
|
||||||
|
"workspace alpha",
|
||||||
|
"runtime/one",
|
||||||
|
"worker one",
|
||||||
|
);
|
||||||
|
let api = BackendApiClient::from_access_token_for_test(
|
||||||
|
"http://127.0.0.1:8787",
|
||||||
|
"websocket-secret",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let request = protocol_ws_request(&target, &api).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
request.headers().get(AUTHORIZATION).unwrap(),
|
||||||
|
"Bearer websocket-secret"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_worker_summary_decodes_current_occupied_workdir_contract() {
|
fn backend_worker_summary_decodes_current_occupied_workdir_contract() {
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use crate::{BackendApiClient, BackendApiClientError};
|
||||||
|
use reqwest::Method;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
||||||
@@ -70,7 +72,7 @@ impl BackendWorkspaceCatalogTarget {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BackendWorkspaceClientError {
|
pub enum BackendWorkspaceClientError {
|
||||||
InvalidTarget(String),
|
InvalidTarget(String),
|
||||||
RequestFailed { status: u16, message: String },
|
Api(BackendApiClientError),
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,9 +80,7 @@ impl fmt::Display for BackendWorkspaceClientError {
|
|||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::InvalidTarget(message) => f.write_str(message),
|
Self::InvalidTarget(message) => f.write_str(message),
|
||||||
Self::RequestFailed { status, message } => {
|
Self::Api(error) => write!(f, "{error}"),
|
||||||
write!(f, "Backend request failed with HTTP {status}: {message}")
|
|
||||||
}
|
|
||||||
Self::Http(error) => write!(f, "{error}"),
|
Self::Http(error) => write!(f, "{error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,6 +88,12 @@ impl fmt::Display for BackendWorkspaceClientError {
|
|||||||
|
|
||||||
impl std::error::Error for BackendWorkspaceClientError {}
|
impl std::error::Error for BackendWorkspaceClientError {}
|
||||||
|
|
||||||
|
impl From<BackendApiClientError> for BackendWorkspaceClientError {
|
||||||
|
fn from(error: BackendApiClientError) -> Self {
|
||||||
|
Self::Api(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<reqwest::Error> for BackendWorkspaceClientError {
|
impl From<reqwest::Error> for BackendWorkspaceClientError {
|
||||||
fn from(error: reqwest::Error) -> Self {
|
fn from(error: reqwest::Error) -> Self {
|
||||||
Self::Http(error)
|
Self::Http(error)
|
||||||
@@ -97,13 +103,21 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
|
|||||||
pub async fn list_backend_workspaces(
|
pub async fn list_backend_workspaces(
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||||
validate_target(target)?;
|
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let url = format!(
|
list_backend_workspaces_with_client(&client).await
|
||||||
"{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}",
|
}
|
||||||
target.base_url.trim_end_matches('/')
|
|
||||||
);
|
async fn list_backend_workspaces_with_client(
|
||||||
let response = reqwest::Client::new().get(url).send().await?;
|
client: &BackendApiClient,
|
||||||
let response = require_success(response).await?;
|
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||||
|
let response = client
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
|
||||||
|
)?
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
client.check_status(response.status())?;
|
||||||
Ok(response.json::<Vec<BackendWorkspace>>().await?)
|
Ok(response.json::<Vec<BackendWorkspace>>().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,42 +125,50 @@ pub async fn create_backend_workspace(
|
|||||||
target: &BackendWorkspaceCatalogTarget,
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
request: &CreateBackendWorkspaceRequest,
|
request: &CreateBackendWorkspaceRequest,
|
||||||
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
|
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
|
||||||
validate_target(target)?;
|
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/'));
|
let response = client
|
||||||
let response = reqwest::Client::new()
|
.request(Method::POST, "/api/workspaces")?
|
||||||
.post(url)
|
|
||||||
.json(request)
|
.json(request)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
let response = require_success(response).await?;
|
client.check_status(response.status())?;
|
||||||
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
|
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn require_success(
|
|
||||||
response: reqwest::Response,
|
|
||||||
) -> Result<reqwest::Response, BackendWorkspaceClientError> {
|
|
||||||
if response.status().is_success() {
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
let message = response.text().await.unwrap_or_default();
|
|
||||||
Err(BackendWorkspaceClientError::RequestFailed { status, message })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_target(
|
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
|
||||||
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
|
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Backend API base URL must start with http:// or https://".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn workspace_catalog_request_uses_shared_bearer_client() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut request = vec![0; 4096];
|
||||||
|
let read = stream.read(&mut request).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
||||||
|
assert!(request.starts_with("get /api/workspaces?limit=200 "));
|
||||||
|
assert!(request.contains("authorization: bearer catalog-secret\r\n"));
|
||||||
|
stream
|
||||||
|
.write_all(
|
||||||
|
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let client =
|
||||||
|
BackendApiClient::from_access_token_for_test(&base_url, "catalog-secret").unwrap();
|
||||||
|
assert!(
|
||||||
|
list_backend_workspaces_with_client(&client)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
handle.join().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_request_keeps_operation_key_for_exact_retry() {
|
fn create_request_keeps_operation_key_for_exact_retry() {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
//!
|
//!
|
||||||
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
||||||
|
|
||||||
pub mod backend_auth;
|
pub mod backend_api;
|
||||||
|
mod backend_auth;
|
||||||
pub mod backend_runtime;
|
pub mod backend_runtime;
|
||||||
pub mod backend_workspace;
|
pub mod backend_workspace;
|
||||||
pub mod runtime_command;
|
pub mod runtime_command;
|
||||||
@@ -18,6 +19,10 @@ pub mod ticket_role;
|
|||||||
mod worker_client;
|
mod worker_client;
|
||||||
mod workspace_product;
|
mod workspace_product;
|
||||||
|
|
||||||
|
pub use backend_api::{
|
||||||
|
BackendApiClient, BackendApiClientError, BackendOrigin, backend_token_file_path,
|
||||||
|
save_backend_token,
|
||||||
|
};
|
||||||
pub use backend_auth::{
|
pub use backend_auth::{
|
||||||
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
||||||
poll_device_login, start_device_login, wait_for_device_login,
|
poll_device_login, start_device_login, wait_for_device_login,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
|
use crate::{
|
||||||
|
BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget,
|
||||||
|
BackendRuntimeTarget, WorkerRuntimeCommand,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum TargetKind {
|
pub enum TargetKind {
|
||||||
@@ -62,11 +65,19 @@ pub struct BackendTarget {
|
|||||||
|
|
||||||
impl BackendTarget {
|
impl BackendTarget {
|
||||||
pub fn new(base_url: impl Into<String>, workspace_id: Option<impl Into<String>>) -> Self {
|
pub fn new(base_url: impl Into<String>, workspace_id: Option<impl Into<String>>) -> Self {
|
||||||
|
let base_url = base_url.into();
|
||||||
|
let base_url = BackendOrigin::parse(&base_url)
|
||||||
|
.map(|origin| origin.to_string())
|
||||||
|
.unwrap_or(base_url);
|
||||||
Self {
|
Self {
|
||||||
base_url: base_url.into(),
|
base_url,
|
||||||
workspace_id: workspace_id.map(Into::into),
|
workspace_id: workspace_id.map(Into::into),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn authenticated_client(&self) -> Result<BackendApiClient, BackendApiClientError> {
|
||||||
|
BackendApiClient::from_stored_token(&self.base_url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -730,7 +730,7 @@ mod tests {
|
|||||||
|
|
||||||
fn test_snapshot() -> Event {
|
fn test_snapshot() -> Event {
|
||||||
Event::Snapshot {
|
Event::Snapshot {
|
||||||
entries: vec![],
|
session: protocol::SessionSnapshot { entries: vec![] },
|
||||||
greeting: Greeting {
|
greeting: Greeting {
|
||||||
worker_name: "ticket-intake".to_string(),
|
worker_name: "ticket-intake".to_string(),
|
||||||
cwd: "/tmp".to_string(),
|
cwd: "/tmp".to_string(),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use workspace_api::{
|
|||||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::BackendWorkspaceClientError;
|
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||||
|
|
||||||
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
||||||
|
|
||||||
@@ -47,9 +47,9 @@ struct BackendWorkspaceOrchestratorResponse {
|
|||||||
/// Construction requires both the selected Backend URL and Workspace identity.
|
/// Construction requires both the selected Backend URL and Workspace identity.
|
||||||
/// Callers should derive these once from `Target::resolve()` and must not retry
|
/// Callers should derive these once from `Target::resolve()` and must not retry
|
||||||
/// failed requests against repository-local state.
|
/// failed requests against repository-local state.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BackendWorkspaceProductClient {
|
pub struct BackendWorkspaceProductClient {
|
||||||
base_url: String,
|
api: BackendApiClient,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,22 +58,32 @@ impl BackendWorkspaceProductClient {
|
|||||||
base_url: impl Into<String>,
|
base_url: impl Into<String>,
|
||||||
workspace_id: impl Into<String>,
|
workspace_id: impl Into<String>,
|
||||||
) -> Result<Self, BackendWorkspaceClientError> {
|
) -> Result<Self, BackendWorkspaceClientError> {
|
||||||
let base_url = base_url.into().trim_end_matches('/').to_string();
|
let base_url = base_url.into();
|
||||||
if base_url.is_empty() {
|
let api = BackendApiClient::from_stored_token(&base_url)?;
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Backend base URL must not be empty".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let workspace_id = workspace_id.into();
|
let workspace_id = workspace_id.into();
|
||||||
if workspace_id.trim().is_empty() {
|
if workspace_id.trim().is_empty() {
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||||
"Backend Workspace identity must not be empty".into(),
|
"Backend Workspace identity must not be empty".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self { api, workspace_id })
|
||||||
base_url,
|
}
|
||||||
workspace_id,
|
|
||||||
})
|
#[cfg(test)]
|
||||||
|
fn new_with_access_token(
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
workspace_id: impl Into<String>,
|
||||||
|
access_token: &str,
|
||||||
|
) -> Result<Self, BackendWorkspaceClientError> {
|
||||||
|
let base_url = base_url.into();
|
||||||
|
let api = BackendApiClient::from_access_token_for_test(&base_url, access_token)?;
|
||||||
|
let workspace_id = workspace_id.into();
|
||||||
|
if workspace_id.trim().is_empty() {
|
||||||
|
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||||
|
"Backend Workspace identity must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self { api, workspace_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn workspace_id(&self) -> &str {
|
pub fn workspace_id(&self) -> &str {
|
||||||
@@ -316,7 +326,7 @@ impl BackendWorkspaceProductClient {
|
|||||||
body: Option<&B>,
|
body: Option<&B>,
|
||||||
) -> Result<R, BackendWorkspaceClientError> {
|
) -> Result<R, BackendWorkspaceClientError> {
|
||||||
let response = self.request(method, path, body)?.send()?;
|
let response = self.request(method, path, body)?.send()?;
|
||||||
let response = ensure_success(response)?;
|
self.api.check_status(response.status())?;
|
||||||
response.json().map_err(BackendWorkspaceClientError::Http)
|
response.json().map_err(BackendWorkspaceClientError::Http)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +336,8 @@ impl BackendWorkspaceProductClient {
|
|||||||
path: &str,
|
path: &str,
|
||||||
body: Option<&B>,
|
body: Option<&B>,
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
) -> Result<(), BackendWorkspaceClientError> {
|
||||||
ensure_success(self.request(method, path, body)?.send()?)?;
|
let response = self.request(method, path, body)?.send()?;
|
||||||
|
self.api.check_status(response.status())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,14 +347,12 @@ impl BackendWorkspaceProductClient {
|
|||||||
path: &str,
|
path: &str,
|
||||||
body: Option<&B>,
|
body: Option<&B>,
|
||||||
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
|
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
|
||||||
let client = reqwest::blocking::Client::builder().build()?;
|
let path = format!(
|
||||||
let url = format!(
|
"/api/w/{}/{}",
|
||||||
"{}/api/w/{}/{}",
|
|
||||||
self.base_url,
|
|
||||||
encode_path_segment(&self.workspace_id),
|
encode_path_segment(&self.workspace_id),
|
||||||
path.trim_start_matches('/')
|
path.trim_start_matches('/')
|
||||||
);
|
);
|
||||||
let request = client.request(method, url);
|
let request = self.api.blocking_request(method, &path)?;
|
||||||
Ok(match body {
|
Ok(match body {
|
||||||
Some(body) => request.json(body),
|
Some(body) => request.json(body),
|
||||||
None => request,
|
None => request,
|
||||||
@@ -588,19 +597,6 @@ fn ticket_client_error(error: BackendWorkspaceClientError) -> TicketError {
|
|||||||
TicketError::Sqlite(format!("Backend request failed: {error}"))
|
TicketError::Sqlite(format!("Backend request failed: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_success(
|
|
||||||
response: reqwest::blocking::Response,
|
|
||||||
) -> Result<reqwest::blocking::Response, BackendWorkspaceClientError> {
|
|
||||||
if response.status().is_success() {
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
let message = response
|
|
||||||
.text()
|
|
||||||
.unwrap_or_else(|_| "Backend request failed".to_string());
|
|
||||||
Err(BackendWorkspaceClientError::RequestFailed { status, message })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ticket_reference(id: &TicketIdOrSlug) -> String {
|
fn ticket_reference(id: &TicketIdOrSlug) -> String {
|
||||||
match id {
|
match id {
|
||||||
TicketIdOrSlug::Id(id) => id.to_string(),
|
TicketIdOrSlug::Id(id) => id.to_string(),
|
||||||
@@ -698,24 +694,32 @@ mod tests {
|
|||||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
fn objective_list_uses_workspace_scoped_backend_route() {
|
||||||
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
let (base_url, request, handle) = one_response_server("200 OK", body);
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let response = client.list_objectives(1_000).unwrap();
|
let response = client.list_objectives(1_000).unwrap();
|
||||||
|
|
||||||
assert!(response.items.is_empty());
|
assert!(response.items.is_empty());
|
||||||
assert!(
|
let request = request.recv().unwrap();
|
||||||
request
|
assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 "));
|
||||||
.recv()
|
assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
|
||||||
.unwrap()
|
|
||||||
.starts_with("GET /api/w/workspace-a/objectives?limit=1000 ")
|
|
||||||
);
|
|
||||||
handle.join().unwrap();
|
handle.join().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_mutation_failure_is_returned_without_local_fallback() {
|
fn backend_mutation_failure_is_returned_without_local_fallback() {
|
||||||
let (base_url, request, handle) = one_response_server("403 Forbidden", "denied");
|
let (base_url, request, handle) =
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
one_response_server("403 Forbidden", "test-backend-token");
|
||||||
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let error = client
|
let error = client
|
||||||
.create_objective(&ObjectiveCreateRequest {
|
.create_objective(&ObjectiveCreateRequest {
|
||||||
@@ -727,6 +731,7 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(error.to_string().contains("403"));
|
assert!(error.to_string().contains("403"));
|
||||||
|
assert!(!error.to_string().contains("test-backend-token"));
|
||||||
assert!(
|
assert!(
|
||||||
request
|
request
|
||||||
.recv()
|
.recv()
|
||||||
@@ -739,7 +744,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ticket_relation_query_uses_workspace_scoped_backend_route() {
|
fn ticket_relation_query_uses_workspace_scoped_backend_route() {
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let relations = client
|
let relations = client
|
||||||
.query_ticket_relations(
|
.query_ticket_relations(
|
||||||
@@ -758,7 +768,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
|
fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap();
|
let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap();
|
||||||
|
|
||||||
@@ -784,7 +799,12 @@ mod tests {
|
|||||||
r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#,
|
r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let status = client.launch_ticket_intake("T-1").unwrap();
|
let status = client.launch_ticket_intake("T-1").unwrap();
|
||||||
|
|
||||||
@@ -806,7 +826,12 @@ mod tests {
|
|||||||
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
|
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
|
||||||
let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#;
|
let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#;
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
let (base_url, request, handle) = one_response_server("200 OK", body);
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let status = client.start_workspace_orchestrator().unwrap();
|
let status = client.start_workspace_orchestrator().unwrap();
|
||||||
|
|
||||||
@@ -822,7 +847,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn product_client_requires_workspace_identity() {
|
fn product_client_requires_workspace_identity() {
|
||||||
let error = BackendWorkspaceProductClient::new("http://127.0.0.1:8787", "").unwrap_err();
|
let error = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
"http://127.0.0.1:8787",
|
||||||
|
"",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
assert!(error.to_string().contains("Workspace identity"));
|
assert!(error.to_string().contains("Workspace identity"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+142
-29
@@ -340,8 +340,7 @@ pub struct InternalWorkerRef {
|
|||||||
pub struct InternalWorkerSnapshot {
|
pub struct InternalWorkerSnapshot {
|
||||||
pub worker: InternalWorkerRef,
|
pub worker: InternalWorkerRef,
|
||||||
pub revision: u64,
|
pub revision: u64,
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
|
pub session: SessionSnapshot,
|
||||||
pub entries: Vec<serde_json::Value>,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub status: WorkerStatus,
|
pub status: WorkerStatus,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -364,12 +363,114 @@ pub enum ToolResultDisposition {
|
|||||||
OutcomeUnknown,
|
OutcomeUnknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Canonical, storage-independent projection of committed session history.
|
||||||
|
///
|
||||||
|
/// Worker protocols expose this DTO instead of append-log records. New
|
||||||
|
/// storage variants can therefore be added without teaching every client how
|
||||||
|
/// to replay the durable log format.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct SessionSnapshot {
|
||||||
|
pub entries: Vec<SessionSnapshotEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SessionEntryProvenance {
|
||||||
|
HumanInput,
|
||||||
|
WorkerInput,
|
||||||
|
FlowInstruction,
|
||||||
|
BackendInstruction,
|
||||||
|
ModelOutput,
|
||||||
|
ToolOutput,
|
||||||
|
DerivedSummary,
|
||||||
|
LegacyUnknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct SessionSnapshotEntry {
|
||||||
|
/// Stable identity from durable history metadata, or a deterministic
|
||||||
|
/// identity derived from the legacy segment and log position.
|
||||||
|
pub entry_id: String,
|
||||||
|
/// Timestamp copied from the durable log record that commits this entry.
|
||||||
|
pub timestamp: u64,
|
||||||
|
pub provenance: SessionEntryProvenance,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub derived_from: Vec<String>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub data: SessionSnapshotEntryData,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum SessionSnapshotEntryData {
|
||||||
|
UserInput {
|
||||||
|
segments: Vec<Segment>,
|
||||||
|
},
|
||||||
|
Message {
|
||||||
|
role: SessionMessageRole,
|
||||||
|
content: Vec<SessionContentPart>,
|
||||||
|
},
|
||||||
|
ToolCall {
|
||||||
|
call_id: String,
|
||||||
|
name: String,
|
||||||
|
arguments: String,
|
||||||
|
},
|
||||||
|
ToolResult {
|
||||||
|
call_id: String,
|
||||||
|
summary: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
content: Option<String>,
|
||||||
|
is_error: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
attachments: Vec<SessionToolAttachment>,
|
||||||
|
},
|
||||||
|
SystemItem {
|
||||||
|
item_kind: String,
|
||||||
|
content: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||||
|
data: Option<serde_json::Value>,
|
||||||
|
},
|
||||||
|
RunError {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SessionMessageRole {
|
||||||
|
User,
|
||||||
|
Assistant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
pub enum SessionContentPart {
|
||||||
|
Text { text: String },
|
||||||
|
Refusal { refusal: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct SessionToolAttachment {
|
||||||
|
pub media_type: String,
|
||||||
|
/// Base64-encoded durable attachment body. Public snapshots preserve the
|
||||||
|
/// committed multimodal value instead of replacing it with placeholder text.
|
||||||
|
pub data_base64: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
|
||||||
pub enum Event {
|
pub enum Event {
|
||||||
/// A user input message was accepted, persisted as
|
/// A user input message was accepted, persisted as
|
||||||
/// `LogEntry::UserInput`, and is about to start a new turn.
|
/// `LogEntry::AnnotatedUserInput`, and is about to start a new turn.
|
||||||
/// Broadcast to every subscribed client so TUI / GUI instances show
|
/// Broadcast to every subscribed client so TUI / GUI instances show
|
||||||
/// the same user line that reconnect snapshots would replay from
|
/// the same user line that reconnect snapshots would replay from
|
||||||
/// history; clients must not synthesize a separate pending/fake
|
/// history; clients must not synthesize a separate pending/fake
|
||||||
@@ -390,7 +491,7 @@ pub enum Event {
|
|||||||
/// of parsing free-text prefixes like `[Notification] …` or
|
/// of parsing free-text prefixes like `[Notification] …` or
|
||||||
/// `[File: …]`.
|
/// `[File: …]`.
|
||||||
///
|
///
|
||||||
/// One event per `LogEntry::SystemItem` commit. Disk-side and
|
/// One event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and
|
||||||
/// wire-side are 1:1.
|
/// wire-side are 1:1.
|
||||||
SystemItem {
|
SystemItem {
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
||||||
@@ -555,8 +656,7 @@ pub enum Event {
|
|||||||
/// role-specific entry events (`SegmentRotated` / `SystemItem`) —
|
/// role-specific entry events (`SegmentRotated` / `SystemItem`) —
|
||||||
/// there is no generic "every committed entry" broadcast.
|
/// there is no generic "every committed entry" broadcast.
|
||||||
Snapshot {
|
Snapshot {
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
|
session: SessionSnapshot,
|
||||||
entries: Vec<serde_json::Value>,
|
|
||||||
greeting: Greeting,
|
greeting: Greeting,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
status: WorkerStatus,
|
status: WorkerStatus,
|
||||||
@@ -589,14 +689,10 @@ pub enum Event {
|
|||||||
/// Server-side segment log rotated to a fresh `SegmentStart`.
|
/// Server-side segment log rotated to a fresh `SegmentStart`.
|
||||||
///
|
///
|
||||||
/// Fires on compaction and on auto-fork when the store head drifts
|
/// Fires on compaction and on auto-fork when the store head drifts
|
||||||
/// from the live writer's cached head. Clients drop their derived
|
/// A compaction/fork has replaced the authoritative segment. Clients drop
|
||||||
/// view and reseed from `entry.history` exactly the way they would
|
/// their derived view and reseed from the canonical committed snapshot.
|
||||||
/// from a connect-time `Snapshot`.
|
|
||||||
///
|
|
||||||
/// Payload is the JSON form of `session_store::LogEntry::SegmentStart`.
|
|
||||||
SegmentRotated {
|
SegmentRotated {
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "unknown"))]
|
session: SessionSnapshot,
|
||||||
entry: serde_json::Value,
|
|
||||||
},
|
},
|
||||||
/// Current Worker controller status. Broadcast on every controller-level
|
/// Current Worker controller status. Broadcast on every controller-level
|
||||||
/// transition and included in `History` snapshots for late attach.
|
/// transition and included in `History` snapshots for late attach.
|
||||||
@@ -623,11 +719,10 @@ pub enum Event {
|
|||||||
head_entries: usize,
|
head_entries: usize,
|
||||||
targets: Vec<RewindTarget>,
|
targets: Vec<RewindTarget>,
|
||||||
},
|
},
|
||||||
/// A rewind has truncated the authoritative session. `entries` is the
|
/// A rewind has truncated the authoritative session. `session` is the
|
||||||
/// retained session-log prefix clients should use to reseed display state.
|
/// retained canonical snapshot clients should use to reseed display state.
|
||||||
RewindApplied {
|
RewindApplied {
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "Array<unknown>"))]
|
session: SessionSnapshot,
|
||||||
entries: Vec<serde_json::Value>,
|
|
||||||
input: Vec<Segment>,
|
input: Vec<Segment>,
|
||||||
summary: RewindSummary,
|
summary: RewindSummary,
|
||||||
},
|
},
|
||||||
@@ -1440,7 +1535,17 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn event_snapshot_format() {
|
fn event_snapshot_format() {
|
||||||
let event = Event::Snapshot {
|
let event = Event::Snapshot {
|
||||||
entries: vec![serde_json::json!({"kind": "user_input", "ts": 1, "segments": []})],
|
session: SessionSnapshot {
|
||||||
|
entries: vec![SessionSnapshotEntry {
|
||||||
|
entry_id: "entry-1".into(),
|
||||||
|
timestamp: 1,
|
||||||
|
provenance: SessionEntryProvenance::HumanInput,
|
||||||
|
derived_from: Vec::new(),
|
||||||
|
data: SessionSnapshotEntryData::UserInput {
|
||||||
|
segments: Vec::new(),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
greeting: Greeting {
|
greeting: Greeting {
|
||||||
worker_name: "test".into(),
|
worker_name: "test".into(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
@@ -1458,8 +1563,12 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed["event"], "snapshot");
|
assert_eq!(parsed["event"], "snapshot");
|
||||||
assert!(parsed["data"]["entries"].is_array());
|
assert!(parsed["data"]["session"]["entries"].is_array());
|
||||||
assert_eq!(parsed["data"]["entries"][0]["kind"], "user_input");
|
assert_eq!(
|
||||||
|
parsed["data"]["session"]["entries"][0]["kind"],
|
||||||
|
"user_input"
|
||||||
|
);
|
||||||
|
assert_eq!(parsed["data"]["session"]["entries"][0]["timestamp"], 1);
|
||||||
assert_eq!(parsed["data"]["greeting"]["worker_name"], "test");
|
assert_eq!(parsed["data"]["greeting"]["worker_name"], "test");
|
||||||
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
|
assert_eq!(parsed["data"]["greeting"]["tools"][0], "Read");
|
||||||
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
|
assert_eq!(parsed["data"]["greeting"]["context_window"], 200_000);
|
||||||
@@ -1469,7 +1578,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_snapshot_in_flight_roundtrip_and_default() {
|
fn event_snapshot_in_flight_roundtrip_and_default() {
|
||||||
let inbound = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"status":"running"}}"#;
|
let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"status":"running"}}"#;
|
||||||
let decoded: Event = serde_json::from_str(inbound).unwrap();
|
let decoded: Event = serde_json::from_str(inbound).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
|
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
|
||||||
@@ -1477,7 +1586,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let event = Event::Snapshot {
|
let event = Event::Snapshot {
|
||||||
entries: Vec::new(),
|
session: SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
greeting: Greeting {
|
greeting: Greeting {
|
||||||
worker_name: "test".into(),
|
worker_name: "test".into(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
@@ -1543,15 +1654,17 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn event_segment_rotated_roundtrip() {
|
fn event_segment_rotated_roundtrip() {
|
||||||
let event = Event::SegmentRotated {
|
let event = Event::SegmentRotated {
|
||||||
entry: serde_json::json!({"kind": "segment_start", "ts": 1, "history": []}),
|
session: SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed["event"], "segment_rotated");
|
assert_eq!(parsed["event"], "segment_rotated");
|
||||||
assert_eq!(parsed["data"]["entry"]["kind"], "segment_start");
|
assert!(parsed["data"]["session"]["entries"].is_array());
|
||||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
Event::SegmentRotated { entry } => assert_eq!(entry["kind"], "segment_start"),
|
Event::SegmentRotated { session } => assert!(session.entries.is_empty()),
|
||||||
other => panic!("expected SegmentRotated, got {other:?}"),
|
other => panic!("expected SegmentRotated, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1627,8 +1740,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_snapshot_legacy_without_status_defaults_to_idle() {
|
fn event_snapshot_without_status_defaults_to_idle() {
|
||||||
let json = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
|
let json = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#;
|
||||||
let decoded: Event = serde_json::from_str(json).unwrap();
|
let decoded: Event = serde_json::from_str(json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
Event::Snapshot {
|
Event::Snapshot {
|
||||||
@@ -2039,11 +2152,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_snapshot_defaults_internal_workers_to_empty() {
|
fn snapshot_defaults_internal_workers_to_empty() {
|
||||||
let snapshot: Event = serde_json::from_value(serde_json::json!({
|
let snapshot: Event = serde_json::from_value(serde_json::json!({
|
||||||
"event": "snapshot",
|
"event": "snapshot",
|
||||||
"data": {
|
"data": {
|
||||||
"entries": [],
|
"session": { "entries": [] },
|
||||||
"greeting": {
|
"greeting": {
|
||||||
"worker_name": "parent",
|
"worker_name": "parent",
|
||||||
"cwd": ".",
|
"cwd": ".",
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ use crate::{
|
|||||||
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
||||||
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||||
InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId,
|
InvokeKind, MemoryWorkerEvent, Method, 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::{
|
subscription::{
|
||||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||||
@@ -63,6 +65,13 @@ pub fn generated_protocol_types() -> String {
|
|||||||
push_decl::<RewindSummary>(&cfg, &mut output);
|
push_decl::<RewindSummary>(&cfg, &mut output);
|
||||||
push_decl::<InFlightBlock>(&cfg, &mut output);
|
push_decl::<InFlightBlock>(&cfg, &mut output);
|
||||||
push_decl::<InFlightSnapshot>(&cfg, &mut output);
|
push_decl::<InFlightSnapshot>(&cfg, &mut output);
|
||||||
|
push_decl::<SessionEntryProvenance>(&cfg, &mut output);
|
||||||
|
push_decl::<SessionMessageRole>(&cfg, &mut output);
|
||||||
|
push_decl::<SessionContentPart>(&cfg, &mut output);
|
||||||
|
push_decl::<SessionToolAttachment>(&cfg, &mut output);
|
||||||
|
push_decl::<SessionSnapshotEntryData>(&cfg, &mut output);
|
||||||
|
push_decl::<SessionSnapshotEntry>(&cfg, &mut output);
|
||||||
|
push_decl::<SessionSnapshot>(&cfg, &mut output);
|
||||||
push_decl::<InternalWorkerKind>(&cfg, &mut output);
|
push_decl::<InternalWorkerKind>(&cfg, &mut output);
|
||||||
push_decl::<InternalWorkerRef>(&cfg, &mut output);
|
push_decl::<InternalWorkerRef>(&cfg, &mut output);
|
||||||
push_decl::<InternalWorkerSnapshot>(&cfg, &mut output);
|
push_decl::<InternalWorkerSnapshot>(&cfg, &mut output);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{LoggedItem, SessionId};
|
use crate::LoggedItem;
|
||||||
|
|
||||||
/// Stable logical identity of one model-visible history entry.
|
/// Stable logical identity of one model-visible history entry.
|
||||||
///
|
///
|
||||||
@@ -142,12 +142,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn annotated_segment_start_is_restore_visible_without_projecting_metadata() {
|
fn annotated_segment_start_is_restore_visible_without_projecting_metadata() {
|
||||||
let session_id = uuid::Uuid::now_v7();
|
let session_id = uuid::Uuid::now_v7();
|
||||||
let history_entry = legacy_logged_history(LoggedItem::Message {
|
let history_entry = LoggedHistoryEntry {
|
||||||
role: LoggedRole::Assistant,
|
item: LoggedItem::Message {
|
||||||
content: vec![crate::LoggedContentPart::Text {
|
role: LoggedRole::Assistant,
|
||||||
text: "answer".into(),
|
content: vec![crate::LoggedContentPart::Text {
|
||||||
}],
|
text: "answer".into(),
|
||||||
});
|
}],
|
||||||
|
},
|
||||||
|
metadata: LoggedSessionHistoryMetadata::legacy_unknown(),
|
||||||
|
};
|
||||||
let state = crate::collect_state(&[crate::LogEntry::AnnotatedSegmentStart {
|
let state = crate::collect_state(&[crate::LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 1,
|
ts: 1,
|
||||||
session_id,
|
session_id,
|
||||||
@@ -160,21 +163,3 @@ mod tests {
|
|||||||
assert_eq!(state.history[0].as_text(), Some("answer"));
|
assert_eq!(state.history[0].as_text(), Some("answer"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Legacy Session Logs did not persist annotations. Decode helpers explicitly
|
|
||||||
/// create `LegacyUnknown`; they never infer Human/System authority from role or
|
|
||||||
/// plaintext.
|
|
||||||
pub fn legacy_logged_history(item: LoggedItem) -> LoggedHistoryEntry {
|
|
||||||
LoggedHistoryEntry {
|
|
||||||
item,
|
|
||||||
metadata: LoggedSessionHistoryMetadata::legacy_unknown(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn legacy_segment_history(
|
|
||||||
session_id: SessionId,
|
|
||||||
items: impl IntoIterator<Item = LoggedItem>,
|
|
||||||
) -> Vec<LoggedHistoryEntry> {
|
|
||||||
let _ = session_id;
|
|
||||||
items.into_iter().map(legacy_logged_history).collect()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
//! Versioned decoder for Session schemas that predate canonical annotated history.
|
||||||
|
//!
|
||||||
|
//! These types are intentionally private to `session-store`. Current writers,
|
||||||
|
//! replay, and public projections use [`crate::LogEntry`] exclusively; only the
|
||||||
|
//! Worker Session schema migration is allowed to deserialize these shapes.
|
||||||
|
|
||||||
|
use agen::llm_client::types::RequestConfig;
|
||||||
|
use protocol::Segment;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
LogEntry, LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId,
|
||||||
|
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry, SegmentId,
|
||||||
|
SegmentOrigin, SessionExtension, SessionId, SystemItem,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
|
enum LegacyHistoryLogEntry {
|
||||||
|
SegmentStart {
|
||||||
|
ts: u64,
|
||||||
|
session_id: SessionId,
|
||||||
|
system_prompt: Option<String>,
|
||||||
|
config: RequestConfig,
|
||||||
|
history: Vec<LoggedItem>,
|
||||||
|
#[serde(default)]
|
||||||
|
forked_from: Option<SegmentOrigin>,
|
||||||
|
#[serde(default)]
|
||||||
|
compacted_from: Option<SegmentOrigin>,
|
||||||
|
},
|
||||||
|
UserInput {
|
||||||
|
ts: u64,
|
||||||
|
segments: Vec<Segment>,
|
||||||
|
#[serde(default)]
|
||||||
|
extensions: Vec<SessionExtension>,
|
||||||
|
},
|
||||||
|
AssistantItem {
|
||||||
|
ts: u64,
|
||||||
|
item: LoggedItem,
|
||||||
|
},
|
||||||
|
ToolResult {
|
||||||
|
ts: u64,
|
||||||
|
item: LoggedItem,
|
||||||
|
},
|
||||||
|
SystemItem {
|
||||||
|
ts: u64,
|
||||||
|
item: SystemItem,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schema-v1 decoder. Non-history records already had their current shape, so
|
||||||
|
/// they pass through `LogEntry`; legacy history records are converted below.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum LegacySessionLogEntryV1 {
|
||||||
|
History(LegacyHistoryLogEntry),
|
||||||
|
Current(LogEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schema v2 retained the v1 history shapes while adding non-history records.
|
||||||
|
/// Keep a distinct type so supported source versions remain explicit rather
|
||||||
|
/// than turning migration compatibility into the current `LogEntry` contract.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum LegacySessionLogEntryV2 {
|
||||||
|
History(LegacyHistoryLogEntry),
|
||||||
|
Current(LogEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decode_entry(
|
||||||
|
schema_version: u32,
|
||||||
|
line: &str,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
line_index: usize,
|
||||||
|
) -> Result<LogEntry, serde_json::Error> {
|
||||||
|
let entry = match schema_version {
|
||||||
|
1 => match serde_json::from_str::<LegacySessionLogEntryV1>(line)? {
|
||||||
|
LegacySessionLogEntryV1::History(entry) => Entry::History(entry),
|
||||||
|
LegacySessionLogEntryV1::Current(entry) => Entry::Current(entry),
|
||||||
|
},
|
||||||
|
2 => match serde_json::from_str::<LegacySessionLogEntryV2>(line)? {
|
||||||
|
LegacySessionLogEntryV2::History(entry) => Entry::History(entry),
|
||||||
|
LegacySessionLogEntryV2::Current(entry) => Entry::Current(entry),
|
||||||
|
},
|
||||||
|
_ => unreachable!("legacy decoder called for unsupported schema {schema_version}"),
|
||||||
|
};
|
||||||
|
Ok(match entry {
|
||||||
|
Entry::History(entry) => {
|
||||||
|
canonicalize_history_entry(session_id, segment_id, line_index, entry)
|
||||||
|
}
|
||||||
|
Entry::Current(entry) => entry,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Entry {
|
||||||
|
History(LegacyHistoryLogEntry),
|
||||||
|
Current(LogEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_metadata(
|
||||||
|
segment_id: SegmentId,
|
||||||
|
line_index: usize,
|
||||||
|
item_index: usize,
|
||||||
|
) -> LoggedSessionHistoryMetadata {
|
||||||
|
let mut identity = Vec::with_capacity(32);
|
||||||
|
identity.extend_from_slice(segment_id.as_bytes());
|
||||||
|
identity.extend_from_slice(&(line_index as u64).to_be_bytes());
|
||||||
|
identity.extend_from_slice(&(item_index as u64).to_be_bytes());
|
||||||
|
LoggedSessionHistoryMetadata {
|
||||||
|
entry_id: LoggedSessionHistoryEntryId(format!(
|
||||||
|
"l-{}",
|
||||||
|
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, identity)
|
||||||
|
)),
|
||||||
|
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||||
|
derivation: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonicalize_history_entry(
|
||||||
|
_session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
line_index: usize,
|
||||||
|
entry: LegacyHistoryLogEntry,
|
||||||
|
) -> LogEntry {
|
||||||
|
match entry {
|
||||||
|
LegacyHistoryLogEntry::SegmentStart {
|
||||||
|
ts,
|
||||||
|
session_id,
|
||||||
|
system_prompt,
|
||||||
|
config,
|
||||||
|
history,
|
||||||
|
forked_from,
|
||||||
|
compacted_from,
|
||||||
|
} => LogEntry::AnnotatedSegmentStart {
|
||||||
|
ts,
|
||||||
|
session_id,
|
||||||
|
system_prompt,
|
||||||
|
config,
|
||||||
|
history: history
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(item_index, item)| LoggedHistoryEntry {
|
||||||
|
item,
|
||||||
|
metadata: legacy_metadata(segment_id, line_index, item_index),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
forked_from,
|
||||||
|
compacted_from,
|
||||||
|
},
|
||||||
|
LegacyHistoryLogEntry::UserInput {
|
||||||
|
ts,
|
||||||
|
segments,
|
||||||
|
extensions,
|
||||||
|
} => LogEntry::AnnotatedUserInput {
|
||||||
|
ts,
|
||||||
|
history: vec![LoggedHistoryEntry {
|
||||||
|
item: LoggedItem::from(agen::Item::user_message(Segment::flatten_to_text(
|
||||||
|
&segments,
|
||||||
|
))),
|
||||||
|
metadata: legacy_metadata(segment_id, line_index, 0),
|
||||||
|
}],
|
||||||
|
segments,
|
||||||
|
extensions,
|
||||||
|
},
|
||||||
|
LegacyHistoryLogEntry::AssistantItem { ts, item } => LogEntry::AnnotatedAssistantItem {
|
||||||
|
ts,
|
||||||
|
entry: LoggedHistoryEntry {
|
||||||
|
item,
|
||||||
|
metadata: legacy_metadata(segment_id, line_index, 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
LegacyHistoryLogEntry::ToolResult { ts, item } => LogEntry::AnnotatedToolResult {
|
||||||
|
ts,
|
||||||
|
entry: LoggedHistoryEntry {
|
||||||
|
item,
|
||||||
|
metadata: legacy_metadata(segment_id, line_index, 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
LegacyHistoryLogEntry::SystemItem { ts, item } => LogEntry::AnnotatedSystemItem {
|
||||||
|
ts,
|
||||||
|
entry: LoggedSystemHistoryEntry {
|
||||||
|
item,
|
||||||
|
metadata: legacy_metadata(segment_id, line_index, 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,14 +26,16 @@
|
|||||||
//! let (session_id, segment_id) = create_segment(&store, SegmentStartState {
|
//! let (session_id, segment_id) = create_segment(&store, SegmentStartState {
|
||||||
//! system_prompt: None,
|
//! system_prompt: None,
|
||||||
//! config: &config,
|
//! config: &config,
|
||||||
//! history: &[],
|
//! history: Vec::new(),
|
||||||
//! })?;
|
//! })?;
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
pub mod event_trace;
|
pub mod event_trace;
|
||||||
pub mod fs_store;
|
pub mod fs_store;
|
||||||
pub mod history;
|
pub mod history;
|
||||||
|
mod legacy_session_log;
|
||||||
pub mod logged_item;
|
pub mod logged_item;
|
||||||
|
pub mod public_snapshot;
|
||||||
pub mod segment;
|
pub mod segment;
|
||||||
pub mod segment_log;
|
pub mod segment_log;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
@@ -48,11 +50,11 @@ pub use fs_store::FsStore;
|
|||||||
pub use history::{
|
pub use history::{
|
||||||
LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
|
LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
|
||||||
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry,
|
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry,
|
||||||
LoggedWorkerSubject, legacy_logged_history, legacy_segment_history,
|
LoggedWorkerSubject,
|
||||||
};
|
};
|
||||||
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
||||||
pub use segment::{
|
pub use segment::{
|
||||||
SegmentStartState, append_entry, append_system_item, classify_history_item,
|
SegmentStartState, append_entry, append_system_item, classify_logged_history_entry,
|
||||||
create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork,
|
create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork,
|
||||||
fork_at, restore, restore_by_segment, save_config_changed, save_delta, save_extension,
|
fork_at, restore, restore_by_segment, save_config_changed, save_delta, save_extension,
|
||||||
save_run_completed, save_run_errored, save_turn_end, save_usage, save_user_input,
|
save_run_completed, save_run_errored, save_turn_end, save_usage, save_user_input,
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
use base64::{
|
||||||
|
Engine as _,
|
||||||
|
engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD},
|
||||||
|
};
|
||||||
|
use protocol::{
|
||||||
|
Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole, SessionSnapshot,
|
||||||
|
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole,
|
||||||
|
LoggedSessionHistoryOrigin, SessionId, SystemItem,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Project a complete current-segment log. A valid segment starts with one
|
||||||
|
/// canonical annotated SegmentStart record; malformed partial input uses the
|
||||||
|
/// nil session only to keep the public failure projection deterministic.
|
||||||
|
pub fn project_current_session_snapshot(log: &[LogEntry]) -> SessionSnapshot {
|
||||||
|
let session_id = log.iter().find_map(|entry| match entry {
|
||||||
|
LogEntry::AnnotatedSegmentStart { session_id, .. } => Some(*session_id),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
project_session_snapshot(session_id.unwrap_or_else(SessionId::nil), log)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project the current durable segment into the only public session-history
|
||||||
|
/// representation. Append-log records remain an internal persistence format.
|
||||||
|
pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> SessionSnapshot {
|
||||||
|
let mut session_key = session_id;
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
|
||||||
|
for (log_index, record) in log.iter().enumerate() {
|
||||||
|
match record {
|
||||||
|
LogEntry::AnnotatedSegmentStart {
|
||||||
|
ts,
|
||||||
|
session_id,
|
||||||
|
history,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
session_key = *session_id;
|
||||||
|
entries.clear();
|
||||||
|
extend_history(&mut entries, history, None, *ts);
|
||||||
|
}
|
||||||
|
LogEntry::AnnotatedUserInput {
|
||||||
|
ts,
|
||||||
|
segments,
|
||||||
|
history,
|
||||||
|
..
|
||||||
|
} => extend_history(&mut entries, history, Some(segments), *ts),
|
||||||
|
LogEntry::AnnotatedAssistantItem { ts, entry }
|
||||||
|
| LogEntry::AnnotatedToolResult { ts, entry } => {
|
||||||
|
if let Some(data) = project_item(&entry.item) {
|
||||||
|
entries.push(history_entry(entry, *ts, data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LogEntry::AnnotatedSystemItem { ts, entry } => entries.push(system_entry(
|
||||||
|
&entry.item,
|
||||||
|
entry.metadata.entry_id.0.clone(),
|
||||||
|
*ts,
|
||||||
|
provenance(&entry.metadata.origin),
|
||||||
|
derivation_ids(entry),
|
||||||
|
)),
|
||||||
|
LogEntry::RunErrored { ts, message, .. } => entries.push(legacy_entry(
|
||||||
|
&session_key,
|
||||||
|
log_index,
|
||||||
|
0,
|
||||||
|
*ts,
|
||||||
|
SessionSnapshotEntryData::RunError {
|
||||||
|
message: message.clone(),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
// Run checkpoints, configuration, usage, and extension state are
|
||||||
|
// controller/storage authority rather than committed conversation.
|
||||||
|
LogEntry::Invoke { .. }
|
||||||
|
| LogEntry::TurnEnd { .. }
|
||||||
|
| LogEntry::RunCompleted { .. }
|
||||||
|
| LogEntry::ActiveRunCheckpoint { .. }
|
||||||
|
| LogEntry::PausedTurnAbandoned { .. }
|
||||||
|
| LogEntry::ConfigChanged { .. }
|
||||||
|
| LogEntry::LlmUsage { .. }
|
||||||
|
| LogEntry::Extension { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionSnapshot { entries }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extend_history(
|
||||||
|
output: &mut Vec<SessionSnapshotEntry>,
|
||||||
|
history: &[LoggedHistoryEntry],
|
||||||
|
input_segments: Option<&Vec<Segment>>,
|
||||||
|
timestamp: u64,
|
||||||
|
) {
|
||||||
|
let mut attached_segments = false;
|
||||||
|
for entry in history {
|
||||||
|
let data = if !attached_segments
|
||||||
|
&& input_segments.is_some()
|
||||||
|
&& matches!(
|
||||||
|
&entry.item,
|
||||||
|
LoggedItem::Message {
|
||||||
|
role: LoggedRole::User,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
attached_segments = true;
|
||||||
|
SessionSnapshotEntryData::UserInput {
|
||||||
|
segments: input_segments.cloned().unwrap_or_default(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let Some(data) = project_item(&entry.item) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
data
|
||||||
|
};
|
||||||
|
output.push(history_entry(entry, timestamp, data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history_entry(
|
||||||
|
entry: &LoggedHistoryEntry,
|
||||||
|
timestamp: u64,
|
||||||
|
data: SessionSnapshotEntryData,
|
||||||
|
) -> SessionSnapshotEntry {
|
||||||
|
SessionSnapshotEntry {
|
||||||
|
entry_id: entry.metadata.entry_id.0.clone(),
|
||||||
|
timestamp,
|
||||||
|
provenance: provenance(&entry.metadata.origin),
|
||||||
|
derived_from: entry
|
||||||
|
.metadata
|
||||||
|
.derivation
|
||||||
|
.as_ref()
|
||||||
|
.map(|derivation| {
|
||||||
|
derivation
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.map(|source| source.0.clone())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default(),
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derivation_ids(entry: &crate::LoggedSystemHistoryEntry) -> Vec<String> {
|
||||||
|
entry
|
||||||
|
.metadata
|
||||||
|
.derivation
|
||||||
|
.as_ref()
|
||||||
|
.map(|derivation| {
|
||||||
|
derivation
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.map(|source| source.0.clone())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_entry(
|
||||||
|
session_key: &SessionId,
|
||||||
|
log_index: usize,
|
||||||
|
item_index: usize,
|
||||||
|
timestamp: u64,
|
||||||
|
data: SessionSnapshotEntryData,
|
||||||
|
) -> SessionSnapshotEntry {
|
||||||
|
SessionSnapshotEntry {
|
||||||
|
entry_id: legacy_entry_id(session_key, log_index, item_index),
|
||||||
|
timestamp,
|
||||||
|
provenance: SessionEntryProvenance::LegacyUnknown,
|
||||||
|
derived_from: Vec::new(),
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_entry_id(session_key: &SessionId, log_index: usize, item_index: usize) -> String {
|
||||||
|
let mut identity = Vec::with_capacity(32);
|
||||||
|
identity.extend_from_slice(session_key.as_bytes());
|
||||||
|
identity.extend_from_slice(&(log_index as u64).to_be_bytes());
|
||||||
|
identity.extend_from_slice(&(item_index as u64).to_be_bytes());
|
||||||
|
format!("l-{}", URL_SAFE_NO_PAD.encode(identity))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance {
|
||||||
|
match origin {
|
||||||
|
LoggedSessionHistoryOrigin::HumanInput { .. } => SessionEntryProvenance::HumanInput,
|
||||||
|
LoggedSessionHistoryOrigin::WorkerInput { .. } => SessionEntryProvenance::WorkerInput,
|
||||||
|
LoggedSessionHistoryOrigin::FlowInstruction { .. } => {
|
||||||
|
SessionEntryProvenance::FlowInstruction
|
||||||
|
}
|
||||||
|
LoggedSessionHistoryOrigin::BackendInstruction { .. } => {
|
||||||
|
SessionEntryProvenance::BackendInstruction
|
||||||
|
}
|
||||||
|
LoggedSessionHistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
|
||||||
|
LoggedSessionHistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
|
||||||
|
LoggedSessionHistoryOrigin::DerivedSummary => SessionEntryProvenance::DerivedSummary,
|
||||||
|
LoggedSessionHistoryOrigin::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_item(item: &LoggedItem) -> Option<SessionSnapshotEntryData> {
|
||||||
|
match item {
|
||||||
|
LoggedItem::Message { role, content } => {
|
||||||
|
let role = match role {
|
||||||
|
LoggedRole::User => SessionMessageRole::User,
|
||||||
|
LoggedRole::Assistant => SessionMessageRole::Assistant,
|
||||||
|
// System prompts and instruction history never cross the public
|
||||||
|
// snapshot boundary. Typed SystemItems have separate records.
|
||||||
|
LoggedRole::System => return None,
|
||||||
|
};
|
||||||
|
Some(SessionSnapshotEntryData::Message {
|
||||||
|
role,
|
||||||
|
content: content
|
||||||
|
.iter()
|
||||||
|
.map(|part| match part {
|
||||||
|
LoggedContentPart::Text { text } => {
|
||||||
|
SessionContentPart::Text { text: text.clone() }
|
||||||
|
}
|
||||||
|
LoggedContentPart::Refusal { refusal } => SessionContentPart::Refusal {
|
||||||
|
refusal: refusal.clone(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
LoggedItem::ToolCall {
|
||||||
|
call_id,
|
||||||
|
name,
|
||||||
|
arguments,
|
||||||
|
} => Some(SessionSnapshotEntryData::ToolCall {
|
||||||
|
call_id: call_id.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
arguments: arguments.clone(),
|
||||||
|
}),
|
||||||
|
LoggedItem::ToolResult {
|
||||||
|
call_id,
|
||||||
|
summary,
|
||||||
|
content,
|
||||||
|
is_error,
|
||||||
|
attachments,
|
||||||
|
..
|
||||||
|
} => Some(SessionSnapshotEntryData::ToolResult {
|
||||||
|
call_id: call_id.clone(),
|
||||||
|
summary: summary.clone(),
|
||||||
|
content: content.clone(),
|
||||||
|
is_error: *is_error,
|
||||||
|
attachments: attachments
|
||||||
|
.iter()
|
||||||
|
.map(|attachment| match attachment {
|
||||||
|
crate::logged_item::LoggedAttachment::Image { mime_type, data } => {
|
||||||
|
SessionToolAttachment {
|
||||||
|
media_type: mime_type.clone(),
|
||||||
|
data_base64: BASE64.encode(data),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}),
|
||||||
|
// Hidden model reasoning is never observable.
|
||||||
|
LoggedItem::Reasoning { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn system_entry(
|
||||||
|
item: &SystemItem,
|
||||||
|
entry_id: String,
|
||||||
|
timestamp: u64,
|
||||||
|
provenance: SessionEntryProvenance,
|
||||||
|
derived_from: Vec<String>,
|
||||||
|
) -> SessionSnapshotEntry {
|
||||||
|
let mut data = serde_json::to_value(item).ok();
|
||||||
|
if let Some(serde_json::Value::Object(object)) = data.as_mut() {
|
||||||
|
object.remove("prompt_provenance");
|
||||||
|
}
|
||||||
|
let item_kind = data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|value| value.get("kind"))
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.unwrap_or("system_item")
|
||||||
|
.to_owned();
|
||||||
|
SessionSnapshotEntry {
|
||||||
|
entry_id,
|
||||||
|
timestamp,
|
||||||
|
provenance,
|
||||||
|
derived_from,
|
||||||
|
data: SessionSnapshotEntryData::SystemItem {
|
||||||
|
item_kind,
|
||||||
|
content: item.history_text(),
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use agen::llm_client::RequestConfig;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
LoggedHistoryDerivation, LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata,
|
||||||
|
LoggedWorkerSubject,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_projection_is_stable_and_hides_reasoning_and_system_prompts() {
|
||||||
|
let session_id = crate::new_session_id();
|
||||||
|
let log = vec![LogEntry::AnnotatedSegmentStart {
|
||||||
|
ts: 1,
|
||||||
|
session_id,
|
||||||
|
system_prompt: None,
|
||||||
|
config: RequestConfig::default(),
|
||||||
|
history: vec![
|
||||||
|
LoggedItem::Message {
|
||||||
|
role: LoggedRole::System,
|
||||||
|
content: vec![LoggedContentPart::Text {
|
||||||
|
text: "secret prompt".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
LoggedItem::Reasoning {
|
||||||
|
text: "secret reasoning".into(),
|
||||||
|
summary: Vec::new(),
|
||||||
|
encrypted_content: None,
|
||||||
|
signature: None,
|
||||||
|
},
|
||||||
|
LoggedItem::Message {
|
||||||
|
role: LoggedRole::Assistant,
|
||||||
|
content: vec![LoggedContentPart::Text {
|
||||||
|
text: "visible".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| LoggedHistoryEntry {
|
||||||
|
item,
|
||||||
|
metadata: LoggedSessionHistoryMetadata {
|
||||||
|
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||||
|
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||||
|
derivation: None,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let first = project_session_snapshot(session_id, &log);
|
||||||
|
let second = project_session_snapshot(session_id, &log);
|
||||||
|
assert_eq!(first, second);
|
||||||
|
assert_eq!(first.entries.len(), 1);
|
||||||
|
assert_eq!(first.entries[0].timestamp, 1);
|
||||||
|
assert_eq!(
|
||||||
|
first.entries[0].provenance,
|
||||||
|
SessionEntryProvenance::LegacyUnknown
|
||||||
|
);
|
||||||
|
let json = serde_json::to_string(&first).unwrap();
|
||||||
|
assert!(!json.contains("secret prompt"));
|
||||||
|
assert!(!json.contains("secret reasoning"));
|
||||||
|
assert!(json.contains("visible"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn annotated_user_input_attaches_segments_to_first_user_role_entry_for_any_origin() {
|
||||||
|
let session_id = crate::new_session_id();
|
||||||
|
let segments = vec![Segment::Text {
|
||||||
|
content: "normal submit".into(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
for origin in [
|
||||||
|
LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||||
|
LoggedSessionHistoryOrigin::FlowInstruction {
|
||||||
|
selector: "builtin:coder-review".into(),
|
||||||
|
definition_id: "flow-definition".into(),
|
||||||
|
definition_revision: 7,
|
||||||
|
instance_id: "flow-instance".into(),
|
||||||
|
state_id: "implement".into(),
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let user_entry_id = LoggedSessionHistoryEntryId::new();
|
||||||
|
let source_entry_id = LoggedSessionHistoryEntryId::new();
|
||||||
|
let log = vec![
|
||||||
|
LogEntry::AnnotatedSegmentStart {
|
||||||
|
ts: 1,
|
||||||
|
session_id,
|
||||||
|
system_prompt: None,
|
||||||
|
config: RequestConfig::default(),
|
||||||
|
history: Vec::new(),
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: None,
|
||||||
|
},
|
||||||
|
LogEntry::AnnotatedUserInput {
|
||||||
|
ts: 2,
|
||||||
|
segments: segments.clone(),
|
||||||
|
history: vec![
|
||||||
|
LoggedHistoryEntry {
|
||||||
|
item: LoggedItem::Message {
|
||||||
|
role: LoggedRole::System,
|
||||||
|
content: vec![LoggedContentPart::Text {
|
||||||
|
text: "flow instruction".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
metadata: LoggedSessionHistoryMetadata {
|
||||||
|
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||||
|
origin: LoggedSessionHistoryOrigin::FlowInstruction {
|
||||||
|
selector: "builtin:coder-review".into(),
|
||||||
|
definition_id: "flow-definition".into(),
|
||||||
|
definition_revision: 7,
|
||||||
|
instance_id: "flow-instance".into(),
|
||||||
|
state_id: "implement".into(),
|
||||||
|
},
|
||||||
|
derivation: None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
LoggedHistoryEntry {
|
||||||
|
item: LoggedItem::Message {
|
||||||
|
role: LoggedRole::User,
|
||||||
|
content: vec![LoggedContentPart::Text {
|
||||||
|
text: "normal submit".into(),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
metadata: LoggedSessionHistoryMetadata {
|
||||||
|
entry_id: user_entry_id.clone(),
|
||||||
|
origin: origin.clone(),
|
||||||
|
derivation: Some(LoggedHistoryDerivation {
|
||||||
|
sources: vec![source_entry_id.clone()],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
extensions: Vec::new(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let snapshot = project_current_session_snapshot(&log);
|
||||||
|
assert_eq!(snapshot.entries.len(), 1);
|
||||||
|
assert_eq!(snapshot.entries[0].entry_id, user_entry_id.0);
|
||||||
|
assert_eq!(snapshot.entries[0].provenance, provenance(&origin));
|
||||||
|
assert_eq!(snapshot.entries[0].derived_from, vec![source_entry_id.0]);
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.entries[0].data,
|
||||||
|
SessionSnapshotEntryData::UserInput {
|
||||||
|
segments: segments.clone(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn annotated_projection_preserves_identity_and_provenance() {
|
||||||
|
let session_id = crate::new_session_id();
|
||||||
|
let metadata = LoggedSessionHistoryMetadata {
|
||||||
|
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||||
|
origin: LoggedSessionHistoryOrigin::ModelOutput {
|
||||||
|
worker: LoggedWorkerSubject {
|
||||||
|
workspace_id: None,
|
||||||
|
runtime_id: None,
|
||||||
|
worker_id: "worker".into(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
derivation: None,
|
||||||
|
};
|
||||||
|
let expected_id = metadata.entry_id.0.clone();
|
||||||
|
let log = vec![LogEntry::AnnotatedSegmentStart {
|
||||||
|
ts: 1,
|
||||||
|
session_id,
|
||||||
|
system_prompt: None,
|
||||||
|
config: RequestConfig::default(),
|
||||||
|
history: vec![LoggedHistoryEntry {
|
||||||
|
item: LoggedItem::Message {
|
||||||
|
role: LoggedRole::Assistant,
|
||||||
|
content: vec![LoggedContentPart::Text { text: "ok".into() }],
|
||||||
|
},
|
||||||
|
metadata,
|
||||||
|
}],
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: None,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let snapshot = project_session_snapshot(session_id, &log);
|
||||||
|
assert_eq!(snapshot.entries[0].entry_id, expected_id);
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.entries[0].provenance,
|
||||||
|
SessionEntryProvenance::ModelOutput
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,9 @@
|
|||||||
//! The caller (typically Worker) holds the Engine directly and calls these
|
//! The caller (typically Worker) holds the Engine directly and calls these
|
||||||
//! functions after state-mutating operations.
|
//! functions after state-mutating operations.
|
||||||
|
|
||||||
use crate::logged_item::{LoggedItem, to_logged};
|
|
||||||
use crate::segment_log::{self, LogEntry, SegmentOrigin};
|
use crate::segment_log::{self, LogEntry, SegmentOrigin};
|
||||||
use crate::store::{Store, StoreError};
|
use crate::store::{Store, StoreError};
|
||||||
use crate::system_item::SystemItem;
|
use crate::{LoggedHistoryEntry, LoggedSystemHistoryEntry, SegmentId, SessionId};
|
||||||
use crate::{SegmentId, SessionId};
|
|
||||||
use agen::EngineResult;
|
use agen::EngineResult;
|
||||||
use agen::llm_client::RequestConfig;
|
use agen::llm_client::RequestConfig;
|
||||||
use agen::llm_client::types::Item;
|
use agen::llm_client::types::Item;
|
||||||
@@ -18,7 +16,7 @@ use protocol::Segment;
|
|||||||
pub struct SegmentStartState<'a> {
|
pub struct SegmentStartState<'a> {
|
||||||
pub system_prompt: Option<&'a str>,
|
pub system_prompt: Option<&'a str>,
|
||||||
pub config: &'a RequestConfig,
|
pub config: &'a RequestConfig,
|
||||||
pub history: &'a [Item],
|
pub history: Vec<LoggedHistoryEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new session + initial segment, writing the initial
|
/// Create a new session + initial segment, writing the initial
|
||||||
@@ -44,12 +42,12 @@ pub fn create_segment_with_ids(
|
|||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
state: SegmentStartState<'_>,
|
state: SegmentStartState<'_>,
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
let entry = LogEntry::SegmentStart {
|
let entry = LogEntry::AnnotatedSegmentStart {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
session_id,
|
session_id,
|
||||||
system_prompt: state.system_prompt.map(String::from),
|
system_prompt: state.system_prompt.map(String::from),
|
||||||
config: state.config.clone(),
|
config: state.config.clone(),
|
||||||
history: to_logged(state.history),
|
history: state.history.to_vec(),
|
||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
};
|
};
|
||||||
@@ -70,12 +68,12 @@ pub fn create_compacted_segment(
|
|||||||
source_turn_count: usize,
|
source_turn_count: usize,
|
||||||
) -> Result<SegmentId, StoreError> {
|
) -> Result<SegmentId, StoreError> {
|
||||||
let segment_id = crate::new_segment_id();
|
let segment_id = crate::new_segment_id();
|
||||||
let entry = LogEntry::SegmentStart {
|
let entry = LogEntry::AnnotatedSegmentStart {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
session_id: source_session_id,
|
session_id: source_session_id,
|
||||||
system_prompt: state.system_prompt.map(String::from),
|
system_prompt: state.system_prompt.map(String::from),
|
||||||
config: state.config.clone(),
|
config: state.config.clone(),
|
||||||
history: to_logged(state.history),
|
history: state.history.to_vec(),
|
||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: Some(SegmentOrigin {
|
compacted_from: Some(SegmentOrigin {
|
||||||
segment_id: source_segment_id,
|
segment_id: source_segment_id,
|
||||||
@@ -154,12 +152,12 @@ pub fn ensure_head_or_fork(
|
|||||||
}
|
}
|
||||||
let source_segment_id = *segment_id;
|
let source_segment_id = *segment_id;
|
||||||
let fork_id = crate::new_segment_id();
|
let fork_id = crate::new_segment_id();
|
||||||
let entry = LogEntry::SegmentStart {
|
let entry = LogEntry::AnnotatedSegmentStart {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
session_id,
|
session_id,
|
||||||
system_prompt: state.system_prompt.map(String::from),
|
system_prompt: state.system_prompt.map(String::from),
|
||||||
config: state.config.clone(),
|
config: state.config.clone(),
|
||||||
history: to_logged(state.history),
|
history: state.history.to_vec(),
|
||||||
forked_from: Some(SegmentOrigin {
|
forked_from: Some(SegmentOrigin {
|
||||||
segment_id: source_segment_id,
|
segment_id: source_segment_id,
|
||||||
at_turn_index,
|
at_turn_index,
|
||||||
@@ -183,8 +181,9 @@ pub fn save_user_input(
|
|||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
segments: Vec<Segment>,
|
segments: Vec<Segment>,
|
||||||
|
history: Vec<LoggedHistoryEntry>,
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
save_user_input_with_extensions(store, session_id, segment_id, segments, Vec::new())
|
save_user_input_with_extensions(store, session_id, segment_id, segments, history, Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically persist one typed user submission and Runtime-owned session
|
/// Atomically persist one typed user submission and Runtime-owned session
|
||||||
@@ -194,15 +193,17 @@ pub fn save_user_input_with_extensions(
|
|||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
segments: Vec<Segment>,
|
segments: Vec<Segment>,
|
||||||
|
history: Vec<LoggedHistoryEntry>,
|
||||||
extensions: Vec<segment_log::SessionExtension>,
|
extensions: Vec<segment_log::SessionExtension>,
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
append_entry(
|
append_entry(
|
||||||
store,
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
segment_id,
|
segment_id,
|
||||||
LogEntry::UserInput {
|
LogEntry::AnnotatedUserInput {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
segments,
|
segments,
|
||||||
|
history,
|
||||||
extensions,
|
extensions,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -220,64 +221,57 @@ pub fn save_delta(
|
|||||||
store: &impl Store,
|
store: &impl Store,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
new_items: &[Item],
|
new_items: &[LoggedHistoryEntry],
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
if new_items.is_empty() {
|
if new_items.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let ts = segment_log::now_millis();
|
let ts = segment_log::now_millis();
|
||||||
for item in new_items {
|
for entry in new_items {
|
||||||
|
let item = Item::from(entry.item.clone());
|
||||||
if item.is_user_message() {
|
if item.is_user_message() {
|
||||||
// Already persisted by save_user_input at submit time.
|
// Already persisted by save_user_input at submit time.
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let entry = classify_history_item(item, ts);
|
let entry = classify_logged_history_entry(entry.clone(), ts);
|
||||||
append_entry(store, session_id, segment_id, entry)?;
|
append_entry(store, session_id, segment_id, entry)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map one history item to its singular `LogEntry` form. Used by the
|
/// Map one annotated history entry to its singular `LogEntry` form. Used by
|
||||||
/// fallback `save_delta` path and the controller's worker-callback
|
/// the fallback `save_delta` path and the controller's worker-callback
|
||||||
/// classifier so write classification lives in one place.
|
/// classifier so write classification lives in one place without discarding
|
||||||
pub fn classify_history_item(item: &Item, ts: u64) -> LogEntry {
|
/// identity or provenance.
|
||||||
|
/// Map one already-annotated history entry to its singular canonical record
|
||||||
|
/// without changing its identity or provenance.
|
||||||
|
pub fn classify_logged_history_entry(entry: LoggedHistoryEntry, ts: u64) -> LogEntry {
|
||||||
|
let item = Item::from(entry.item.clone());
|
||||||
if item.is_tool_result() {
|
if item.is_tool_result() {
|
||||||
LogEntry::ToolResult {
|
LogEntry::AnnotatedToolResult { ts, entry }
|
||||||
ts,
|
|
||||||
item: LoggedItem::from(item),
|
|
||||||
}
|
|
||||||
} else if item.is_assistant_message() || item.is_tool_call() || item.is_reasoning() {
|
|
||||||
LogEntry::AssistantItem {
|
|
||||||
ts,
|
|
||||||
item: LoggedItem::from(item),
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Defensive: anything else (future Item kinds) routes through
|
// Assistant messages, tool calls, reasoning, and future non-user
|
||||||
// AssistantItem rather than getting silently dropped.
|
// items all use the assistant-side canonical record.
|
||||||
LogEntry::AssistantItem {
|
LogEntry::AnnotatedAssistantItem { ts, entry }
|
||||||
ts,
|
|
||||||
item: LoggedItem::from(item),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append a single typed system item as `LogEntry::SystemItem`. Helper
|
/// Append one typed system item and its history metadata as a canonical
|
||||||
/// for the Worker-side interceptor commit path; mirrors the per-item
|
/// `LogEntry::AnnotatedSystemItem`.
|
||||||
/// commit shape used for assistant / tool result entries.
|
|
||||||
pub fn append_system_item(
|
pub fn append_system_item(
|
||||||
store: &impl Store,
|
store: &impl Store,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
item: SystemItem,
|
entry: LoggedSystemHistoryEntry,
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
append_entry(
|
append_entry(
|
||||||
store,
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
segment_id,
|
segment_id,
|
||||||
LogEntry::SystemItem {
|
LogEntry::AnnotatedSystemItem {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
item,
|
entry,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -430,12 +424,12 @@ pub fn fork(
|
|||||||
) -> Result<(SessionId, SegmentId), StoreError> {
|
) -> Result<(SessionId, SegmentId), StoreError> {
|
||||||
let session_id = crate::new_session_id();
|
let session_id = crate::new_session_id();
|
||||||
let fork_id = crate::new_segment_id();
|
let fork_id = crate::new_segment_id();
|
||||||
let entry = LogEntry::SegmentStart {
|
let entry = LogEntry::AnnotatedSegmentStart {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
session_id,
|
session_id,
|
||||||
system_prompt: state.system_prompt.map(String::from),
|
system_prompt: state.system_prompt.map(String::from),
|
||||||
config: state.config.clone(),
|
config: state.config.clone(),
|
||||||
history: to_logged(state.history),
|
history: state.history.to_vec(),
|
||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
};
|
};
|
||||||
@@ -470,7 +464,7 @@ pub fn fork_at(
|
|||||||
// segment), before any turn completes.
|
// segment), before any turn completes.
|
||||||
entries
|
entries
|
||||||
.iter()
|
.iter()
|
||||||
.position(|e| !matches!(e, LogEntry::SegmentStart { .. }))
|
.position(|e| !matches!(e, LogEntry::AnnotatedSegmentStart { .. }))
|
||||||
.unwrap_or(entries.len())
|
.unwrap_or(entries.len())
|
||||||
} else {
|
} else {
|
||||||
entries
|
entries
|
||||||
@@ -482,12 +476,12 @@ pub fn fork_at(
|
|||||||
let state = segment_log::collect_state(&entries[..cut]);
|
let state = segment_log::collect_state(&entries[..cut]);
|
||||||
|
|
||||||
let fork_id = crate::new_segment_id();
|
let fork_id = crate::new_segment_id();
|
||||||
let entry = LogEntry::SegmentStart {
|
let entry = LogEntry::AnnotatedSegmentStart {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
session_id: source_session_id,
|
session_id: source_session_id,
|
||||||
system_prompt: state.system_prompt,
|
system_prompt: state.system_prompt,
|
||||||
config: state.config,
|
config: state.config,
|
||||||
history: to_logged(&state.history),
|
history: state.annotated_history,
|
||||||
forked_from: Some(SegmentOrigin {
|
forked_from: Some(SegmentOrigin {
|
||||||
segment_id: source_id,
|
segment_id: source_id,
|
||||||
at_turn_index,
|
at_turn_index,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::history::{LoggedHistoryEntry, LoggedSystemHistoryEntry};
|
use crate::history::{LoggedHistoryEntry, LoggedSystemHistoryEntry};
|
||||||
use crate::logged_item::LoggedItem;
|
use crate::logged_item::LoggedItem;
|
||||||
use crate::system_item::SystemItem;
|
|
||||||
|
|
||||||
/// A single segment log entry, serialized as one JSONL line.
|
/// A single segment log entry, serialized as one JSONL line.
|
||||||
///
|
///
|
||||||
@@ -50,28 +49,7 @@ impl SessionExtension {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum LogEntry {
|
pub enum LogEntry {
|
||||||
/// Segment start. Always the first entry in a segment log.
|
/// Canonical segment seed. Retained entries keep their stable logical
|
||||||
/// For forked segments, `history` contains the seed state from the parent.
|
|
||||||
SegmentStart {
|
|
||||||
ts: u64,
|
|
||||||
/// Session this segment belongs to. Compaction / fork inherits
|
|
||||||
/// the source segment's session_id; only fresh "new conversation"
|
|
||||||
/// segments mint a new session_id.
|
|
||||||
session_id: crate::SessionId,
|
|
||||||
system_prompt: Option<String>,
|
|
||||||
config: RequestConfig,
|
|
||||||
history: Vec<LoggedItem>,
|
|
||||||
/// Origin: forked from a sibling segment at a specific turn boundary.
|
|
||||||
/// The referenced segment is guaranteed to share `session_id`.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
forked_from: Option<SegmentOrigin>,
|
|
||||||
/// Origin: compacted from a sibling segment at a specific turn boundary.
|
|
||||||
/// The referenced segment is guaranteed to share `session_id`.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
compacted_from: Option<SegmentOrigin>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Schema-v2 segment seed. Retained entries keep their stable logical
|
|
||||||
/// identity and origin across fork/compaction/restore.
|
/// identity and origin across fork/compaction/restore.
|
||||||
AnnotatedSegmentStart {
|
AnnotatedSegmentStart {
|
||||||
ts: u64,
|
ts: u64,
|
||||||
@@ -105,22 +83,7 @@ pub enum LogEntry {
|
|||||||
/// restore conservatively instead of re-running a dangling tool call.
|
/// restore conservatively instead of re-running a dangling tool call.
|
||||||
Invoke { ts: u64, trigger: InvokeKind },
|
Invoke { ts: u64, trigger: InvokeKind },
|
||||||
|
|
||||||
/// User input accepted at submit time. Carries the original typed
|
/// Canonical user submission with its exact model-visible entries. Typed
|
||||||
/// `Vec<Segment>` so clients can re-render typed atoms (paste chips,
|
|
||||||
/// file refs) on segment restore.
|
|
||||||
/// Replay flattens these into a `Item::user_message` for the worker
|
|
||||||
/// history; the worker layer never sees segments directly.
|
|
||||||
UserInput {
|
|
||||||
ts: u64,
|
|
||||||
segments: Vec<Segment>,
|
|
||||||
/// Typed durable state committed atomically with this input record.
|
|
||||||
/// Runtime-owned Flow invocation uses this to avoid a Backend-instance
|
|
||||||
/// commit that can get ahead of Worker history.
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
extensions: Vec<SessionExtension>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Schema-v2 user submission with its exact model-visible entries. Typed
|
|
||||||
/// Flow instructions and caller-attributed input remain separate entries.
|
/// Flow instructions and caller-attributed input remain separate entries.
|
||||||
AnnotatedUserInput {
|
AnnotatedUserInput {
|
||||||
ts: u64,
|
ts: u64,
|
||||||
@@ -130,35 +93,19 @@ pub enum LogEntry {
|
|||||||
history: Vec<LoggedHistoryEntry>,
|
history: Vec<LoggedHistoryEntry>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Schema-v2 model output and metadata committed as one journal record.
|
/// Canonical model output and metadata committed as one journal record.
|
||||||
AnnotatedAssistantItem { ts: u64, entry: LoggedHistoryEntry },
|
AnnotatedAssistantItem { ts: u64, entry: LoggedHistoryEntry },
|
||||||
|
|
||||||
/// One assistant-side item appended to history — assistant message,
|
/// Canonical tool output and metadata committed as one journal record.
|
||||||
/// reasoning, or tool call. Singular: one entry per history item so
|
|
||||||
/// the wire-side `Event::*` lane and on-disk LogEntry stay 1:1.
|
|
||||||
AssistantItem { ts: u64, item: LoggedItem },
|
|
||||||
|
|
||||||
/// Schema-v2 tool output and metadata committed as one journal record.
|
|
||||||
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
|
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
|
||||||
|
|
||||||
/// One tool-execution result appended to history.
|
/// Canonical typed system event and model-visible metadata committed
|
||||||
ToolResult { ts: u64, item: LoggedItem },
|
|
||||||
|
|
||||||
/// Schema-v2 typed system event and model-visible metadata committed
|
|
||||||
/// together.
|
/// together.
|
||||||
AnnotatedSystemItem {
|
AnnotatedSystemItem {
|
||||||
ts: u64,
|
ts: u64,
|
||||||
entry: LoggedSystemHistoryEntry,
|
entry: LoggedSystemHistoryEntry,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// One typed agent-injected system item: notification, child-Worker
|
|
||||||
/// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each
|
|
||||||
/// `SystemItem` carries kind metadata that the LLM
|
|
||||||
/// itself never sees (the LLM gets `Item::system_message` with the
|
|
||||||
/// item's denormalised `body`), but live clients and replay paths
|
|
||||||
/// dispatch on `kind` for typed rendering.
|
|
||||||
SystemItem { ts: u64, item: SystemItem },
|
|
||||||
|
|
||||||
/// Turn boundary. Records the turn count after increment.
|
/// Turn boundary. Records the turn count after increment.
|
||||||
TurnEnd { ts: u64, turn_count: usize },
|
TurnEnd { ts: u64, turn_count: usize },
|
||||||
|
|
||||||
@@ -260,6 +207,10 @@ pub struct RestoredState {
|
|||||||
pub system_prompt: Option<String>,
|
pub system_prompt: Option<String>,
|
||||||
pub config: RequestConfig,
|
pub config: RequestConfig,
|
||||||
pub history: Vec<Item>,
|
pub history: Vec<Item>,
|
||||||
|
/// Canonical persisted history with stable identity and provenance. This is
|
||||||
|
/// the authority for rewrites, forks, and annotated restore; `history` is
|
||||||
|
/// retained as the model-facing item projection.
|
||||||
|
pub annotated_history: Vec<LoggedHistoryEntry>,
|
||||||
pub turn_count: usize,
|
pub turn_count: usize,
|
||||||
/// AgentTurns consumed by the active paused/yielded logical run.
|
/// AgentTurns consumed by the active paused/yielded logical run.
|
||||||
pub active_run_turn_count: Option<usize>,
|
pub active_run_turn_count: Option<usize>,
|
||||||
@@ -276,7 +227,7 @@ pub struct RestoredState {
|
|||||||
/// session-store は domain を不透明扱いし、各ドメインが自前で fold する。
|
/// session-store は domain を不透明扱いし、各ドメインが自前で fold する。
|
||||||
pub extensions: Vec<(String, serde_json::Value)>,
|
pub extensions: Vec<(String, serde_json::Value)>,
|
||||||
/// User submissions in original typed form, in submit order.
|
/// User submissions in original typed form, in submit order.
|
||||||
/// One entry per `LogEntry::UserInput`; the K-th entry corresponds to
|
/// One entry per `LogEntry::AnnotatedUserInput`; the K-th entry corresponds to
|
||||||
/// the K-th `Item::user_message` derived during replay (modulo
|
/// the K-th `Item::user_message` derived during replay (modulo
|
||||||
/// pre-compaction history seeded via `SegmentStart.history`, whose
|
/// pre-compaction history seeded via `SegmentStart.history`, whose
|
||||||
/// original segments are not preserved). Used by clients to re-render
|
/// original segments are not preserved). Used by clients to re-render
|
||||||
@@ -291,6 +242,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
|||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
config: RequestConfig::default(),
|
config: RequestConfig::default(),
|
||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
|
annotated_history: Vec::new(),
|
||||||
turn_count: 0,
|
turn_count: 0,
|
||||||
active_run_turn_count: None,
|
active_run_turn_count: None,
|
||||||
last_run_interrupted: false,
|
last_run_interrupted: false,
|
||||||
@@ -304,18 +256,6 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
|||||||
state.entries_count += 1;
|
state.entries_count += 1;
|
||||||
|
|
||||||
match entry {
|
match entry {
|
||||||
LogEntry::SegmentStart {
|
|
||||||
session_id,
|
|
||||||
system_prompt,
|
|
||||||
config,
|
|
||||||
history,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
state.session_id = Some(*session_id);
|
|
||||||
state.system_prompt = system_prompt.clone();
|
|
||||||
state.config = config.clone();
|
|
||||||
state.history = history.iter().cloned().map(Item::from).collect();
|
|
||||||
}
|
|
||||||
LogEntry::AnnotatedSegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
session_id,
|
session_id,
|
||||||
system_prompt,
|
system_prompt,
|
||||||
@@ -326,6 +266,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
|||||||
state.session_id = Some(*session_id);
|
state.session_id = Some(*session_id);
|
||||||
state.system_prompt = system_prompt.clone();
|
state.system_prompt = system_prompt.clone();
|
||||||
state.config = config.clone();
|
state.config = config.clone();
|
||||||
|
state.annotated_history = history.clone();
|
||||||
state.history = history
|
state.history = history
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -338,26 +279,13 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
|||||||
state.last_run_interrupted = true;
|
state.last_run_interrupted = true;
|
||||||
state.active_run_turn_count = Some(0);
|
state.active_run_turn_count = Some(0);
|
||||||
}
|
}
|
||||||
LogEntry::UserInput {
|
|
||||||
segments,
|
|
||||||
extensions,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let text = Segment::flatten_to_text(segments);
|
|
||||||
state.history.push(Item::user_message(text));
|
|
||||||
state.user_segments.push(segments.clone());
|
|
||||||
state.extensions.extend(
|
|
||||||
extensions
|
|
||||||
.iter()
|
|
||||||
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
LogEntry::AnnotatedUserInput {
|
LogEntry::AnnotatedUserInput {
|
||||||
segments,
|
segments,
|
||||||
extensions,
|
extensions,
|
||||||
history,
|
history,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
|
state.annotated_history.extend(history.iter().cloned());
|
||||||
state
|
state
|
||||||
.history
|
.history
|
||||||
.extend(history.iter().cloned().map(|entry| Item::from(entry.item)));
|
.extend(history.iter().cloned().map(|entry| Item::from(entry.item)));
|
||||||
@@ -370,20 +298,16 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
|
|||||||
}
|
}
|
||||||
LogEntry::AnnotatedAssistantItem { entry, .. }
|
LogEntry::AnnotatedAssistantItem { entry, .. }
|
||||||
| LogEntry::AnnotatedToolResult { entry, .. } => {
|
| LogEntry::AnnotatedToolResult { entry, .. } => {
|
||||||
|
state.annotated_history.push(entry.clone());
|
||||||
state.history.push(Item::from(entry.item.clone()));
|
state.history.push(Item::from(entry.item.clone()));
|
||||||
}
|
}
|
||||||
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||||
|
state.annotated_history.push(LoggedHistoryEntry {
|
||||||
|
item: LoggedItem::from(entry.item.to_history_item()),
|
||||||
|
metadata: entry.metadata.clone(),
|
||||||
|
});
|
||||||
state.history.push(entry.item.to_history_item());
|
state.history.push(entry.item.to_history_item());
|
||||||
}
|
}
|
||||||
LogEntry::AssistantItem { item, .. } => {
|
|
||||||
state.history.push(Item::from(item.clone()));
|
|
||||||
}
|
|
||||||
LogEntry::ToolResult { item, .. } => {
|
|
||||||
state.history.push(Item::from(item.clone()));
|
|
||||||
}
|
|
||||||
LogEntry::SystemItem { item, .. } => {
|
|
||||||
state.history.push(item.to_history_item());
|
|
||||||
}
|
|
||||||
LogEntry::TurnEnd { turn_count, .. } => {
|
LogEntry::TurnEnd { turn_count, .. } => {
|
||||||
if let Some(active_turn_count) = &mut state.active_run_turn_count {
|
if let Some(active_turn_count) = &mut state.active_run_turn_count {
|
||||||
*active_turn_count += turn_count.saturating_sub(state.turn_count);
|
*active_turn_count += turn_count.saturating_sub(state.turn_count);
|
||||||
@@ -465,6 +389,20 @@ pub fn now_millis() -> u64 {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::{
|
||||||
|
LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn annotated(item: Item) -> LoggedHistoryEntry {
|
||||||
|
LoggedHistoryEntry {
|
||||||
|
item: LoggedItem::from(item),
|
||||||
|
metadata: LoggedSessionHistoryMetadata {
|
||||||
|
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||||
|
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||||
|
derivation: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_empty() {
|
fn replay_empty() {
|
||||||
@@ -476,12 +414,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_segment_start_sets_initial_state() {
|
fn replay_segment_start_sets_initial_state() {
|
||||||
let state = collect_state(&[LogEntry::SegmentStart {
|
let state = collect_state(&[LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: Some("You are helpful.".into()),
|
system_prompt: Some("You are helpful.".into()),
|
||||||
config: RequestConfig::default().with_max_tokens(1024),
|
config: RequestConfig::default().with_max_tokens(1024),
|
||||||
history: vec![Item::user_message("seed").into()],
|
history: vec![annotated(Item::user_message("seed"))],
|
||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
}]);
|
}]);
|
||||||
@@ -494,7 +432,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_full_turn() {
|
fn replay_full_turn() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -503,14 +441,15 @@ mod tests {
|
|||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
},
|
},
|
||||||
LogEntry::UserInput {
|
LogEntry::AnnotatedUserInput {
|
||||||
ts: 2000,
|
ts: 2000,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![Segment::text("Hello")],
|
segments: vec![Segment::text("Hello")],
|
||||||
|
history: vec![annotated(Item::user_message("Hello"))],
|
||||||
},
|
},
|
||||||
LogEntry::AssistantItem {
|
LogEntry::AnnotatedAssistantItem {
|
||||||
ts: 3000,
|
ts: 3000,
|
||||||
item: Item::assistant_message("Hi!").into(),
|
entry: annotated(Item::assistant_message("Hi!")),
|
||||||
},
|
},
|
||||||
LogEntry::TurnEnd {
|
LogEntry::TurnEnd {
|
||||||
ts: 3100,
|
ts: 3100,
|
||||||
@@ -531,7 +470,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_incomplete_invoke_is_interrupted() {
|
fn replay_incomplete_invoke_is_interrupted() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -544,14 +483,15 @@ mod tests {
|
|||||||
ts: 2000,
|
ts: 2000,
|
||||||
trigger: InvokeKind::UserSend,
|
trigger: InvokeKind::UserSend,
|
||||||
},
|
},
|
||||||
LogEntry::UserInput {
|
LogEntry::AnnotatedUserInput {
|
||||||
ts: 2001,
|
ts: 2001,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![Segment::text("run a tool")],
|
segments: vec![Segment::text("run a tool")],
|
||||||
|
history: vec![annotated(Item::user_message("run a tool"))],
|
||||||
},
|
},
|
||||||
LogEntry::AssistantItem {
|
LogEntry::AnnotatedAssistantItem {
|
||||||
ts: 3000,
|
ts: 3000,
|
||||||
item: Item::tool_call("call_1", "side_effect", "{}").into(),
|
entry: annotated(Item::tool_call("call_1", "side_effect", "{}")),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -561,7 +501,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_with_tool_calls() {
|
fn replay_with_tool_calls() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -570,22 +510,27 @@ mod tests {
|
|||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
},
|
},
|
||||||
LogEntry::UserInput {
|
LogEntry::AnnotatedUserInput {
|
||||||
ts: 2000,
|
ts: 2000,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![Segment::text("Check weather")],
|
segments: vec![Segment::text("Check weather")],
|
||||||
|
history: vec![annotated(Item::user_message("Check weather"))],
|
||||||
},
|
},
|
||||||
LogEntry::AssistantItem {
|
LogEntry::AnnotatedAssistantItem {
|
||||||
ts: 3000,
|
ts: 3000,
|
||||||
item: Item::tool_call("call_1", "get_weather", r#"{"city":"Tokyo"}"#).into(),
|
entry: annotated(Item::tool_call(
|
||||||
|
"call_1",
|
||||||
|
"get_weather",
|
||||||
|
r#"{"city":"Tokyo"}"#,
|
||||||
|
)),
|
||||||
},
|
},
|
||||||
LogEntry::ToolResult {
|
LogEntry::AnnotatedToolResult {
|
||||||
ts: 3500,
|
ts: 3500,
|
||||||
item: Item::tool_result("call_1", "Sunny, 25C").into(),
|
entry: annotated(Item::tool_result("call_1", "Sunny, 25C")),
|
||||||
},
|
},
|
||||||
LogEntry::AssistantItem {
|
LogEntry::AnnotatedAssistantItem {
|
||||||
ts: 4000,
|
ts: 4000,
|
||||||
item: Item::assistant_message("It's sunny in Tokyo!").into(),
|
entry: annotated(Item::assistant_message("It's sunny in Tokyo!")),
|
||||||
},
|
},
|
||||||
LogEntry::TurnEnd {
|
LogEntry::TurnEnd {
|
||||||
ts: 4100,
|
ts: 4100,
|
||||||
@@ -599,9 +544,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_restores_durable_tool_image_detail() {
|
fn replay_restores_durable_tool_image_detail() {
|
||||||
let entry = LogEntry::ToolResult {
|
let entry = LogEntry::AnnotatedToolResult {
|
||||||
ts: 3500,
|
ts: 3500,
|
||||||
item: Item::tool_result_item_with_attachments(
|
entry: annotated(Item::tool_result_item_with_attachments(
|
||||||
"call_image",
|
"call_image",
|
||||||
"attached",
|
"attached",
|
||||||
None,
|
None,
|
||||||
@@ -609,8 +554,7 @@ mod tests {
|
|||||||
vec![agen::tool::Attachment::Image(
|
vec![agen::tool::Attachment::Image(
|
||||||
agen::tool::ImageAttachment::new("image/png", b"durable-image".to_vec()),
|
agen::tool::ImageAttachment::new("image/png", b"durable-image".to_vec()),
|
||||||
)],
|
)],
|
||||||
)
|
)),
|
||||||
.into(),
|
|
||||||
};
|
};
|
||||||
let persisted = serde_json::to_string(&entry).unwrap();
|
let persisted = serde_json::to_string(&entry).unwrap();
|
||||||
let restored_entry: LogEntry = serde_json::from_str(&persisted).unwrap();
|
let restored_entry: LogEntry = serde_json::from_str(&persisted).unwrap();
|
||||||
@@ -630,7 +574,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_config_changed() {
|
fn replay_config_changed() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -650,7 +594,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_llm_usage_appends_to_usage_history() {
|
fn replay_llm_usage_appends_to_usage_history() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -659,10 +603,11 @@ mod tests {
|
|||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
},
|
},
|
||||||
LogEntry::UserInput {
|
LogEntry::AnnotatedUserInput {
|
||||||
ts: 2000,
|
ts: 2000,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![Segment::text("hi")],
|
segments: vec![Segment::text("hi")],
|
||||||
|
history: vec![annotated(Item::user_message("hi"))],
|
||||||
},
|
},
|
||||||
LogEntry::LlmUsage {
|
LogEntry::LlmUsage {
|
||||||
ts: 2100,
|
ts: 2100,
|
||||||
@@ -672,9 +617,9 @@ mod tests {
|
|||||||
cache_write_tokens: 0,
|
cache_write_tokens: 0,
|
||||||
output_tokens: 10,
|
output_tokens: 10,
|
||||||
},
|
},
|
||||||
LogEntry::AssistantItem {
|
LogEntry::AnnotatedAssistantItem {
|
||||||
ts: 2200,
|
ts: 2200,
|
||||||
item: Item::assistant_message("yo").into(),
|
entry: annotated(Item::assistant_message("yo")),
|
||||||
},
|
},
|
||||||
LogEntry::LlmUsage {
|
LogEntry::LlmUsage {
|
||||||
ts: 3100,
|
ts: 3100,
|
||||||
@@ -698,7 +643,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_without_llm_usage_keeps_usage_history_empty() {
|
fn replay_without_llm_usage_keeps_usage_history_empty() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -707,10 +652,11 @@ mod tests {
|
|||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
},
|
},
|
||||||
LogEntry::UserInput {
|
LogEntry::AnnotatedUserInput {
|
||||||
ts: 2000,
|
ts: 2000,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![Segment::text("hi")],
|
segments: vec![Segment::text("hi")],
|
||||||
|
history: vec![annotated(Item::user_message("hi"))],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
assert!(state.usage_history.is_empty());
|
assert!(state.usage_history.is_empty());
|
||||||
@@ -771,7 +717,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_invoke_marker_only_mutates_interrupted_state() {
|
fn replay_invoke_marker_only_mutates_interrupted_state() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 0,
|
ts: 0,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -784,10 +730,11 @@ mod tests {
|
|||||||
ts: 100,
|
ts: 100,
|
||||||
trigger: InvokeKind::UserSend,
|
trigger: InvokeKind::UserSend,
|
||||||
},
|
},
|
||||||
LogEntry::UserInput {
|
LogEntry::AnnotatedUserInput {
|
||||||
ts: 101,
|
ts: 101,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![Segment::text("hi")],
|
segments: vec![Segment::text("hi")],
|
||||||
|
history: vec![annotated(Item::user_message("hi"))],
|
||||||
},
|
},
|
||||||
LogEntry::TurnEnd {
|
LogEntry::TurnEnd {
|
||||||
ts: 200,
|
ts: 200,
|
||||||
@@ -806,7 +753,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_paused_turn_abandoned_clears_interrupted_marker() {
|
fn replay_paused_turn_abandoned_clears_interrupted_marker() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 0,
|
ts: 0,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -830,7 +777,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_restores_active_run_budget_across_compaction_checkpoint() {
|
fn replay_restores_active_run_budget_across_compaction_checkpoint() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 0,
|
ts: 0,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -861,7 +808,7 @@ mod tests {
|
|||||||
}))
|
}))
|
||||||
.expect("legacy run-completed entry");
|
.expect("legacy run-completed entry");
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 0,
|
ts: 0,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -924,7 +871,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn replay_extension_collects_domain_payload_pairs() {
|
fn replay_extension_collects_domain_payload_pairs() {
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -983,9 +930,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn user_input_extensions_restore_with_the_same_committed_input() {
|
fn user_input_extensions_restore_with_the_same_committed_input() {
|
||||||
let segments = vec![Segment::text("Flow instructions"), Segment::text("Ticket")];
|
let segments = vec![Segment::text("Flow instructions"), Segment::text("Ticket")];
|
||||||
let entry = LogEntry::UserInput {
|
let entry = LogEntry::AnnotatedUserInput {
|
||||||
ts: 9999,
|
ts: 9999,
|
||||||
segments: segments.clone(),
|
segments: segments.clone(),
|
||||||
|
history: vec![annotated(Item::user_message(Segment::flatten_to_text(
|
||||||
|
&segments,
|
||||||
|
)))],
|
||||||
extensions: vec![SessionExtension::new(
|
extensions: vec![SessionExtension::new(
|
||||||
"flow.runtime.v1",
|
"flow.runtime.v1",
|
||||||
serde_json::json!({ "state": "implement", "revision": 0 }),
|
serde_json::json!({ "state": "implement", "revision": 0 }),
|
||||||
@@ -1000,7 +950,7 @@ mod tests {
|
|||||||
assert_eq!(state.extensions[0].1["state"], "implement");
|
assert_eq!(state.extensions[0].1["state"], "implement");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mixed segments survive a JSON round-trip through `LogEntry::UserInput`,
|
/// Mixed segments survive a JSON round-trip through `LogEntry::AnnotatedUserInput`,
|
||||||
/// and `collect_state` derives `Item::user_message` from the flattened
|
/// and `collect_state` derives `Item::user_message` from the flattened
|
||||||
/// text while preserving the original segments separately. This covers
|
/// text while preserving the original segments separately. This covers
|
||||||
/// the segments → flatten → Item replay path from the ticket.
|
/// the segments → flatten → Item replay path from the ticket.
|
||||||
@@ -1020,16 +970,19 @@ mod tests {
|
|||||||
path: "src/main.rs".into(),
|
path: "src/main.rs".into(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let entry = LogEntry::UserInput {
|
let entry = LogEntry::AnnotatedUserInput {
|
||||||
ts: 4242,
|
ts: 4242,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: segments.clone(),
|
segments: segments.clone(),
|
||||||
|
history: vec![annotated(Item::user_message(Segment::flatten_to_text(
|
||||||
|
&segments,
|
||||||
|
)))],
|
||||||
};
|
};
|
||||||
// JSON round-trip preserves the variant byte-for-byte.
|
// JSON round-trip preserves the variant byte-for-byte.
|
||||||
let json = serde_json::to_string(&entry).unwrap();
|
let json = serde_json::to_string(&entry).unwrap();
|
||||||
let parsed: LogEntry = serde_json::from_str(&json).unwrap();
|
let parsed: LogEntry = serde_json::from_str(&json).unwrap();
|
||||||
let state = collect_state(&[
|
let state = collect_state(&[
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 1,
|
ts: 1,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//! `kind` instead of parsing text prefixes like `[Notification] …` or
|
//! `kind` instead of parsing text prefixes like `[Notification] …` or
|
||||||
//! `[File: …]`.
|
//! `[File: …]`.
|
||||||
//!
|
//!
|
||||||
//! Persisted as the payload of [`crate::LogEntry::SystemItem`] (one
|
//! Persisted as the payload of [`crate::LogEntry::AnnotatedSystemItem`] (one
|
||||||
//! entry per item), and broadcast live as the payload of
|
//! entry per item), and broadcast live as the payload of
|
||||||
//! `Event::SystemItem` on the wire.
|
//! `Event::SystemItem` on the wire.
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
const SESSION_SCHEMA_VERSION: u32 = 2;
|
const SESSION_SCHEMA_VERSION: u32 = 3;
|
||||||
|
const PREVIOUS_SESSION_SCHEMA_VERSION: u32 = 2;
|
||||||
const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1;
|
const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1;
|
||||||
const SESSION_FILE: &str = "session.json";
|
const SESSION_FILE: &str = "session.json";
|
||||||
const SEGMENTS_DIR: &str = "segments";
|
const SEGMENTS_DIR: &str = "segments";
|
||||||
@@ -47,9 +48,15 @@ impl WorkerSessionStore {
|
|||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
let mut manifest: SessionManifest = serde_json::from_slice(&bytes)?;
|
let mut manifest: SessionManifest = serde_json::from_slice(&bytes)?;
|
||||||
match manifest.schema_version {
|
match manifest.schema_version {
|
||||||
SESSION_SCHEMA_VERSION => {}
|
SESSION_SCHEMA_VERSION => {
|
||||||
LEGACY_SESSION_SCHEMA_VERSION => {
|
validate_canonical_segment_logs(&root)?;
|
||||||
validate_legacy_segment_logs(&root)?;
|
}
|
||||||
|
PREVIOUS_SESSION_SCHEMA_VERSION | LEGACY_SESSION_SCHEMA_VERSION => {
|
||||||
|
migrate_segment_logs_to_v3(
|
||||||
|
&root,
|
||||||
|
manifest.session_id,
|
||||||
|
manifest.schema_version,
|
||||||
|
)?;
|
||||||
manifest.schema_version = SESSION_SCHEMA_VERSION;
|
manifest.schema_version = SESSION_SCHEMA_VERSION;
|
||||||
atomic_write_json(&root.join(SESSION_FILE), &manifest)?;
|
atomic_write_json(&root.join(SESSION_FILE), &manifest)?;
|
||||||
}
|
}
|
||||||
@@ -144,6 +151,41 @@ impl WorkerSessionStore {
|
|||||||
.join(format!("{segment_id}.trace.jsonl"))
|
.join(format!("{segment_id}.trace.jsonl"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn append_log_entry(&self, path: &Path, entry: &LogEntry) -> Result<(), StoreError> {
|
||||||
|
let _guard = self
|
||||||
|
.append_lock
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| std::io::Error::other("Worker Session append lock was poisoned"))?;
|
||||||
|
let mut file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.append(true)
|
||||||
|
.open(path)?;
|
||||||
|
let committed_len = truncate_uncommitted_tail(&mut file)?;
|
||||||
|
file.seek(SeekFrom::Start(0))?;
|
||||||
|
let mut existing = Vec::new();
|
||||||
|
file.read_to_end(&mut existing)?;
|
||||||
|
parse_jsonl::<LogEntry>(&existing)?;
|
||||||
|
let line = serde_json::to_string(entry)?;
|
||||||
|
let mut record = Vec::with_capacity(line.len() + 1);
|
||||||
|
record.extend_from_slice(line.as_bytes());
|
||||||
|
record.push(b'\n');
|
||||||
|
if let Err(write_error) = file.write_all(&record) {
|
||||||
|
return match file.set_len(committed_len) {
|
||||||
|
Ok(()) => Err(write_error.into()),
|
||||||
|
Err(rollback_error) => Err(std::io::Error::new(
|
||||||
|
rollback_error.kind(),
|
||||||
|
format!(
|
||||||
|
"session append failed ({write_error}) and rollback failed: {rollback_error}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
|
fn append_line(&self, path: &Path, line: &str) -> Result<(), StoreError> {
|
||||||
let _guard = self
|
let _guard = self
|
||||||
.append_lock
|
.append_lock
|
||||||
@@ -183,7 +225,7 @@ impl Store for WorkerSessionStore {
|
|||||||
entry: &LogEntry,
|
entry: &LogEntry,
|
||||||
) -> Result<(), StoreError> {
|
) -> Result<(), StoreError> {
|
||||||
self.ensure_session(session_id, true)?;
|
self.ensure_session(session_id, true)?;
|
||||||
self.append_line(&self.log_path(segment_id), &serde_json::to_string(entry)?)
|
self.append_log_entry(&self.log_path(segment_id), entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_all(
|
fn read_all(
|
||||||
@@ -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);
|
let segments = root.join(SEGMENTS_DIR);
|
||||||
if !segments.exists() {
|
if !segments.exists() {
|
||||||
return Ok(());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
let mut paths = Vec::new();
|
||||||
for entry in fs::read_dir(&segments)? {
|
for entry in fs::read_dir(&segments)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
|
let metadata = fs::symlink_metadata(&path)?;
|
||||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||||
continue;
|
return Err(StoreError::Corrupt {
|
||||||
|
line: 0,
|
||||||
|
message: format!("non-UTF-8 Worker Session segment path: {}", path.display()),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
if !name.ends_with(".jsonl") || name.ends_with(".trace.jsonl") {
|
if name.ends_with(".trace.jsonl") || name.starts_with('.') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let contents = fs::read_to_string(&path)?;
|
if !name.ends_with(".jsonl") {
|
||||||
for (line_index, line) in contents.lines().enumerate() {
|
continue;
|
||||||
if line.trim().is_empty() {
|
}
|
||||||
continue;
|
if !metadata.file_type().is_file() {
|
||||||
}
|
return Err(StoreError::Corrupt {
|
||||||
serde_json::from_str::<LogEntry>(line).map_err(|error| StoreError::Corrupt {
|
line: 0,
|
||||||
line: line_index + 1,
|
|
||||||
message: format!(
|
message: format!(
|
||||||
"cannot migrate legacy Worker Session log {}: {error}",
|
"Worker Session segment is not a regular file: {}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let segment_id =
|
||||||
|
name.trim_end_matches(".jsonl")
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| StoreError::Corrupt {
|
||||||
|
line: 0,
|
||||||
|
message: format!("invalid Worker Session segment name: {name}"),
|
||||||
|
})?;
|
||||||
|
paths.push((segment_id, path));
|
||||||
|
}
|
||||||
|
paths.sort_by_key(|(segment_id, _)| *segment_id);
|
||||||
|
Ok(paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn migrate_segment_logs_to_v3(
|
||||||
|
root: &Path,
|
||||||
|
session_id: SessionId,
|
||||||
|
source_schema_version: u32,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
|
struct MigrationPlan {
|
||||||
|
path: PathBuf,
|
||||||
|
source: Vec<u8>,
|
||||||
|
output: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1 is strictly read-only. Every segment must parse and canonicalize
|
||||||
|
// successfully before the first authoritative byte is replaced.
|
||||||
|
let mut plans = Vec::new();
|
||||||
|
for (segment_id, path) in segment_log_paths(root)? {
|
||||||
|
let source = fs::read(&path)?;
|
||||||
|
let canonical = parse_legacy_jsonl(source_schema_version, session_id, segment_id, &source)
|
||||||
|
.map_err(|error| StoreError::Corrupt {
|
||||||
|
line: 0,
|
||||||
|
message: format!(
|
||||||
|
"cannot migrate Worker Session log {}: {error}",
|
||||||
path.display()
|
path.display()
|
||||||
),
|
),
|
||||||
})?;
|
})?;
|
||||||
|
let mut output = Vec::new();
|
||||||
|
for entry in canonical {
|
||||||
|
serde_json::to_writer(&mut output, &entry)?;
|
||||||
|
output.push(b'\n');
|
||||||
|
}
|
||||||
|
plans.push(MigrationPlan {
|
||||||
|
path,
|
||||||
|
source,
|
||||||
|
output,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fence the complete preflight snapshot before starting phase 2. Session
|
||||||
|
// open is the exclusive restore boundary; this additionally fails closed
|
||||||
|
// if an unexpected writer raced the preflight.
|
||||||
|
for plan in &plans {
|
||||||
|
if fs::read(&plan.path)? != plan.source {
|
||||||
|
return Err(StoreError::Corrupt {
|
||||||
|
line: 0,
|
||||||
|
message: format!(
|
||||||
|
"Worker Session segment changed during migration: {}",
|
||||||
|
plan.path.display()
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for plan in plans {
|
||||||
|
atomic_write_bytes(&plan.path, &plan.output)?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_canonical_segment_logs(root: &Path) -> Result<(), StoreError> {
|
||||||
|
for (_, path) in segment_log_paths(root)? {
|
||||||
|
let _: Vec<LogEntry> = parse_jsonl(&fs::read(&path)?)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_legacy_jsonl(
|
||||||
|
schema_version: u32,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<Vec<LogEntry>, serde_json::Error> {
|
||||||
|
let text = std::str::from_utf8(bytes).map_err(|error| {
|
||||||
|
serde_json::Error::io(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
|
||||||
|
})?;
|
||||||
|
text.lines()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, line)| !line.trim().is_empty())
|
||||||
|
.map(|(line_index, line)| {
|
||||||
|
crate::legacy_session_log::decode_entry(
|
||||||
|
schema_version,
|
||||||
|
line,
|
||||||
|
session_id,
|
||||||
|
segment_id,
|
||||||
|
line_index,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
|
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
|
||||||
let mut bytes = serde_json::to_vec_pretty(value)?;
|
let mut bytes = serde_json::to_vec_pretty(value)?;
|
||||||
bytes.push(b'\n');
|
bytes.push(b'\n');
|
||||||
@@ -418,7 +561,21 @@ fn truncate_uncommitted_tail(file: &mut File) -> std::io::Result<u64> {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{Store, new_segment_id, new_session_id};
|
use crate::{
|
||||||
|
LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata,
|
||||||
|
LoggedSessionHistoryOrigin, Store, new_segment_id, new_session_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn annotated(item: agen::Item) -> LoggedHistoryEntry {
|
||||||
|
LoggedHistoryEntry {
|
||||||
|
item: LoggedItem::from(item),
|
||||||
|
metadata: LoggedSessionHistoryMetadata {
|
||||||
|
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||||
|
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||||
|
derivation: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn canonical_layout_and_single_session_invariant() {
|
fn canonical_layout_and_single_session_invariant() {
|
||||||
@@ -445,7 +602,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 root = tempfile::tempdir().unwrap();
|
||||||
let session_id = new_session_id();
|
let session_id = new_session_id();
|
||||||
let segment_id = new_segment_id();
|
let segment_id = new_segment_id();
|
||||||
@@ -467,7 +624,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn schema_v1_migration_rejects_corrupt_log_before_manifest_update() {
|
fn schema_v1_migration_rejects_corrupt_log_before_v3_manifest_update() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
let session_id = new_session_id();
|
let session_id = new_session_id();
|
||||||
let manifest = SessionManifest {
|
let manifest = SessionManifest {
|
||||||
@@ -492,6 +649,280 @@ mod tests {
|
|||||||
assert_eq!(persisted.schema_version, LEGACY_SESSION_SCHEMA_VERSION);
|
assert_eq!(persisted.schema_version, LEGACY_SESSION_SCHEMA_VERSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_v2_migration_rewrites_legacy_records_with_stable_unknown_provenance() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let session_id = new_session_id();
|
||||||
|
let segment_id = new_segment_id();
|
||||||
|
fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap();
|
||||||
|
atomic_write_json(
|
||||||
|
&root.path().join(SESSION_FILE),
|
||||||
|
&SessionManifest {
|
||||||
|
schema_version: PREVIOUS_SESSION_SCHEMA_VERSION,
|
||||||
|
session_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let source = vec![
|
||||||
|
serde_json::json!({
|
||||||
|
"kind": "segment_start",
|
||||||
|
"ts": 1,
|
||||||
|
"session_id": session_id,
|
||||||
|
"system_prompt": null,
|
||||||
|
"config": agen::llm_client::RequestConfig::default(),
|
||||||
|
"history": [LoggedItem::from(agen::Item::assistant_message("prior"))],
|
||||||
|
"forked_from": null,
|
||||||
|
"compacted_from": null
|
||||||
|
}),
|
||||||
|
serde_json::json!({
|
||||||
|
"kind": "user_input",
|
||||||
|
"ts": 2,
|
||||||
|
"segments": [{ "kind": "text", "content": "hello" }],
|
||||||
|
"extensions": []
|
||||||
|
}),
|
||||||
|
serde_json::json!({
|
||||||
|
"kind": "assistant_item",
|
||||||
|
"ts": 3,
|
||||||
|
"item": LoggedItem::from(agen::Item::assistant_message("reply"))
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
let path = root
|
||||||
|
.path()
|
||||||
|
.join(SEGMENTS_DIR)
|
||||||
|
.join(format!("{segment_id}.jsonl"));
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
for entry in source {
|
||||||
|
serde_json::to_writer(&mut bytes, &entry).unwrap();
|
||||||
|
bytes.push(b'\n');
|
||||||
|
}
|
||||||
|
fs::write(&path, bytes).unwrap();
|
||||||
|
|
||||||
|
let store = WorkerSessionStore::new(root.path()).unwrap();
|
||||||
|
let first = store.read_all(session_id, segment_id).unwrap();
|
||||||
|
assert!(matches!(first[0], LogEntry::AnnotatedSegmentStart { .. }));
|
||||||
|
assert!(matches!(first[1], LogEntry::AnnotatedUserInput { .. }));
|
||||||
|
assert!(matches!(first[2], LogEntry::AnnotatedAssistantItem { .. }));
|
||||||
|
let first_bytes = fs::read(&path).unwrap();
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let reopened = WorkerSessionStore::new(root.path()).unwrap();
|
||||||
|
assert_eq!(fs::read(&path).unwrap(), first_bytes);
|
||||||
|
let snapshot = crate::public_snapshot::project_current_session_snapshot(
|
||||||
|
&reopened.read_all(session_id, segment_id).unwrap(),
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.entries.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
snapshot
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| entry.timestamp)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![1, 2, 3]
|
||||||
|
);
|
||||||
|
assert!(snapshot.entries.iter().all(|entry| {
|
||||||
|
entry.provenance == protocol::SessionEntryProvenance::LegacyUnknown
|
||||||
|
&& entry.entry_id.len() <= 64
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_v2_preflight_keeps_earlier_segments_unchanged_when_later_is_corrupt() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let session_id = new_session_id();
|
||||||
|
let valid_segment = uuid::Uuid::from_u128(1);
|
||||||
|
let corrupt_segment = uuid::Uuid::from_u128(2);
|
||||||
|
fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap();
|
||||||
|
atomic_write_json(
|
||||||
|
&root.path().join(SESSION_FILE),
|
||||||
|
&SessionManifest {
|
||||||
|
schema_version: PREVIOUS_SESSION_SCHEMA_VERSION,
|
||||||
|
session_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let manifest_before = fs::read(root.path().join(SESSION_FILE)).unwrap();
|
||||||
|
|
||||||
|
let valid_path = root
|
||||||
|
.path()
|
||||||
|
.join(SEGMENTS_DIR)
|
||||||
|
.join(format!("{valid_segment}.jsonl"));
|
||||||
|
let valid_entry = serde_json::json!({
|
||||||
|
"kind": "segment_start",
|
||||||
|
"ts": 1,
|
||||||
|
"session_id": session_id,
|
||||||
|
"system_prompt": null,
|
||||||
|
"config": agen::llm_client::RequestConfig::default(),
|
||||||
|
"history": [LoggedItem::from(agen::Item::assistant_message("prior"))],
|
||||||
|
"forked_from": null,
|
||||||
|
"compacted_from": null
|
||||||
|
});
|
||||||
|
let mut valid_bytes = serde_json::to_vec(&valid_entry).unwrap();
|
||||||
|
valid_bytes.push(b'\n');
|
||||||
|
fs::write(&valid_path, &valid_bytes).unwrap();
|
||||||
|
let corrupt_path = root
|
||||||
|
.path()
|
||||||
|
.join(SEGMENTS_DIR)
|
||||||
|
.join(format!("{corrupt_segment}.jsonl"));
|
||||||
|
fs::write(&corrupt_path, b"{not-json}\n").unwrap();
|
||||||
|
let corrupt_before = fs::read(&corrupt_path).unwrap();
|
||||||
|
|
||||||
|
let error = match WorkerSessionStore::new(root.path()) {
|
||||||
|
Ok(_) => panic!("later corrupt segment must fail migration preflight"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
assert!(matches!(error, StoreError::Corrupt { .. }));
|
||||||
|
assert_eq!(fs::read(&valid_path).unwrap(), valid_bytes);
|
||||||
|
assert_eq!(fs::read(&corrupt_path).unwrap(), corrupt_before);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(root.path().join(SESSION_FILE)).unwrap(),
|
||||||
|
manifest_before
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_jsonl_requires_annotations_across_append_rewrite_and_reopen() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let session_id = new_session_id();
|
||||||
|
let segment_id = new_segment_id();
|
||||||
|
let store = WorkerSessionStore::new(root.path()).unwrap();
|
||||||
|
store
|
||||||
|
.create_segment(
|
||||||
|
session_id,
|
||||||
|
segment_id,
|
||||||
|
&[LogEntry::AnnotatedSegmentStart {
|
||||||
|
ts: 1,
|
||||||
|
session_id,
|
||||||
|
system_prompt: None,
|
||||||
|
config: agen::llm_client::RequestConfig::default(),
|
||||||
|
history: vec![annotated(agen::Item::user_message("seed"))],
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: None,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.append(
|
||||||
|
session_id,
|
||||||
|
segment_id,
|
||||||
|
&LogEntry::AnnotatedAssistantItem {
|
||||||
|
ts: 2,
|
||||||
|
entry: annotated(agen::Item::assistant_message("reply")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let before_rewrite = store.read_all(session_id, segment_id).unwrap();
|
||||||
|
store
|
||||||
|
.create_segment(session_id, segment_id, &before_rewrite)
|
||||||
|
.unwrap();
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let reopened = WorkerSessionStore::new(root.path()).unwrap();
|
||||||
|
let restored = reopened.read_all(session_id, segment_id).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&restored).unwrap(),
|
||||||
|
serde_json::to_value(&before_rewrite).unwrap()
|
||||||
|
);
|
||||||
|
for entry in &restored {
|
||||||
|
match entry {
|
||||||
|
LogEntry::AnnotatedSegmentStart { history, .. } => assert!(history.iter().all(
|
||||||
|
|entry| !entry.metadata.entry_id.0.is_empty()
|
||||||
|
&& matches!(
|
||||||
|
entry.metadata.origin,
|
||||||
|
LoggedSessionHistoryOrigin::LegacyUnknown
|
||||||
|
)
|
||||||
|
)),
|
||||||
|
LogEntry::AnnotatedAssistantItem { entry, .. } => {
|
||||||
|
assert!(!entry.metadata.entry_id.0.is_empty());
|
||||||
|
assert!(matches!(
|
||||||
|
entry.metadata.origin,
|
||||||
|
LoggedSessionHistoryOrigin::LegacyUnknown
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let log = fs::read_to_string(reopened.log_path(segment_id)).unwrap();
|
||||||
|
for line in log.lines() {
|
||||||
|
let value: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||||
|
let kind = value["kind"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
!matches!(
|
||||||
|
kind,
|
||||||
|
"segment_start"
|
||||||
|
| "user_input"
|
||||||
|
| "assistant_item"
|
||||||
|
| "tool_result"
|
||||||
|
| "system_item"
|
||||||
|
),
|
||||||
|
"current-schema JSONL contains legacy history record: {kind}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schema_v3_rejects_legacy_records_and_new_writes_are_canonical() {
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let session_id = new_session_id();
|
||||||
|
let segment_id = new_segment_id();
|
||||||
|
let store = WorkerSessionStore::new(root.path()).unwrap();
|
||||||
|
store
|
||||||
|
.create_segment(
|
||||||
|
session_id,
|
||||||
|
segment_id,
|
||||||
|
&[LogEntry::AnnotatedSegmentStart {
|
||||||
|
ts: 1,
|
||||||
|
session_id,
|
||||||
|
system_prompt: None,
|
||||||
|
config: agen::llm_client::RequestConfig::default(),
|
||||||
|
history: vec![annotated(agen::Item::assistant_message("seed"))],
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: None,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.append(
|
||||||
|
session_id,
|
||||||
|
segment_id,
|
||||||
|
&LogEntry::AnnotatedUserInput {
|
||||||
|
ts: 2,
|
||||||
|
segments: vec![protocol::Segment::Text {
|
||||||
|
content: "new".into(),
|
||||||
|
}],
|
||||||
|
history: vec![annotated(agen::Item::user_message("new"))],
|
||||||
|
extensions: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let entries = store.read_all(session_id, segment_id).unwrap();
|
||||||
|
assert!(matches!(entries[0], LogEntry::AnnotatedSegmentStart { .. }));
|
||||||
|
assert!(matches!(entries[1], LogEntry::AnnotatedUserInput { .. }));
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let path = root
|
||||||
|
.path()
|
||||||
|
.join(SEGMENTS_DIR)
|
||||||
|
.join(format!("{segment_id}.jsonl"));
|
||||||
|
let mut file = OpenOptions::new().append(true).open(path).unwrap();
|
||||||
|
serde_json::to_writer(
|
||||||
|
&mut file,
|
||||||
|
&serde_json::json!({
|
||||||
|
"kind": "system_item",
|
||||||
|
"ts": 3,
|
||||||
|
"item": { "kind": "legacy_ignored", "slug": "legacy" }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
file.write_all(b"\n").unwrap();
|
||||||
|
let error = match WorkerSessionStore::new(root.path()) {
|
||||||
|
Ok(_) => panic!("schema v3 must reject a legacy history record"),
|
||||||
|
Err(error) => error,
|
||||||
|
};
|
||||||
|
assert!(matches!(error, StoreError::Corrupt { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reopen_preserves_session_and_segment_ids() {
|
fn reopen_preserves_session_and_segment_ids() {
|
||||||
let root = tempfile::tempdir().unwrap();
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
use agen::EngineResult;
|
use agen::EngineResult;
|
||||||
use agen::llm_client::types::{Item, RequestConfig};
|
use agen::llm_client::types::{Item, RequestConfig};
|
||||||
use session_store::{
|
use session_store::{
|
||||||
FsStore, LogEntry, Store, TraceEntry, collect_state, new_segment_id, new_session_id,
|
FsStore, LogEntry, LoggedHistoryEntry, LoggedItem, LoggedSessionHistoryEntryId,
|
||||||
|
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, Store, TraceEntry, collect_state,
|
||||||
|
new_segment_id, new_session_id,
|
||||||
};
|
};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
|
fn annotated(item: Item) -> LoggedHistoryEntry {
|
||||||
|
LoggedHistoryEntry {
|
||||||
|
item: LoggedItem::from(item),
|
||||||
|
metadata: LoggedSessionHistoryMetadata {
|
||||||
|
entry_id: LoggedSessionHistoryEntryId::new(),
|
||||||
|
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||||
|
derivation: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn nil_session_start(ts: u64, session_id: uuid::Uuid) -> LogEntry {
|
fn nil_session_start(ts: u64, session_id: uuid::Uuid) -> LogEntry {
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts,
|
ts,
|
||||||
session_id,
|
session_id,
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -25,7 +38,7 @@ fn round_trip_write_and_read() {
|
|||||||
let segid = new_segment_id();
|
let segid = new_segment_id();
|
||||||
|
|
||||||
let entries = vec![
|
let entries = vec![
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
system_prompt: Some("You are helpful.".into()),
|
system_prompt: Some("You are helpful.".into()),
|
||||||
@@ -34,14 +47,15 @@ fn round_trip_write_and_read() {
|
|||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
},
|
},
|
||||||
LogEntry::UserInput {
|
LogEntry::AnnotatedUserInput {
|
||||||
ts: 2000,
|
ts: 2000,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![protocol::Segment::text("Hello")],
|
segments: vec![protocol::Segment::text("Hello")],
|
||||||
|
history: vec![annotated(Item::user_message("Hello"))],
|
||||||
},
|
},
|
||||||
LogEntry::AssistantItem {
|
LogEntry::AnnotatedAssistantItem {
|
||||||
ts: 3000,
|
ts: 3000,
|
||||||
item: Item::assistant_message("Hi there!").into(),
|
entry: annotated(Item::assistant_message("Hi there!")),
|
||||||
},
|
},
|
||||||
LogEntry::TurnEnd {
|
LogEntry::TurnEnd {
|
||||||
ts: 3100,
|
ts: 3100,
|
||||||
@@ -79,14 +93,14 @@ fn create_segment_writes_all_entries() {
|
|||||||
let sid = new_session_id();
|
let sid = new_session_id();
|
||||||
let segid = new_segment_id();
|
let segid = new_segment_id();
|
||||||
|
|
||||||
let entries = [LogEntry::SegmentStart {
|
let entries = [LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
config: RequestConfig::default(),
|
config: RequestConfig::default(),
|
||||||
history: vec![
|
history: vec![
|
||||||
Item::user_message("seed").into(),
|
annotated(Item::user_message("seed")),
|
||||||
Item::assistant_message("ok").into(),
|
annotated(Item::assistant_message("ok")),
|
||||||
],
|
],
|
||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
@@ -205,7 +219,7 @@ fn read_entry_count_matches_append_tally() {
|
|||||||
let segid = new_segment_id();
|
let segid = new_segment_id();
|
||||||
|
|
||||||
let entries = [
|
let entries = [
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 1000,
|
ts: 1000,
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -214,10 +228,11 @@ fn read_entry_count_matches_append_tally() {
|
|||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
},
|
},
|
||||||
LogEntry::UserInput {
|
LogEntry::AnnotatedUserInput {
|
||||||
ts: 2000,
|
ts: 2000,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![protocol::Segment::text("Hello")],
|
segments: vec![protocol::Segment::text("Hello")],
|
||||||
|
history: vec![annotated(Item::user_message("Hello"))],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -254,10 +269,11 @@ fn unterminated_utf8_tail_is_ignored_and_replaced_on_append() {
|
|||||||
assert_eq!(store.read_all(sid, segid).unwrap().len(), 1);
|
assert_eq!(store.read_all(sid, segid).unwrap().len(), 1);
|
||||||
assert_eq!(store.read_entry_count(sid, segid).unwrap(), 1);
|
assert_eq!(store.read_entry_count(sid, segid).unwrap(), 1);
|
||||||
|
|
||||||
let next = LogEntry::UserInput {
|
let next = LogEntry::AnnotatedUserInput {
|
||||||
ts: 2,
|
ts: 2,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
segments: vec![protocol::Segment::text("recovered")],
|
segments: vec![protocol::Segment::text("recovered")],
|
||||||
|
history: vec![annotated(Item::user_message("recovered"))],
|
||||||
};
|
};
|
||||||
store.append(sid, segid, &next).unwrap();
|
store.append(sid, segid, &next).unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state};
|
|||||||
// Helpers
|
// Helpers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
fn annotated(items: &[Item]) -> Vec<session_store::LoggedHistoryEntry> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|item| session_store::LoggedHistoryEntry {
|
||||||
|
item: session_store::LoggedItem::from(item),
|
||||||
|
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||||
|
entry_id: session_store::LoggedSessionHistoryEntryId::new(),
|
||||||
|
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||||
|
derivation: None,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn simple_text_events() -> Vec<Event> {
|
fn simple_text_events() -> Vec<Event> {
|
||||||
vec![
|
vec![
|
||||||
Event::text_block_start(0),
|
Event::text_block_start(0),
|
||||||
@@ -144,6 +159,7 @@ async fn run_and_persist(
|
|||||||
session_id,
|
session_id,
|
||||||
segment_id,
|
segment_id,
|
||||||
vec![protocol::Segment::text(input)],
|
vec![protocol::Segment::text(input)],
|
||||||
|
annotated(&[Item::user_message(input)]),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -154,8 +170,8 @@ async fn run_and_persist(
|
|||||||
worker.engine = locked.unlock();
|
worker.engine = locked.unlock();
|
||||||
|
|
||||||
let projected = worker.history();
|
let projected = worker.history();
|
||||||
let new_items = &projected[history_before..];
|
let new_items = annotated(&projected[history_before..]);
|
||||||
session_store::save_delta(store, session_id, segment_id, new_items).unwrap();
|
session_store::save_delta(store, session_id, segment_id, &new_items).unwrap();
|
||||||
session_store::save_turn_end(store, session_id, segment_id, worker.turn_count()).unwrap();
|
session_store::save_turn_end(store, session_id, segment_id, worker.turn_count()).unwrap();
|
||||||
|
|
||||||
match &result {
|
match &result {
|
||||||
@@ -219,7 +235,7 @@ async fn session_run_logs_entries() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: &worker.history(),
|
history: annotated(&worker.history()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -237,7 +253,10 @@ async fn session_run_logs_entries() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// First entry is SegmentStart
|
// First entry is SegmentStart
|
||||||
assert!(matches!(&entries[0], LogEntry::SegmentStart { .. }));
|
assert!(matches!(
|
||||||
|
&entries[0],
|
||||||
|
LogEntry::AnnotatedSegmentStart { .. }
|
||||||
|
));
|
||||||
|
|
||||||
// Has a RunCompleted with Finished
|
// Has a RunCompleted with Finished
|
||||||
let has_finished = entries.iter().any(|e| {
|
let has_finished = entries.iter().any(|e| {
|
||||||
@@ -264,7 +283,7 @@ async fn session_restore_round_trip() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: &worker.history(),
|
history: annotated(&worker.history()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -303,7 +322,7 @@ async fn session_run_with_tool_call() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: &worker.history(),
|
history: annotated(&worker.history()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -314,12 +333,12 @@ async fn session_run_with_tool_call() {
|
|||||||
|
|
||||||
let has_tool_results = entries
|
let has_tool_results = entries
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, LogEntry::ToolResult { .. }));
|
.any(|e| matches!(e, LogEntry::AnnotatedToolResult { .. }));
|
||||||
assert!(has_tool_results, "should have ToolResult entry");
|
assert!(has_tool_results, "should have ToolResult entry");
|
||||||
|
|
||||||
let has_assistant = entries
|
let has_assistant = entries
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, LogEntry::AssistantItem { .. }));
|
.any(|e| matches!(e, LogEntry::AnnotatedAssistantItem { .. }));
|
||||||
assert!(has_assistant, "should have AssistantItem entry");
|
assert!(has_assistant, "should have AssistantItem entry");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +357,7 @@ async fn session_resume_after_pause() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: &worker.history(),
|
history: annotated(&worker.history()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -377,7 +396,7 @@ async fn session_fork_creates_new_session() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: &worker.history(),
|
history: annotated(&worker.history()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -390,7 +409,7 @@ async fn session_fork_creates_new_session() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: &worker.history(),
|
history: annotated(&worker.history()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -399,7 +418,10 @@ async fn session_fork_creates_new_session() {
|
|||||||
// Fork should have a SegmentStart with the current history
|
// Fork should have a SegmentStart with the current history
|
||||||
let fork_entries = store.read_all(fork_sid, fork_segid).unwrap();
|
let fork_entries = store.read_all(fork_sid, fork_segid).unwrap();
|
||||||
assert_eq!(fork_entries.len(), 1);
|
assert_eq!(fork_entries.len(), 1);
|
||||||
assert!(matches!(&fork_entries[0], LogEntry::SegmentStart { .. }));
|
assert!(matches!(
|
||||||
|
&fork_entries[0],
|
||||||
|
LogEntry::AnnotatedSegmentStart { .. }
|
||||||
|
));
|
||||||
|
|
||||||
let fork_state = collect_state(&fork_entries);
|
let fork_state = collect_state(&fork_entries);
|
||||||
assert_eq!(fork_state.session_id, Some(fork_sid));
|
assert_eq!(fork_state.session_id, Some(fork_sid));
|
||||||
@@ -418,7 +440,7 @@ async fn session_fork_at_truncates_within_session() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: &worker.history(),
|
history: annotated(&worker.history()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -445,6 +467,23 @@ async fn session_fork_at_truncates_within_session() {
|
|||||||
.expect("source segment has the matching TurnEnd");
|
.expect("source segment has the matching TurnEnd");
|
||||||
let source_state_at_fork = collect_state(&all_entries[..=turn_end_pos]);
|
let source_state_at_fork = collect_state(&all_entries[..=turn_end_pos]);
|
||||||
assert_eq!(fork_state.history.len(), source_state_at_fork.history.len());
|
assert_eq!(fork_state.history.len(), source_state_at_fork.history.len());
|
||||||
|
assert_eq!(
|
||||||
|
fork_state.annotated_history, source_state_at_fork.annotated_history,
|
||||||
|
"fork_at must preserve every retained history entry identity and provenance",
|
||||||
|
);
|
||||||
|
assert!(fork_state.annotated_history.iter().all(|entry| {
|
||||||
|
!entry.metadata.entry_id.0.is_empty()
|
||||||
|
&& matches!(
|
||||||
|
entry.metadata.origin,
|
||||||
|
session_store::LoggedSessionHistoryOrigin::LegacyUnknown
|
||||||
|
| session_store::LoggedSessionHistoryOrigin::HumanInput { .. }
|
||||||
|
| session_store::LoggedSessionHistoryOrigin::WorkerInput { .. }
|
||||||
|
| session_store::LoggedSessionHistoryOrigin::BackendInstruction { .. }
|
||||||
|
| session_store::LoggedSessionHistoryOrigin::ModelOutput { .. }
|
||||||
|
| session_store::LoggedSessionHistoryOrigin::ToolOutput { .. }
|
||||||
|
| session_store::LoggedSessionHistoryOrigin::DerivedSummary
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
// list_segments should show both source and fork in the same Session.
|
// list_segments should show both source and fork in the same Session.
|
||||||
let segs = store.list_segments(sid).unwrap();
|
let segs = store.list_segments(sid).unwrap();
|
||||||
@@ -463,7 +502,7 @@ async fn session_config_changed_logged() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: &worker.history(),
|
history: annotated(&worker.history()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -496,7 +535,7 @@ async fn session_auto_forks_on_conflict() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker_a.get_system_prompt(),
|
system_prompt: worker_a.get_system_prompt(),
|
||||||
config: worker_a.request_config(),
|
config: worker_a.request_config(),
|
||||||
history: &worker_a.history(),
|
history: annotated(&worker_a.history()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -505,12 +544,14 @@ async fn session_auto_forks_on_conflict() {
|
|||||||
let mut entries_written: usize = 1;
|
let mut entries_written: usize = 1;
|
||||||
|
|
||||||
// Simulate another Worker writing to the same segment behind our back.
|
// Simulate another Worker writing to the same segment behind our back.
|
||||||
let extra_entry = LogEntry::UserInput {
|
session_store::save_user_input(
|
||||||
ts: 9999,
|
&store,
|
||||||
extensions: vec![],
|
sid,
|
||||||
segments: vec![protocol::Segment::text("Interloper")],
|
original_segid,
|
||||||
};
|
vec![protocol::Segment::text("Interloper")],
|
||||||
store.append(sid, original_segid, &extra_entry).unwrap();
|
annotated(&[Item::user_message("Interloper")]),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
// Now the on-disk count exceeds our tally — ensure_head_or_fork should auto-fork.
|
// Now the on-disk count exceeds our tally — ensure_head_or_fork should auto-fork.
|
||||||
session_store::ensure_head_or_fork(
|
session_store::ensure_head_or_fork(
|
||||||
@@ -522,7 +563,7 @@ async fn session_auto_forks_on_conflict() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker_a.get_system_prompt(),
|
system_prompt: worker_a.get_system_prompt(),
|
||||||
config: worker_a.request_config(),
|
config: worker_a.request_config(),
|
||||||
history: &worker_a.history(),
|
history: annotated(&worker_a.history()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -543,7 +584,7 @@ async fn session_auto_forks_on_conflict() {
|
|||||||
// The new segment records its lineage forward via forked_from; the
|
// The new segment records its lineage forward via forked_from; the
|
||||||
// source segment is left immutable (no terminal marker written back).
|
// source segment is left immutable (no terminal marker written back).
|
||||||
match &fork_entries[0] {
|
match &fork_entries[0] {
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
forked_from: Some(origin),
|
forked_from: Some(origin),
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
@@ -563,7 +604,7 @@ async fn session_auto_forks_on_conflict() {
|
|||||||
);
|
);
|
||||||
let has_interloper = original_entries
|
let has_interloper = original_entries
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, LogEntry::UserInput { .. }));
|
.any(|e| matches!(e, LogEntry::AnnotatedUserInput { .. }));
|
||||||
assert!(has_interloper);
|
assert!(has_interloper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,7 +622,7 @@ async fn nested_past_fork_leaves_ancestors_immutable() {
|
|||||||
SegmentStartState {
|
SegmentStartState {
|
||||||
system_prompt: worker.get_system_prompt(),
|
system_prompt: worker.get_system_prompt(),
|
||||||
config: worker.request_config(),
|
config: worker.request_config(),
|
||||||
history: &worker.history(),
|
history: annotated(&worker.history()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -618,7 +659,7 @@ async fn nested_past_fork_leaves_ancestors_immutable() {
|
|||||||
|
|
||||||
// fork2's lineage points at fork1, not the root.
|
// fork2's lineage points at fork1, not the root.
|
||||||
match &store.read_all(sid, fork2).unwrap()[0] {
|
match &store.read_all(sid, fork2).unwrap()[0] {
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
forked_from: Some(origin),
|
forked_from: Some(origin),
|
||||||
..
|
..
|
||||||
} => assert_eq!(origin.segment_id, fork1),
|
} => assert_eq!(origin.segment_id, fork1),
|
||||||
|
|||||||
+173
-192
@@ -765,7 +765,7 @@ impl App {
|
|||||||
|
|
||||||
fn method_for_run(&mut self, segments: Vec<Segment>) -> Method {
|
fn method_for_run(&mut self, segments: Vec<Segment>) -> Method {
|
||||||
// TurnHeader / UserMessage blocks are pushed only after the Worker
|
// TurnHeader / UserMessage blocks are pushed only after the Worker
|
||||||
// emits `Event::UserMessage` from a committed `LogEntry::UserInput`.
|
// emits `Event::UserMessage` from a committed `LogEntry::AnnotatedUserInput`.
|
||||||
// Locally we only clear the input buffer and forward the method,
|
// Locally we only clear the input buffer and forward the method,
|
||||||
// while remembering enough local state to undo the visible submit if
|
// while remembering enough local state to undo the visible submit if
|
||||||
// the accepted run produced no assistant output and was rolled back.
|
// the accepted run produced no assistant output and was rolled back.
|
||||||
@@ -1098,10 +1098,9 @@ impl App {
|
|||||||
self.blocks.push(Block::UserMessage { segments });
|
self.blocks.push(Block::UserMessage { segments });
|
||||||
self.assistant_streaming = false;
|
self.assistant_streaming = false;
|
||||||
}
|
}
|
||||||
Event::SegmentRotated { entry } => {
|
Event::SegmentRotated { session } => {
|
||||||
let retained_run_errors = self.run_error_messages.clone();
|
let retained_run_errors = self.run_error_messages.clone();
|
||||||
self.reset_for_rotation();
|
self.restore_session(&session, self.greeting.clone());
|
||||||
self.apply_log_entry_raw(&entry);
|
|
||||||
for message in retained_run_errors {
|
for message in retained_run_errors {
|
||||||
self.blocks.push(Block::Alert {
|
self.blocks.push(Block::Alert {
|
||||||
level: AlertLevel::Error,
|
level: AlertLevel::Error,
|
||||||
@@ -1408,14 +1407,14 @@ impl App {
|
|||||||
self.latest_memory_worker_event = Some(event.message);
|
self.latest_memory_worker_event = Some(event.message);
|
||||||
}
|
}
|
||||||
Event::Snapshot {
|
Event::Snapshot {
|
||||||
entries,
|
session,
|
||||||
greeting,
|
greeting,
|
||||||
status,
|
status,
|
||||||
in_flight,
|
in_flight,
|
||||||
internal_workers,
|
internal_workers,
|
||||||
} => {
|
} => {
|
||||||
self.rewind_refresh_fence = false;
|
self.rewind_refresh_fence = false;
|
||||||
self.restore_snapshot(&entries, greeting, in_flight);
|
self.restore_snapshot(&session, greeting, in_flight);
|
||||||
self.replace_internal_worker_snapshots(internal_workers);
|
self.replace_internal_worker_snapshots(internal_workers);
|
||||||
self.set_worker_status(status);
|
self.set_worker_status(status);
|
||||||
}
|
}
|
||||||
@@ -1455,11 +1454,11 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::RewindApplied {
|
Event::RewindApplied {
|
||||||
entries,
|
session,
|
||||||
input,
|
input,
|
||||||
summary,
|
summary,
|
||||||
} => {
|
} => {
|
||||||
self.restore_rewind_snapshot(&entries);
|
self.restore_rewind_snapshot(&session);
|
||||||
self.rewind_refresh_fence = true;
|
self.rewind_refresh_fence = true;
|
||||||
let restored_composer = if self.input.is_empty() {
|
let restored_composer = if self.input.is_empty() {
|
||||||
self.input.replace_with_segments(&input);
|
self.input.replace_with_segments(&input);
|
||||||
@@ -2173,7 +2172,7 @@ impl App {
|
|||||||
) -> InternalWorkerView {
|
) -> InternalWorkerView {
|
||||||
let mut app = App::new(snapshot.worker.name.clone());
|
let mut app = App::new(snapshot.worker.name.clone());
|
||||||
app.mode = mode;
|
app.mode = mode;
|
||||||
app.restore_entries(&snapshot.entries, None);
|
app.restore_session(&snapshot.session, None);
|
||||||
app.apply_in_flight_snapshot(snapshot.in_flight);
|
app.apply_in_flight_snapshot(snapshot.in_flight);
|
||||||
app.set_worker_status(snapshot.status);
|
app.set_worker_status(snapshot.status);
|
||||||
if let Some(error) = snapshot.error {
|
if let Some(error) = snapshot.error {
|
||||||
@@ -2254,14 +2253,14 @@ impl App {
|
|||||||
|
|
||||||
fn restore_snapshot(
|
fn restore_snapshot(
|
||||||
&mut self,
|
&mut self,
|
||||||
entries: &[serde_json::Value],
|
session: &protocol::SessionSnapshot,
|
||||||
greeting: protocol::Greeting,
|
greeting: protocol::Greeting,
|
||||||
in_flight: InFlightSnapshot,
|
in_flight: InFlightSnapshot,
|
||||||
) {
|
) {
|
||||||
self.greeting = Some(greeting.clone());
|
self.greeting = Some(greeting.clone());
|
||||||
self.context_window = greeting.context_window;
|
self.context_window = greeting.context_window;
|
||||||
self.session_context_tokens = greeting.context_tokens;
|
self.session_context_tokens = greeting.context_tokens;
|
||||||
self.restore_entries(entries, Some(greeting));
|
self.restore_session(session, Some(greeting));
|
||||||
self.apply_in_flight_snapshot(in_flight);
|
self.apply_in_flight_snapshot(in_flight);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2270,7 +2269,7 @@ impl App {
|
|||||||
/// session tail; always clear/replay from it even if this TUI instance has
|
/// session tail; always clear/replay from it even if this TUI instance has
|
||||||
/// somehow lost connect-time greeting metadata. Skipping the restore in
|
/// somehow lost connect-time greeting metadata. Skipping the restore in
|
||||||
/// that case would leave old post-target output visible after success.
|
/// that case would leave old post-target output visible after success.
|
||||||
fn restore_rewind_snapshot(&mut self, entries: &[serde_json::Value]) {
|
fn restore_rewind_snapshot(&mut self, session: &protocol::SessionSnapshot) {
|
||||||
let greeting = self.greeting.clone().or_else(|| {
|
let greeting = self.greeting.clone().or_else(|| {
|
||||||
self.blocks.iter().find_map(|b| match b {
|
self.blocks.iter().find_map(|b| match b {
|
||||||
Block::Greeting(g) => Some(g.clone()),
|
Block::Greeting(g) => Some(g.clone()),
|
||||||
@@ -2283,7 +2282,7 @@ impl App {
|
|||||||
self.session_context_tokens = greeting.context_tokens;
|
self.session_context_tokens = greeting.context_tokens;
|
||||||
}
|
}
|
||||||
let missing_greeting = greeting.is_none();
|
let missing_greeting = greeting.is_none();
|
||||||
self.restore_entries(entries, greeting);
|
self.restore_session(session, greeting);
|
||||||
if missing_greeting {
|
if missing_greeting {
|
||||||
self.blocks.push(Block::Alert {
|
self.blocks.push(Block::Alert {
|
||||||
level: AlertLevel::Warn,
|
level: AlertLevel::Warn,
|
||||||
@@ -2293,9 +2292,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_entries(
|
fn restore_session(
|
||||||
&mut self,
|
&mut self,
|
||||||
entries: &[serde_json::Value],
|
session: &protocol::SessionSnapshot,
|
||||||
greeting: Option<protocol::Greeting>,
|
greeting: Option<protocol::Greeting>,
|
||||||
) {
|
) {
|
||||||
self.run_error_messages.clear();
|
self.run_error_messages.clear();
|
||||||
@@ -2309,137 +2308,90 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.assistant_streaming = false;
|
self.assistant_streaming = false;
|
||||||
|
|
||||||
for entry in entries {
|
for entry in &session.entries {
|
||||||
self.apply_log_entry_raw(entry);
|
use protocol::{SessionContentPart, SessionMessageRole, SessionSnapshotEntryData};
|
||||||
|
match &entry.data {
|
||||||
|
SessionSnapshotEntryData::UserInput { segments } => {
|
||||||
|
self.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();
|
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.
|
/// Dispatch one `SystemItem` JSON value into the appropriate block.
|
||||||
///
|
///
|
||||||
/// Kind-based routing replaces the old free-text `[Notification]` /
|
/// Kind-based routing replaces the old free-text `[Notification]` /
|
||||||
/// `[File: …]` parsing path: each kind maps directly to a typed
|
/// `[File: …]` parsing path: each kind maps directly to a typed
|
||||||
/// block (`Block::Notify`, `Block::WorkerEvent`, …).
|
/// block (`Block::Notify`, `Block::WorkerEvent`, …).
|
||||||
fn apply_compaction_extension(&mut self, payload: &serde_json::Value) {
|
|
||||||
if payload.get("kind").and_then(|value| value.as_str()) != Some("compaction_block") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
match payload.get("state").and_then(|value| value.as_str()) {
|
|
||||||
Some("running") => {
|
|
||||||
if self.last_streaming_compact_mut().is_none() {
|
|
||||||
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
|
||||||
started_at: Instant::now(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some("done") => {
|
|
||||||
let new_segment_id = payload
|
|
||||||
.get("new_segment_id")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.and_then(|value| value.parse::<uuid::Uuid>().ok())
|
|
||||||
.unwrap_or_else(uuid::Uuid::nil);
|
|
||||||
if let Some(evt) = self.last_streaming_compact_mut() {
|
|
||||||
*evt = CompactEvent::Done {
|
|
||||||
new_segment_id,
|
|
||||||
elapsed_secs: None,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
self.blocks.push(Block::Compact(CompactEvent::Done {
|
|
||||||
new_segment_id,
|
|
||||||
elapsed_secs: None,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some("failed") => {
|
|
||||||
let error = payload
|
|
||||||
.get("error")
|
|
||||||
.and_then(|value| value.as_str())
|
|
||||||
.unwrap_or("compact failed")
|
|
||||||
.to_string();
|
|
||||||
if let Some(evt) = self.last_streaming_compact_mut() {
|
|
||||||
*evt = CompactEvent::Failed {
|
|
||||||
error,
|
|
||||||
elapsed_secs: None,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
self.blocks.push(Block::Compact(CompactEvent::Failed {
|
|
||||||
error,
|
|
||||||
elapsed_secs: None,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_system_item(&mut self, value: &serde_json::Value) {
|
fn apply_system_item(&mut self, value: &serde_json::Value) {
|
||||||
let Ok(item) = serde_json::from_value::<session_store::SystemItem>(value.clone()) else {
|
let Ok(item) = serde_json::from_value::<session_store::SystemItem>(value.clone()) else {
|
||||||
// Unknown / forward-compat shape: fall back to rendering the
|
// Unknown / forward-compat shape: fall back to rendering the
|
||||||
@@ -2542,6 +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 {
|
fn message_text(item: &serde_json::Value) -> String {
|
||||||
item["content"]
|
item["content"]
|
||||||
.as_array()
|
.as_array()
|
||||||
@@ -2685,7 +2646,7 @@ mod rewind_refresh_tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.handle_worker_event(Event::RewindApplied {
|
app.handle_worker_event(Event::RewindApplied {
|
||||||
entries: vec![],
|
session: protocol::SessionSnapshot { entries: vec![] },
|
||||||
input: vec![Segment::text("selected rewind input")],
|
input: vec![Segment::text("selected rewind input")],
|
||||||
summary: summary(3),
|
summary: summary(3),
|
||||||
});
|
});
|
||||||
@@ -2704,7 +2665,7 @@ mod rewind_refresh_tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.handle_worker_event(Event::RewindApplied {
|
app.handle_worker_event(Event::RewindApplied {
|
||||||
entries: vec![],
|
session: protocol::SessionSnapshot { entries: vec![] },
|
||||||
input: vec![Segment::text("rewound input")],
|
input: vec![Segment::text("rewound input")],
|
||||||
summary: summary(1),
|
summary: summary(1),
|
||||||
});
|
});
|
||||||
@@ -2747,7 +2708,7 @@ mod rewind_refresh_tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.handle_worker_event(Event::RewindApplied {
|
app.handle_worker_event(Event::RewindApplied {
|
||||||
entries: vec![],
|
session: protocol::SessionSnapshot { entries: vec![] },
|
||||||
input: vec![Segment::text("rewound input")],
|
input: vec![Segment::text("rewound input")],
|
||||||
summary: summary(2),
|
summary: summary(2),
|
||||||
});
|
});
|
||||||
@@ -2976,6 +2937,17 @@ mod composer_history_persistence_tests {
|
|||||||
mod completion_flow_tests {
|
mod completion_flow_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn annotated(item: agen::Item) -> session_store::LoggedHistoryEntry {
|
||||||
|
session_store::LoggedHistoryEntry {
|
||||||
|
item: session_store::LoggedItem::from(item),
|
||||||
|
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||||
|
entry_id: session_store::LoggedSessionHistoryEntryId::new(),
|
||||||
|
origin: session_store::LoggedSessionHistoryOrigin::LegacyUnknown,
|
||||||
|
derivation: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn typing_at_creates_completion_state_and_emits_query() {
|
fn typing_at_creates_completion_state_and_emits_query() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
@@ -3278,7 +3250,7 @@ mod completion_flow_tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn committed_user_message_survives_fresh_segment_rotation() {
|
fn committed_user_message_survives_fresh_segment_rotation() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
let start = session_store::LogEntry::SegmentStart {
|
let start = session_store::LogEntry::AnnotatedSegmentStart {
|
||||||
ts: session_store::segment_log::now_millis(),
|
ts: session_store::segment_log::now_millis(),
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -3289,7 +3261,9 @@ mod completion_flow_tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
app.handle_worker_event(Event::SegmentRotated {
|
app.handle_worker_event(Event::SegmentRotated {
|
||||||
entry: serde_json::to_value(start).expect("LogEntry is Serialize"),
|
session: public_session(vec![
|
||||||
|
serde_json::to_value(start).expect("LogEntry is Serialize"),
|
||||||
|
]),
|
||||||
});
|
});
|
||||||
app.handle_worker_event(Event::UserMessage {
|
app.handle_worker_event(Event::UserMessage {
|
||||||
segments: vec![Segment::text("first persisted message")],
|
segments: vec![Segment::text("first persisted message")],
|
||||||
@@ -3533,23 +3507,23 @@ mod completion_flow_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshot_renders_system_message_block_from_session_start() {
|
fn snapshot_excludes_system_prompt_history_from_public_blocks() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
let session_start = session_store::LogEntry::SegmentStart {
|
let session_start = session_store::LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 1,
|
ts: 1,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
config: Default::default(),
|
config: Default::default(),
|
||||||
history: vec![session_store::LoggedItem::from(
|
history: vec![annotated(agen::Item::system_message(
|
||||||
&agen::Item::system_message("[File: src/main.rs]\nfn main() {}"),
|
"[File: src/main.rs]\nfn main() {}",
|
||||||
)],
|
))],
|
||||||
forked_from: None,
|
forked_from: None,
|
||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
};
|
};
|
||||||
let session_start_value = serde_json::to_value(&session_start).unwrap();
|
let session_start_value = serde_json::to_value(&session_start).unwrap();
|
||||||
app.handle_worker_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
greeting: test_greeting(),
|
greeting: test_greeting(),
|
||||||
entries: vec![session_start_value],
|
session: public_session(vec![session_start_value]),
|
||||||
status: WorkerStatus::Running,
|
status: WorkerStatus::Running,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
@@ -3557,10 +3531,8 @@ mod completion_flow_tests {
|
|||||||
|
|
||||||
assert!(matches!(app.worker_status, WorkerStatus::Running));
|
assert!(matches!(app.worker_status, WorkerStatus::Running));
|
||||||
assert!(app.running);
|
assert!(app.running);
|
||||||
assert!(matches!(
|
assert_eq!(app.blocks.len(), 1);
|
||||||
app.blocks.get(1),
|
assert!(matches!(app.blocks.first(), Some(Block::Greeting(_))));
|
||||||
Some(Block::SystemMessage { text }) if text == "[File: src/main.rs]\nfn main() {}"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3595,7 +3567,7 @@ mod completion_flow_tests {
|
|||||||
};
|
};
|
||||||
app.handle_worker_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
greeting: test_greeting(),
|
greeting: test_greeting(),
|
||||||
entries: vec![serde_json::to_value(run_errored).unwrap()],
|
session: public_session(vec![serde_json::to_value(run_errored).unwrap()]),
|
||||||
status: WorkerStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
@@ -3623,7 +3595,7 @@ mod completion_flow_tests {
|
|||||||
code: ErrorCode::ProviderError,
|
code: ErrorCode::ProviderError,
|
||||||
message: "provider unavailable".into(),
|
message: "provider unavailable".into(),
|
||||||
});
|
});
|
||||||
let segment_start = session_store::LogEntry::SegmentStart {
|
let segment_start = session_store::LogEntry::AnnotatedSegmentStart {
|
||||||
ts: 5,
|
ts: 5,
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -3633,7 +3605,7 @@ mod completion_flow_tests {
|
|||||||
compacted_from: None,
|
compacted_from: None,
|
||||||
};
|
};
|
||||||
app.handle_worker_event(Event::SegmentRotated {
|
app.handle_worker_event(Event::SegmentRotated {
|
||||||
entry: serde_json::to_value(segment_start).unwrap(),
|
session: public_session(vec![serde_json::to_value(segment_start).unwrap()]),
|
||||||
});
|
});
|
||||||
|
|
||||||
let errors = app
|
let errors = app
|
||||||
@@ -3656,7 +3628,9 @@ mod completion_flow_tests {
|
|||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
app.handle_worker_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
greeting: test_greeting(),
|
greeting: test_greeting(),
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
status: WorkerStatus::Running,
|
status: WorkerStatus::Running,
|
||||||
in_flight: InFlightSnapshot {
|
in_flight: InFlightSnapshot {
|
||||||
blocks: vec![
|
blocks: vec![
|
||||||
@@ -3762,7 +3736,9 @@ mod completion_flow_tests {
|
|||||||
},
|
},
|
||||||
revision,
|
revision,
|
||||||
status: WorkerStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
in_flight: protocol::InFlightSnapshot::default(),
|
in_flight: protocol::InFlightSnapshot::default(),
|
||||||
error: None,
|
error: None,
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
@@ -3977,7 +3953,9 @@ mod completion_flow_tests {
|
|||||||
assert_eq!(app.selected_worker_view().worker_name, "parent");
|
assert_eq!(app.selected_worker_view().worker_name, "parent");
|
||||||
app.handle_worker_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
greeting: test_greeting(),
|
greeting: test_greeting(),
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
status: WorkerStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
@@ -4026,7 +4004,9 @@ mod completion_flow_tests {
|
|||||||
});
|
});
|
||||||
app.handle_worker_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
greeting: test_greeting(),
|
greeting: test_greeting(),
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
status: WorkerStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
internal_workers: vec![InternalWorkerSnapshot {
|
internal_workers: vec![InternalWorkerSnapshot {
|
||||||
@@ -4037,7 +4017,9 @@ mod completion_flow_tests {
|
|||||||
kind: protocol::InternalWorkerKind::SubWorker,
|
kind: protocol::InternalWorkerKind::SubWorker,
|
||||||
},
|
},
|
||||||
revision: 4,
|
revision: 4,
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
status: WorkerStatus::Running,
|
status: WorkerStatus::Running,
|
||||||
error: None,
|
error: None,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
@@ -4193,7 +4175,9 @@ mod completion_flow_tests {
|
|||||||
greeting.context_tokens = 45_000;
|
greeting.context_tokens = 45_000;
|
||||||
|
|
||||||
app.handle_worker_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
greeting,
|
greeting,
|
||||||
status: WorkerStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
@@ -4363,40 +4347,37 @@ mod completion_flow_tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let assistant_item_entries = vec![
|
let assistant_item_entries = vec![
|
||||||
serde_json::json!({
|
serde_json::to_value(session_store::LogEntry::AnnotatedAssistantItem {
|
||||||
"kind": "assistant_item",
|
ts: 1,
|
||||||
"ts": 1,
|
entry: annotated(agen::Item::tool_call(
|
||||||
"item": {
|
"c1",
|
||||||
"kind": "tool_call",
|
"TaskCreate",
|
||||||
"call_id": "c1",
|
r#"{"subject":"a","description":"A"}"#,
|
||||||
"name": "TaskCreate",
|
)),
|
||||||
"arguments": r#"{"subject":"a","description":"A"}"#,
|
})
|
||||||
},
|
.unwrap(),
|
||||||
}),
|
serde_json::to_value(session_store::LogEntry::AnnotatedAssistantItem {
|
||||||
serde_json::json!({
|
ts: 2,
|
||||||
"kind": "assistant_item",
|
entry: annotated(agen::Item::tool_call(
|
||||||
"ts": 2,
|
"c2",
|
||||||
"item": {
|
"TaskCreate",
|
||||||
"kind": "tool_call",
|
r#"{"subject":"b","description":"B"}"#,
|
||||||
"call_id": "c2",
|
)),
|
||||||
"name": "TaskCreate",
|
})
|
||||||
"arguments": r#"{"subject":"b","description":"B"}"#,
|
.unwrap(),
|
||||||
},
|
serde_json::to_value(session_store::LogEntry::AnnotatedAssistantItem {
|
||||||
}),
|
ts: 3,
|
||||||
serde_json::json!({
|
entry: annotated(agen::Item::tool_call(
|
||||||
"kind": "assistant_item",
|
"u1",
|
||||||
"ts": 3,
|
"TaskUpdate",
|
||||||
"item": {
|
r#"{"taskid":2,"status":"inprogress"}"#,
|
||||||
"kind": "tool_call",
|
)),
|
||||||
"call_id": "u1",
|
})
|
||||||
"name": "TaskUpdate",
|
.unwrap(),
|
||||||
"arguments": r#"{"taskid":2,"status":"inprogress"}"#,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
app.handle_worker_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
greeting: test_greeting(),
|
greeting: test_greeting(),
|
||||||
entries: assistant_item_entries,
|
session: public_session(assistant_item_entries),
|
||||||
status: WorkerStatus::Running,
|
status: WorkerStatus::Running,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
|
|||||||
@@ -547,7 +547,9 @@ async fn run_e2e_rewind_fixture(
|
|||||||
let mut app = App::new_with_persistent_input_history(worker_name.clone(), &workspace_root);
|
let mut app = App::new_with_persistent_input_history(worker_name.clone(), &workspace_root);
|
||||||
app.connected = true;
|
app.connected = true;
|
||||||
app.handle_worker_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
status: WorkerStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
greeting: Greeting {
|
greeting: Greeting {
|
||||||
worker_name: worker_name.clone(),
|
worker_name: worker_name.clone(),
|
||||||
@@ -673,7 +675,9 @@ async fn run_e2e_rewind_fixture(
|
|||||||
if let Some(submitted_at) = pending_apply {
|
if let Some(submitted_at) = pending_apply {
|
||||||
if submitted_at.elapsed() >= apply_delay {
|
if submitted_at.elapsed() >= apply_delay {
|
||||||
app.handle_worker_event(Event::RewindApplied {
|
app.handle_worker_event(Event::RewindApplied {
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
input: vec![Segment::text("rewind-live-refresh")],
|
input: vec![Segment::text("rewind-live-refresh")],
|
||||||
summary: RewindSummary {
|
summary: RewindSummary {
|
||||||
truncated_to_entries: 1,
|
truncated_to_entries: 1,
|
||||||
@@ -2023,13 +2027,13 @@ mod tests {
|
|||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.handle_worker_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
greeting: test_greeting(),
|
greeting: test_greeting(),
|
||||||
entries: vec![],
|
session: protocol::SessionSnapshot { entries: vec![] },
|
||||||
status: WorkerStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
});
|
});
|
||||||
app.handle_worker_event(Event::RewindApplied {
|
app.handle_worker_event(Event::RewindApplied {
|
||||||
entries: vec![],
|
session: protocol::SessionSnapshot { entries: vec![] },
|
||||||
input: vec![Segment::Text {
|
input: vec![Segment::Text {
|
||||||
content: "retry this".into(),
|
content: "retry this".into(),
|
||||||
}],
|
}],
|
||||||
@@ -2050,7 +2054,7 @@ mod tests {
|
|||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.handle_worker_event(Event::Snapshot {
|
app.handle_worker_event(Event::Snapshot {
|
||||||
greeting: test_greeting(),
|
greeting: test_greeting(),
|
||||||
entries: vec![],
|
session: protocol::SessionSnapshot { entries: vec![] },
|
||||||
status: WorkerStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
@@ -2058,7 +2062,7 @@ mod tests {
|
|||||||
type_keys(&mut app, "draft");
|
type_keys(&mut app, "draft");
|
||||||
|
|
||||||
app.handle_worker_event(Event::RewindApplied {
|
app.handle_worker_event(Event::RewindApplied {
|
||||||
entries: vec![],
|
session: protocol::SessionSnapshot { entries: vec![] },
|
||||||
input: vec![Segment::Text {
|
input: vec![Segment::Text {
|
||||||
content: "retry this".into(),
|
content: "retry this".into(),
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -849,7 +849,9 @@ async fn ticket_queue_notification_sends_notify_when_socket_available() {
|
|||||||
let mut writer = JsonLineWriter::new(writer);
|
let mut writer = JsonLineWriter::new(writer);
|
||||||
writer
|
writer
|
||||||
.write(&Event::Snapshot {
|
.write(&Event::Snapshot {
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
greeting: protocol::Greeting {
|
greeting: protocol::Greeting {
|
||||||
worker_name: "test-orchestrator".to_string(),
|
worker_name: "test-orchestrator".to_string(),
|
||||||
cwd: temp.path().display().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);
|
let mut writer = JsonLineWriter::new(writer);
|
||||||
writer
|
writer
|
||||||
.write(&Event::Snapshot {
|
.write(&Event::Snapshot {
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
greeting: protocol::Greeting {
|
greeting: protocol::Greeting {
|
||||||
worker_name: "yoi".to_string(),
|
worker_name: "yoi".to_string(),
|
||||||
cwd: temp.path().display().to_string(),
|
cwd: temp.path().display().to_string(),
|
||||||
|
|||||||
@@ -623,6 +623,17 @@ mod tests {
|
|||||||
|
|
||||||
const SOURCE: WorkerVisibilitySource = WorkerVisibilitySource::ResumePicker;
|
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]
|
#[test]
|
||||||
fn stored_metadata_summary_uses_segment_marker_without_reading_session_log() {
|
fn stored_metadata_summary_uses_segment_marker_without_reading_session_log() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
@@ -910,7 +921,7 @@ mod tests {
|
|||||||
timestamp_ms: 0,
|
timestamp_ms: 0,
|
||||||
}),
|
}),
|
||||||
Event::Snapshot {
|
Event::Snapshot {
|
||||||
entries: vec![],
|
session: protocol::SessionSnapshot { entries: vec![] },
|
||||||
greeting: test_greeting(),
|
greeting: test_greeting(),
|
||||||
status: WorkerStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
in_flight: Default::default(),
|
in_flight: Default::default(),
|
||||||
@@ -1207,7 +1218,7 @@ mod tests {
|
|||||||
.append(
|
.append(
|
||||||
session_id,
|
session_id,
|
||||||
segment_id,
|
segment_id,
|
||||||
&LogEntry::SegmentStart {
|
&LogEntry::AnnotatedSegmentStart {
|
||||||
ts,
|
ts,
|
||||||
session_id,
|
session_id,
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -1231,9 +1242,10 @@ mod tests {
|
|||||||
.append(
|
.append(
|
||||||
session_id,
|
session_id,
|
||||||
segment_id,
|
segment_id,
|
||||||
&LogEntry::UserInput {
|
&LogEntry::AnnotatedUserInput {
|
||||||
ts,
|
ts,
|
||||||
segments: vec![protocol::Segment::text(text)],
|
segments: vec![protocol::Segment::text(text)],
|
||||||
|
history: vec![annotated(agen::Item::user_message(text))],
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1530,7 +1530,9 @@ impl Runtime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(protocol::Event::Snapshot {
|
Ok(protocol::Event::Snapshot {
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
greeting: protocol::Greeting {
|
greeting: protocol::Greeting {
|
||||||
worker_name: worker_ref.worker_id.to_string(),
|
worker_name: worker_ref.worker_id.to_string(),
|
||||||
cwd: String::new(),
|
cwd: String::new(),
|
||||||
@@ -3152,7 +3154,9 @@ mod tests {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
let snapshot = protocol::Event::Snapshot {
|
let snapshot = protocol::Event::Snapshot {
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
greeting: protocol::Greeting {
|
greeting: protocol::Greeting {
|
||||||
worker_name: "parent".to_string(),
|
worker_name: "parent".to_string(),
|
||||||
cwd: "/tmp".to_string(),
|
cwd: "/tmp".to_string(),
|
||||||
@@ -4581,7 +4585,17 @@ mod tests {
|
|||||||
backend.set_worker_snapshot(
|
backend.set_worker_snapshot(
|
||||||
&detail.worker_ref,
|
&detail.worker_ref,
|
||||||
protocol::Event::Snapshot {
|
protocol::Event::Snapshot {
|
||||||
entries: vec![expected_entry.clone()],
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: vec![protocol::SessionSnapshotEntry {
|
||||||
|
entry_id: "restored-log-entry".to_owned(),
|
||||||
|
timestamp: 1,
|
||||||
|
provenance: protocol::SessionEntryProvenance::LegacyUnknown,
|
||||||
|
derived_from: Vec::new(),
|
||||||
|
data: protocol::SessionSnapshotEntryData::RunError {
|
||||||
|
message: expected_entry.to_string(),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
greeting: protocol::Greeting {
|
greeting: protocol::Greeting {
|
||||||
worker_name: "live-worker".to_string(),
|
worker_name: "live-worker".to_string(),
|
||||||
cwd: "/tmp/live".to_string(),
|
cwd: "/tmp/live".to_string(),
|
||||||
@@ -4606,12 +4620,13 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
match snapshot {
|
match snapshot {
|
||||||
protocol::Event::Snapshot {
|
protocol::Event::Snapshot {
|
||||||
entries,
|
session,
|
||||||
greeting,
|
greeting,
|
||||||
status,
|
status,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(entries, vec![expected_entry]);
|
assert_eq!(session.entries.len(), 1);
|
||||||
|
assert_eq!(session.entries[0].entry_id, "restored-log-entry");
|
||||||
assert_eq!(greeting.worker_name, "live-worker");
|
assert_eq!(greeting.worker_name, "live-worker");
|
||||||
assert_eq!(status, protocol::WorkerStatus::Running);
|
assert_eq!(status, protocol::WorkerStatus::Running);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ use crate::working_directory::{
|
|||||||
WorkingDirectoryBinding, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer,
|
WorkingDirectoryBinding, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use protocol::{Event, Method, Segment, WorkerStatus};
|
use protocol::{ErrorCode, Event, Method, Segment, WorkerStatus};
|
||||||
use session_store::{CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore};
|
use session_store::{CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use session_store::{FsStore, FsWorkerStore};
|
use session_store::{FsStore, FsWorkerStore};
|
||||||
@@ -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 {
|
fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
|
||||||
let extensions = match entry {
|
let extensions = match entry {
|
||||||
LogEntry::UserInput { extensions, .. }
|
LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
|
||||||
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
|
|
||||||
_ => return false,
|
_ => return false,
|
||||||
};
|
};
|
||||||
extensions.iter().any(|extension| {
|
extensions.iter().any(|extension| {
|
||||||
@@ -1443,7 +1442,6 @@ where
|
|||||||
let streams = subscribe_worker_protocol_session(&handle);
|
let streams = subscribe_worker_protocol_session(&handle);
|
||||||
let mut events = streams.events;
|
let mut events = streams.events;
|
||||||
let mut entry_events = streams.log_entries;
|
let mut entry_events = streams.log_entries;
|
||||||
let bridge_handle = handle.clone();
|
|
||||||
let bridge_busy = busy.clone();
|
let bridge_busy = busy.clone();
|
||||||
if let Err(message) = self.spawn_on_adapter_runtime(async move {
|
if let Err(message) = self.spawn_on_adapter_runtime(async move {
|
||||||
loop {
|
loop {
|
||||||
@@ -1451,12 +1449,28 @@ where
|
|||||||
event = events.recv() => {
|
event = events.recv() => {
|
||||||
match event {
|
match event {
|
||||||
Ok(event) => {
|
Ok(event) => {
|
||||||
|
let next_busy = match &event {
|
||||||
|
Event::InvokeStart { .. }
|
||||||
|
| Event::Status {
|
||||||
|
status: WorkerStatus::Running,
|
||||||
|
} => Some(true),
|
||||||
|
Event::RunEnd { .. }
|
||||||
|
| Event::Error {
|
||||||
|
code: ErrorCode::NotPaused,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
| Event::Status {
|
||||||
|
status:
|
||||||
|
WorkerStatus::Idle
|
||||||
|
| WorkerStatus::Paused
|
||||||
|
| WorkerStatus::Stopped,
|
||||||
|
}
|
||||||
|
| Event::Shutdown => Some(false),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
let _ = bridge_context.publish_protocol_event(event);
|
let _ = bridge_context.publish_protocol_event(event);
|
||||||
if matches!(
|
if let Some(next_busy) = next_busy {
|
||||||
bridge_handle.shared_state.get_status(),
|
bridge_busy.store(next_busy, Ordering::SeqCst);
|
||||||
WorkerStatus::Idle | WorkerStatus::Paused
|
|
||||||
) {
|
|
||||||
bridge_busy.store(false, Ordering::SeqCst);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||||
@@ -2566,18 +2580,22 @@ mod tests {
|
|||||||
) {
|
) {
|
||||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
loop {
|
loop {
|
||||||
let matches = {
|
let observed = {
|
||||||
let workers = backend.workers.lock().unwrap();
|
let workers = backend.workers.lock().unwrap();
|
||||||
let execution = workers.get(worker_ref).expect("live Worker execution");
|
let execution = workers.get(worker_ref).expect("live Worker execution");
|
||||||
execution.handle.shared_state.get_status() == expected_status
|
(
|
||||||
&& execution.busy.load(Ordering::SeqCst) == expected_busy
|
execution.handle.shared_state.get_status(),
|
||||||
|
execution.busy.load(Ordering::SeqCst),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
if matches {
|
if observed == (expected_status, expected_busy) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assert!(
|
assert!(
|
||||||
std::time::Instant::now() < deadline,
|
std::time::Instant::now() < deadline,
|
||||||
"timed out waiting for adapter state {expected_status:?}, busy={expected_busy}"
|
"timed out waiting for adapter state {expected_status:?}, busy={expected_busy}; last observed status={:?}, busy={}",
|
||||||
|
observed.0,
|
||||||
|
observed.1,
|
||||||
);
|
);
|
||||||
std::thread::sleep(Duration::from_millis(10));
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
}
|
}
|
||||||
@@ -3244,8 +3262,7 @@ mod tests {
|
|||||||
assert!(entries.iter().any(|entry| {
|
assert!(entries.iter().any(|entry| {
|
||||||
matches!(
|
matches!(
|
||||||
entry,
|
entry,
|
||||||
LogEntry::UserInput { segments, .. }
|
LogEntry::AnnotatedUserInput { segments, .. }
|
||||||
| LogEntry::AnnotatedUserInput { segments, .. }
|
|
||||||
if segments == &vec![Segment::text("start the ticket")]
|
if segments == &vec![Segment::text("start the ticket")]
|
||||||
)
|
)
|
||||||
}));
|
}));
|
||||||
@@ -3253,8 +3270,7 @@ mod tests {
|
|||||||
.iter()
|
.iter()
|
||||||
.find_map(|entry| {
|
.find_map(|entry| {
|
||||||
let extensions = match entry {
|
let extensions = match entry {
|
||||||
LogEntry::UserInput { extensions, .. }
|
LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
|
||||||
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
|
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
extensions
|
extensions
|
||||||
|
|||||||
@@ -84,10 +84,7 @@ impl WorkerHandle {
|
|||||||
(entries, entry_rx, in_flight)
|
(entries, entry_rx, in_flight)
|
||||||
};
|
};
|
||||||
let event = Event::Snapshot {
|
let event = Event::Snapshot {
|
||||||
entries: entries
|
session: session_store::public_snapshot::project_current_session_snapshot(&entries),
|
||||||
.into_iter()
|
|
||||||
.map(|entry| serde_json::to_value(entry).expect("log entry serializes"))
|
|
||||||
.collect(),
|
|
||||||
greeting: self.shared_state.greeting.clone(),
|
greeting: self.shared_state.greeting.clone(),
|
||||||
status: self.shared_state.get_status(),
|
status: self.shared_state.get_status(),
|
||||||
in_flight,
|
in_flight,
|
||||||
@@ -634,8 +631,8 @@ fn protocol_command_status(status: WorkdirCommandStatus) -> ProtocolCommandStatu
|
|||||||
///
|
///
|
||||||
/// `Worker::wire_history_persistence` is called separately to wire the
|
/// `Worker::wire_history_persistence` is called separately to wire the
|
||||||
/// per-item history commit callback so every assistant / tool item
|
/// per-item history commit callback so every assistant / tool item
|
||||||
/// landing in `worker.history` becomes a singular `LogEntry::AssistantItem`
|
/// landing in `worker.history` becomes a singular `LogEntry::AnnotatedAssistantItem`
|
||||||
/// / `ToolResult` commit through the sync writer.
|
/// / `AnnotatedToolResult` commit through the sync writer.
|
||||||
pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
||||||
worker: &mut Worker<C, St>,
|
worker: &mut Worker<C, St>,
|
||||||
event_tx: &broadcast::Sender<Event>,
|
event_tx: &broadcast::Sender<Event>,
|
||||||
@@ -1320,7 +1317,7 @@ async fn controller_loop<C, St>(
|
|||||||
}
|
}
|
||||||
// Stage the run without a speculative user-message echo.
|
// Stage the run without a speculative user-message echo.
|
||||||
// `Worker::run` validates the input, commits
|
// `Worker::run` validates the input, commits
|
||||||
// `LogEntry::UserInput`, and the session-log sink turns that
|
// `LogEntry::AnnotatedUserInput`, and the session-log sink turns that
|
||||||
// committed entry into the live `Event::UserMessage`. That
|
// committed entry into the live `Event::UserMessage`. That
|
||||||
// keeps every client ordered against `SegmentStart` replay and
|
// keeps every client ordered against `SegmentStart` replay and
|
||||||
// makes persisted history the single source of visible user
|
// makes persisted history the single source of visible user
|
||||||
@@ -1346,7 +1343,7 @@ async fn controller_loop<C, St>(
|
|||||||
Method::Notify { message, auto_run } => {
|
Method::Notify { message, auto_run } => {
|
||||||
// Client-side live echo is delivered as `Event::SystemItem`
|
// Client-side live echo is delivered as `Event::SystemItem`
|
||||||
// once the interceptor commits the corresponding
|
// once the interceptor commits the corresponding
|
||||||
// `LogEntry::SystemItem` entry — drained out of the
|
// `LogEntry::AnnotatedSystemItem` entry — drained out of the
|
||||||
// notify buffer + broadcast through the sink. No
|
// notify buffer + broadcast through the sink. No
|
||||||
// separate echo here.
|
// separate echo here.
|
||||||
worker.push_notify(message, auto_run);
|
worker.push_notify(message, auto_run);
|
||||||
@@ -1874,28 +1871,16 @@ where
|
|||||||
St: Store,
|
St: Store,
|
||||||
{
|
{
|
||||||
match worker.rewind_to(target, expected_head_entries) {
|
match worker.rewind_to(target, expected_head_entries) {
|
||||||
Ok(applied) => match applied
|
Ok(applied) => {
|
||||||
.entries
|
let session =
|
||||||
.into_iter()
|
session_store::public_snapshot::project_current_session_snapshot(&applied.entries);
|
||||||
.map(serde_json::to_value)
|
let _ = event_tx.send(Event::RewindApplied {
|
||||||
.collect::<Result<Vec<_>, _>>()
|
session,
|
||||||
{
|
input: applied.input,
|
||||||
Ok(entries) => {
|
summary: applied.summary,
|
||||||
let _ = event_tx.send(Event::RewindApplied {
|
});
|
||||||
entries,
|
true
|
||||||
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
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = event_tx.send(Event::Error {
|
||||||
code: ErrorCode::InvalidRequest,
|
code: ErrorCode::InvalidRequest,
|
||||||
@@ -2101,7 +2086,9 @@ mod tests {
|
|||||||
let mut writer = JsonLineWriter::new(w);
|
let mut writer = JsonLineWriter::new(w);
|
||||||
writer
|
writer
|
||||||
.write(&Event::Snapshot {
|
.write(&Event::Snapshot {
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
greeting: protocol::Greeting {
|
greeting: protocol::Greeting {
|
||||||
worker_name: "parent".into(),
|
worker_name: "parent".into(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
|
|||||||
@@ -1481,7 +1481,9 @@ mod tests {
|
|||||||
let mut writer = JsonLineWriter::new(stream);
|
let mut writer = JsonLineWriter::new(stream);
|
||||||
writer
|
writer
|
||||||
.write(&Event::Snapshot {
|
.write(&Event::Snapshot {
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
greeting: protocol::Greeting {
|
greeting: protocol::Greeting {
|
||||||
worker_name: "target".into(),
|
worker_name: "target".into(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
@@ -1514,7 +1516,9 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
writer
|
writer
|
||||||
.write(&Event::Snapshot {
|
.write(&Event::Snapshot {
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
greeting: protocol::Greeting {
|
greeting: protocol::Greeting {
|
||||||
worker_name: "target".into(),
|
worker_name: "target".into(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
@@ -1603,7 +1607,9 @@ mod tests {
|
|||||||
let mut writer = JsonLineWriter::new(stream);
|
let mut writer = JsonLineWriter::new(stream);
|
||||||
writer
|
writer
|
||||||
.write(&Event::Snapshot {
|
.write(&Event::Snapshot {
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
greeting: protocol::Greeting {
|
greeting: protocol::Greeting {
|
||||||
worker_name: "target".into(),
|
worker_name: "target".into(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
@@ -1627,7 +1633,9 @@ mod tests {
|
|||||||
let mut writer = JsonLineWriter::new(writer_half);
|
let mut writer = JsonLineWriter::new(writer_half);
|
||||||
writer
|
writer
|
||||||
.write(&Event::Snapshot {
|
.write(&Event::Snapshot {
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
greeting: protocol::Greeting {
|
greeting: protocol::Greeting {
|
||||||
worker_name: "target".into(),
|
worker_name: "target".into(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
@@ -1729,7 +1737,9 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
writer
|
writer
|
||||||
.write(&Event::Snapshot {
|
.write(&Event::Snapshot {
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
greeting: protocol::Greeting {
|
greeting: protocol::Greeting {
|
||||||
worker_name: "alerted".into(),
|
worker_name: "alerted".into(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
@@ -1779,7 +1789,9 @@ mod tests {
|
|||||||
let mut writer = JsonLineWriter::new(stream);
|
let mut writer = JsonLineWriter::new(stream);
|
||||||
let _ = writer
|
let _ = writer
|
||||||
.write(&Event::Snapshot {
|
.write(&Event::Snapshot {
|
||||||
entries: Vec::new(),
|
session: protocol::SessionSnapshot {
|
||||||
|
entries: Vec::new(),
|
||||||
|
},
|
||||||
greeting: protocol::Greeting {
|
greeting: protocol::Greeting {
|
||||||
worker_name: "child-live".into(),
|
worker_name: "child-live".into(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
|
|||||||
@@ -963,9 +963,12 @@ permission = "read"
|
|||||||
.append(
|
.append(
|
||||||
session_id,
|
session_id,
|
||||||
segment_id,
|
segment_id,
|
||||||
&LogEntry::UserInput {
|
&LogEntry::AnnotatedUserInput {
|
||||||
ts: 1,
|
ts: 1,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
|
history: vec![crate::session_history::test_logged_history_entry(
|
||||||
|
agen::Item::user_message("verify current Flow conditions"),
|
||||||
|
)],
|
||||||
segments: vec![Segment::Text {
|
segments: vec![Segment::Text {
|
||||||
content: "verify current Flow conditions".into(),
|
content: "verify current Flow conditions".into(),
|
||||||
}],
|
}],
|
||||||
|
|||||||
@@ -185,12 +185,9 @@ impl Tool for StageMemoryCandidateTool {
|
|||||||
})?);
|
})?);
|
||||||
}
|
}
|
||||||
if matches!(params.kind, CandidateKind::Preference)
|
if matches!(params.kind, CandidateKind::Preference)
|
||||||
&& entries.iter().any(|entry| {
|
&& entries
|
||||||
!matches!(
|
.iter()
|
||||||
entry.origin,
|
.any(|entry| !matches!(entry.origin, protocol::SessionEntryProvenance::HumanInput))
|
||||||
crate::WorkerHistoryProvenance::HumanInput { .. }
|
|
||||||
)
|
|
||||||
})
|
|
||||||
{
|
{
|
||||||
return Err(ToolError::InvalidArgument(
|
return Err(ToolError::InvalidArgument(
|
||||||
"preference candidates require exclusively HumanInput evidence; model, Worker, Flow, backend, derived, and legacy-unknown origins are not preference authority"
|
"preference candidates require exclusively HumanInput evidence; model, Worker, Flow, backend, derived, and legacy-unknown origins are not preference authority"
|
||||||
@@ -324,10 +321,23 @@ fn evidence_kind(entry: &SessionEntryEvidence) -> EvidenceKind {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn evidence_origin(origin: &crate::WorkerHistoryProvenance) -> EvidenceOrigin {
|
fn evidence_origin(origin: &protocol::SessionEntryProvenance) -> EvidenceOrigin {
|
||||||
use crate::WorkerHistoryProvenance as Origin;
|
use protocol::SessionEntryProvenance as Origin;
|
||||||
let mut evidence = EvidenceOrigin {
|
let kind = match origin {
|
||||||
kind: EvidenceOriginKind::LegacyUnknown,
|
Origin::HumanInput => EvidenceOriginKind::HumanInput,
|
||||||
|
Origin::WorkerInput => EvidenceOriginKind::WorkerInput,
|
||||||
|
Origin::FlowInstruction => EvidenceOriginKind::FlowInstruction,
|
||||||
|
Origin::BackendInstruction => EvidenceOriginKind::BackendInstruction,
|
||||||
|
Origin::ModelOutput => EvidenceOriginKind::ModelOutput,
|
||||||
|
Origin::ToolOutput => EvidenceOriginKind::ToolOutput,
|
||||||
|
Origin::DerivedSummary => EvidenceOriginKind::DerivedSummary,
|
||||||
|
Origin::LegacyUnknown => EvidenceOriginKind::LegacyUnknown,
|
||||||
|
};
|
||||||
|
EvidenceOrigin {
|
||||||
|
kind,
|
||||||
|
// The public SessionSnapshot intentionally excludes account, Worker,
|
||||||
|
// Runtime, and Flow internals. Preserve the authenticated origin class
|
||||||
|
// without inventing missing control-plane identity fields.
|
||||||
account_id: None,
|
account_id: None,
|
||||||
workspace_id: None,
|
workspace_id: None,
|
||||||
runtime_id: None,
|
runtime_id: None,
|
||||||
@@ -335,46 +345,7 @@ fn evidence_origin(origin: &crate::WorkerHistoryProvenance) -> EvidenceOrigin {
|
|||||||
flow_selector: None,
|
flow_selector: None,
|
||||||
flow_definition_id: None,
|
flow_definition_id: None,
|
||||||
flow_definition_revision: None,
|
flow_definition_revision: None,
|
||||||
};
|
|
||||||
match origin {
|
|
||||||
Origin::HumanInput { account_id } => {
|
|
||||||
evidence.kind = EvidenceOriginKind::HumanInput;
|
|
||||||
evidence.account_id = Some(account_id.clone());
|
|
||||||
}
|
|
||||||
Origin::WorkerInput { actor } => {
|
|
||||||
evidence.kind = EvidenceOriginKind::WorkerInput;
|
|
||||||
evidence.workspace_id = actor.workspace_id.clone();
|
|
||||||
evidence.runtime_id = actor.runtime_id.clone();
|
|
||||||
evidence.worker_id = Some(actor.worker_id.clone());
|
|
||||||
}
|
|
||||||
Origin::FlowInstruction {
|
|
||||||
selector,
|
|
||||||
definition_id,
|
|
||||||
definition_revision,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
evidence.kind = EvidenceOriginKind::FlowInstruction;
|
|
||||||
evidence.flow_selector = Some(selector.clone());
|
|
||||||
evidence.flow_definition_id = Some(definition_id.clone());
|
|
||||||
evidence.flow_definition_revision = Some(*definition_revision);
|
|
||||||
}
|
|
||||||
Origin::BackendInstruction { .. } => evidence.kind = EvidenceOriginKind::BackendInstruction,
|
|
||||||
Origin::ModelOutput { worker } => {
|
|
||||||
evidence.kind = EvidenceOriginKind::ModelOutput;
|
|
||||||
evidence.workspace_id = worker.workspace_id.clone();
|
|
||||||
evidence.runtime_id = worker.runtime_id.clone();
|
|
||||||
evidence.worker_id = Some(worker.worker_id.clone());
|
|
||||||
}
|
|
||||||
Origin::ToolOutput { worker } => {
|
|
||||||
evidence.kind = EvidenceOriginKind::ToolOutput;
|
|
||||||
evidence.workspace_id = worker.workspace_id.clone();
|
|
||||||
evidence.runtime_id = worker.runtime_id.clone();
|
|
||||||
evidence.worker_id = Some(worker.worker_id.clone());
|
|
||||||
}
|
|
||||||
Origin::DerivedSummary => evidence.kind = EvidenceOriginKind::DerivedSummary,
|
|
||||||
Origin::LegacyUnknown => evidence.kind = EvidenceOriginKind::LegacyUnknown,
|
|
||||||
}
|
}
|
||||||
evidence
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
|
fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
|
||||||
@@ -502,12 +473,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn human_origin_projects_account_authority_into_evidence() {
|
fn public_human_origin_preserves_class_without_inventing_account_authority() {
|
||||||
let origin = evidence_origin(&crate::WorkerHistoryProvenance::HumanInput {
|
let origin = evidence_origin(&protocol::SessionEntryProvenance::HumanInput);
|
||||||
account_id: "account-1".into(),
|
|
||||||
});
|
|
||||||
assert_eq!(origin.kind, EvidenceOriginKind::HumanInput);
|
assert_eq!(origin.kind, EvidenceOriginKind::HumanInput);
|
||||||
assert_eq!(origin.account_id.as_deref(), Some("account-1"));
|
assert_eq!(origin.account_id, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -422,27 +422,11 @@ fn project_relation(
|
|||||||
kind_key: &str,
|
kind_key: &str,
|
||||||
) -> Result<ModelRelation, String> {
|
) -> Result<ModelRelation, String> {
|
||||||
let relation = object(value, "Ticket relation")?;
|
let relation = object(value, "Ticket relation")?;
|
||||||
let relation_data = relation.get("relation").and_then(Value::as_object);
|
|
||||||
let kind = if kind_key == "kind" {
|
|
||||||
relation_data
|
|
||||||
.ok_or_else(|| "Ticket relation is missing relation data".to_string())
|
|
||||||
.and_then(|data| string_field(data, "kind"))?
|
|
||||||
} else {
|
|
||||||
string_field(relation, kind_key)?
|
|
||||||
};
|
|
||||||
let note = match relation_data {
|
|
||||||
Some(data) => optional_string(data, "note")?,
|
|
||||||
None => optional_string(relation, "note")?,
|
|
||||||
};
|
|
||||||
let created_at = match relation_data {
|
|
||||||
Some(data) => optional_string(data, "at")?,
|
|
||||||
None => optional_string(relation, "at")?,
|
|
||||||
};
|
|
||||||
Ok(ModelRelation {
|
Ok(ModelRelation {
|
||||||
ticket: resource_ref(relation, ticket_key, "T-")?,
|
ticket: resource_ref(relation, ticket_key, "T-")?,
|
||||||
kind,
|
kind: string_field(relation, kind_key)?,
|
||||||
note,
|
note: optional_string(relation, "note")?,
|
||||||
created_at,
|
created_at: optional_string(relation, "at")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,6 +760,95 @@ mod tests {
|
|||||||
assert!(!objective_json.contains("00001TICKETINTERNAL"));
|
assert!(!objective_json.contains("00001TICKETINTERNAL"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relation_projection_accepts_current_workspace_api_shapes() {
|
||||||
|
let outgoing = project_relation(
|
||||||
|
&json!({
|
||||||
|
"ticket_id": "internal-source-ticket",
|
||||||
|
"kind": "depends_on",
|
||||||
|
"target": "internal-target-ticket",
|
||||||
|
"target_resource_key": "T-535",
|
||||||
|
"note": "required foundation",
|
||||||
|
"author": "internal-author",
|
||||||
|
"at": "2026-08-22T00:00:00Z"
|
||||||
|
}),
|
||||||
|
"target_resource_key",
|
||||||
|
"kind",
|
||||||
|
)
|
||||||
|
.expect("outgoing relation projection");
|
||||||
|
let incoming = project_relation(
|
||||||
|
&json!({
|
||||||
|
"source_ticket": "internal-source-ticket",
|
||||||
|
"source_resource_key": "T-536",
|
||||||
|
"inverse_kind": "blocks",
|
||||||
|
"forward_kind": "depends_on",
|
||||||
|
"note": null,
|
||||||
|
"author": "internal-author",
|
||||||
|
"at": "2026-08-22T00:01:00Z"
|
||||||
|
}),
|
||||||
|
"source_resource_key",
|
||||||
|
"forward_kind",
|
||||||
|
)
|
||||||
|
.expect("incoming relation projection");
|
||||||
|
|
||||||
|
let outgoing = serde_json::to_value(outgoing).expect("serialize outgoing relation");
|
||||||
|
assert_eq!(
|
||||||
|
outgoing,
|
||||||
|
json!({
|
||||||
|
"ticket": "T-535",
|
||||||
|
"kind": "depends_on",
|
||||||
|
"note": "required foundation",
|
||||||
|
"created_at": "2026-08-22T00:00:00Z"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let incoming = serde_json::to_value(incoming).expect("serialize incoming relation");
|
||||||
|
assert_eq!(
|
||||||
|
incoming,
|
||||||
|
json!({
|
||||||
|
"ticket": "T-536",
|
||||||
|
"kind": "depends_on",
|
||||||
|
"note": null,
|
||||||
|
"created_at": "2026-08-22T00:01:00Z"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let projection = format!("{outgoing}{incoming}");
|
||||||
|
for internal in [
|
||||||
|
"internal-source-ticket",
|
||||||
|
"internal-target-ticket",
|
||||||
|
"internal-author",
|
||||||
|
] {
|
||||||
|
assert!(!projection.contains(internal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relation_projection_rejects_missing_workspace_keys() {
|
||||||
|
let outgoing = json!({
|
||||||
|
"kind": "depends_on",
|
||||||
|
"target": "internal-target-ticket",
|
||||||
|
"note": null,
|
||||||
|
"author": "internal-author",
|
||||||
|
"at": "2026-08-22T00:00:00Z"
|
||||||
|
});
|
||||||
|
let incoming = json!({
|
||||||
|
"source_resource_key": "not-a-ticket-key",
|
||||||
|
"forward_kind": "depends_on",
|
||||||
|
"note": null,
|
||||||
|
"author": "internal-author",
|
||||||
|
"at": "2026-08-22T00:01:00Z"
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
project_relation(&outgoing, "target_resource_key", "kind")
|
||||||
|
.expect_err("missing outgoing key must fail")
|
||||||
|
.contains("T-")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
project_relation(&incoming, "source_resource_key", "forward_kind")
|
||||||
|
.expect_err("invalid incoming key must fail")
|
||||||
|
.contains("T-")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resource_projection_rejects_noncanonical_keys() {
|
fn resource_projection_rejects_noncanonical_keys() {
|
||||||
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
|
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use session_store::{LogEntry, collect_state};
|
use session_store::LogEntry;
|
||||||
|
|
||||||
use super::manage_worker::{WORKER_CONTROL_SERVICE_ID, WorkerControlService};
|
use super::manage_worker::{WORKER_CONTROL_SERVICE_ID, WorkerControlService};
|
||||||
use crate::feature::{
|
use crate::feature::{
|
||||||
@@ -61,7 +61,7 @@ pub struct WorkerObservationSubject {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct WorkerSessionCapture {
|
pub struct WorkerSessionCapture {
|
||||||
pub segment_id: String,
|
pub segment_id: String,
|
||||||
pub entries: Vec<agen::HistoryEntry<crate::SessionHistoryMetadata>>,
|
pub session: protocol::SessionSnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerSessionCapture {
|
impl WorkerSessionCapture {
|
||||||
@@ -69,17 +69,9 @@ impl WorkerSessionCapture {
|
|||||||
segment_id: impl Into<String>,
|
segment_id: impl Into<String>,
|
||||||
log_entries: &[LogEntry],
|
log_entries: &[LogEntry],
|
||||||
) -> Result<Self, String> {
|
) -> 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 {
|
Ok(Self {
|
||||||
segment_id,
|
segment_id: segment_id.into(),
|
||||||
entries,
|
session: session_store::public_snapshot::project_current_session_snapshot(log_entries),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +107,7 @@ struct WorkspaceWorkerObservationListResponse {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct WorkspaceWorkerObservationCaptureResponse {
|
struct WorkspaceWorkerObservationCaptureResponse {
|
||||||
segment_id: String,
|
segment_id: String,
|
||||||
entries: Vec<serde_json::Value>,
|
session: protocol::SessionSnapshot,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct WorkspaceClientWorkerObservationProvider {
|
pub struct WorkspaceClientWorkerObservationProvider {
|
||||||
@@ -173,26 +165,9 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
|
|||||||
let body = workspace_response_body(response)?;
|
let body = workspace_response_body(response)?;
|
||||||
let response = serde_json::from_str::<WorkspaceWorkerObservationCaptureResponse>(&body)
|
let response = serde_json::from_str::<WorkspaceWorkerObservationCaptureResponse>(&body)
|
||||||
.map_err(|error| WorkerObservationError::Unavailable(error.to_string()))?;
|
.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 {
|
Ok(WorkerSessionCapture {
|
||||||
segment_id,
|
segment_id: response.segment_id,
|
||||||
entries: typed_entries,
|
session: response.session,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -420,16 +395,9 @@ impl WorkerObservationProvider for SpawnedSubWorkerObservationProvider {
|
|||||||
.get_internal(name)
|
.get_internal(name)
|
||||||
.ok_or(WorkerObservationError::NotFound)?;
|
.ok_or(WorkerObservationError::NotFound)?;
|
||||||
let entries = record.session.entries();
|
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 {
|
Ok(WorkerSessionCapture {
|
||||||
segment_id: format!("subworker:{name}"),
|
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)
|
.capture_worker_session(subject)
|
||||||
.await
|
.await
|
||||||
.map_err(tool_error)?;
|
.map_err(tool_error)?;
|
||||||
Ok(SessionCapture::from_history_entries(
|
Ok(SessionCapture::from_session_snapshot(
|
||||||
capture.segment_id,
|
capture.segment_id,
|
||||||
capture.entries,
|
capture.session,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,16 +767,43 @@ mod tests {
|
|||||||
.clone()
|
.clone()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, item)| {
|
.filter_map(|(index, item)| {
|
||||||
let mut metadata = crate::SessionHistoryMetadata::legacy_unknown();
|
let data = match item {
|
||||||
metadata.entry_id =
|
Item::Message { role, content, .. } => {
|
||||||
session_store::LoggedSessionHistoryEntryId(format!("fake-{index:08}"));
|
let role = match role {
|
||||||
agen::HistoryEntry::new(item, metadata)
|
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();
|
.collect();
|
||||||
Ok(WorkerSessionCapture {
|
Ok(WorkerSessionCapture {
|
||||||
segment_id: "segment".to_string(),
|
segment_id: "segment".to_string(),
|
||||||
entries,
|
session: protocol::SessionSnapshot { entries },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ impl From<HookTurnEndAction> for TurnEndAction {
|
|||||||
///
|
///
|
||||||
/// Hook code can use this handle only when the Worker host includes it in an
|
/// 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
|
/// 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
|
/// the matching system message visible to the model. It deliberately exposes no
|
||||||
/// raw `agen::Item`, history writer, event sender, `Worker`, `Engine`, or
|
/// raw `agen::Item`, history writer, event sender, `Worker`, `Engine`, or
|
||||||
/// notification buffer.
|
/// notification buffer.
|
||||||
|
|||||||
@@ -539,9 +539,9 @@ mod tests {
|
|||||||
text: "done".into(),
|
text: "done".into(),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
let assistant_entry = LogEntry::AssistantItem {
|
let assistant_entry = LogEntry::AnnotatedAssistantItem {
|
||||||
ts: 1,
|
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();
|
let in_flight_guard = in_flight.snapshot_guard();
|
||||||
@@ -593,9 +593,9 @@ mod tests {
|
|||||||
text: "done".into(),
|
text: "done".into(),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
let assistant_entry = LogEntry::AssistantItem {
|
let assistant_entry = LogEntry::AnnotatedAssistantItem {
|
||||||
ts: 1,
|
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, || {
|
in_flight.clear_for_committed_item_then(&assistant_item, || {
|
||||||
@@ -608,7 +608,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
entries_snapshot.as_slice(),
|
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());
|
assert!(in_flight_snapshot.is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ enum InternalWorkerSessionCommand {
|
|||||||
/// task; protocol access is consumed only by the owning parent registry.
|
/// task; protocol access is consumed only by the owning parent registry.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct InternalWorkerSessionSnapshot {
|
pub(crate) struct InternalWorkerSessionSnapshot {
|
||||||
pub entries: Vec<LogEntry>,
|
pub session: protocol::SessionSnapshot,
|
||||||
pub status: WorkerStatus,
|
pub status: WorkerStatus,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub in_flight: InFlightSnapshot,
|
pub in_flight: InFlightSnapshot,
|
||||||
@@ -402,7 +402,7 @@ impl InternalWorkerSessionHandle {
|
|||||||
(entries, snapshot_from_guard(&guard))
|
(entries, snapshot_from_guard(&guard))
|
||||||
};
|
};
|
||||||
InternalWorkerSessionSnapshot {
|
InternalWorkerSessionSnapshot {
|
||||||
entries,
|
session: session_store::public_snapshot::project_current_session_snapshot(&entries),
|
||||||
status: match self.status() {
|
status: match self.status() {
|
||||||
InternalWorkerSessionStatus::Running => WorkerStatus::Running,
|
InternalWorkerSessionStatus::Running => WorkerStatus::Running,
|
||||||
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
|
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ pub(crate) struct WorkerInterceptor {
|
|||||||
pending_notifies: NotifyBuffer,
|
pending_notifies: NotifyBuffer,
|
||||||
/// Submit-scoped stash of resolver-produced typed system items.
|
/// Submit-scoped stash of resolver-produced typed system items.
|
||||||
/// Drained inside `on_prompt_submit`, committed as
|
/// 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
|
/// returned to the worker as `Item::system_message` via
|
||||||
/// `PromptAction::ContinueWith`. Populated by `Worker::run`
|
/// `PromptAction::ContinueWith`. Populated by `Worker::run`
|
||||||
/// immediately before handing off to the worker.
|
/// immediately before handing off to the worker.
|
||||||
@@ -71,7 +71,7 @@ pub(crate) struct WorkerInterceptor {
|
|||||||
/// Workspace scope associated with Prompt projection provenance.
|
/// Workspace scope associated with Prompt projection provenance.
|
||||||
prompt_workspace_id: Option<String>,
|
prompt_workspace_id: Option<String>,
|
||||||
/// Type-erased commit handle. The interceptor uses it to commit
|
/// 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
|
/// returning the corresponding `Item::system_message`s up to the
|
||||||
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
/// worker. `None` in tests / `Worker::new` paths where no writer is
|
||||||
/// attached.
|
/// attached.
|
||||||
@@ -142,7 +142,7 @@ impl WorkerInterceptor {
|
|||||||
self
|
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
|
/// entry through the attached writer (no-op when no writer is
|
||||||
/// wired). Sync — writes complete before the matching
|
/// wired). Sync — writes complete before the matching
|
||||||
/// `Item::system_message`s reach the worker via
|
/// `Item::system_message`s reach the worker via
|
||||||
@@ -540,7 +540,6 @@ mod tests {
|
|||||||
entry: session_store::LogEntry,
|
entry: session_store::LogEntry,
|
||||||
) -> Result<(), session_store::StoreError> {
|
) -> Result<(), session_store::StoreError> {
|
||||||
let item = match entry {
|
let item = match entry {
|
||||||
session_store::LogEntry::SystemItem { item, .. } => Some(item),
|
|
||||||
session_store::LogEntry::AnnotatedSystemItem { entry, .. } => Some(entry.item),
|
session_store::LogEntry::AnnotatedSystemItem { entry, .. } => Some(entry.item),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! `WorkerInterceptor::pending_history_appends`, which the Engine calls
|
//! `WorkerInterceptor::pending_history_appends`, which the Engine calls
|
||||||
//! at the head of each turn loop iteration. The drain renders each
|
//! at the head of each turn loop iteration. The drain renders each
|
||||||
//! pending entry into a typed `SystemItem` (with the `notify_wrapper`
|
//! 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
|
//! the session-log sink, and returns the corresponding
|
||||||
//! `Item::system_message`s for the worker to append to its
|
//! `Item::system_message`s for the worker to append to its
|
||||||
//! persistent history.
|
//! persistent history.
|
||||||
|
|||||||
@@ -29,17 +29,12 @@ pub fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtoco
|
|||||||
|
|
||||||
pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
|
pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
|
||||||
match entry {
|
match entry {
|
||||||
entry @ (LogEntry::SegmentStart { .. } | LogEntry::AnnotatedSegmentStart { .. }) => {
|
entry @ LogEntry::AnnotatedSegmentStart { .. } => {
|
||||||
let value = serde_json::to_value(&entry).expect("LogEntry is Serialize");
|
let session =
|
||||||
Some(Event::SegmentRotated { entry: value })
|
session_store::public_snapshot::project_current_session_snapshot(&[entry]);
|
||||||
}
|
Some(Event::SegmentRotated { session })
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
|
LogEntry::AnnotatedUserInput { segments, .. } => Some(Event::UserMessage { segments }),
|
||||||
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||||
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
|
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
|
||||||
Some(Event::SystemItem { item: value })
|
Some(Event::SystemItem { item: value })
|
||||||
@@ -88,9 +83,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn user_input_log_entry_maps_to_user_message_event() {
|
fn user_input_log_entry_maps_to_user_message_event() {
|
||||||
let segments = vec![protocol::Segment::text("hello from log")];
|
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(),
|
ts: session_store::segment_log::now_millis(),
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
|
history: vec![crate::session_history::test_logged_history_entry(
|
||||||
|
agen::Item::user_message("hello from log"),
|
||||||
|
)],
|
||||||
segments: segments.clone(),
|
segments: segments.clone(),
|
||||||
})
|
})
|
||||||
.expect("UserInput must be live-relevant");
|
.expect("UserInput must be live-relevant");
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: WorkerHandle)
|
|||||||
let mut writer = JsonLineWriter::new(writer);
|
let mut writer = JsonLineWriter::new(writer);
|
||||||
|
|
||||||
// Hold the in-flight stream lock while taking the session-log mirror
|
// 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
|
// so a finalized assistant block must be observed either as an already
|
||||||
// committed entry or as the still-present in-flight block. This lock
|
// committed entry or as the still-present in-flight block. This lock
|
||||||
// order matches `append_entry` (in-flight clear before sink publish) and
|
// order matches `append_entry` (in-flight clear before sink publish) and
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ struct SinkInner {
|
|||||||
/// Broadcast channel for live entry updates. The same `Sender`
|
/// Broadcast channel for live entry updates. The same `Sender`
|
||||||
/// survives session swaps so existing subscribers keep their
|
/// survives session swaps so existing subscribers keep their
|
||||||
/// receiver — they observe the swap as a freshly broadcast
|
/// 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>,
|
broadcast_tx: broadcast::Sender<LogEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,9 +89,9 @@ impl SegmentLogSink {
|
|||||||
///
|
///
|
||||||
/// Live broadcast fires for committed session-log entries that
|
/// Live broadcast fires for committed session-log entries that
|
||||||
/// socket clients must see in log order:
|
/// socket clients must see in log order:
|
||||||
/// - `LogEntry::SegmentStart` → `Event::SegmentRotated` on the wire.
|
/// - `LogEntry::AnnotatedSegmentStart` → `Event::SegmentRotated` on the wire.
|
||||||
/// - `LogEntry::UserInput` → `Event::UserMessage`.
|
/// - `LogEntry::AnnotatedUserInput` → `Event::UserMessage`.
|
||||||
/// - `LogEntry::SystemItem` → `Event::SystemItem`.
|
/// - `LogEntry::AnnotatedSystemItem` → `Event::SystemItem`.
|
||||||
/// - `LogEntry::Invoke` → `Event::InvokeStart`.
|
/// - `LogEntry::Invoke` → `Event::InvokeStart`.
|
||||||
/// Everything else (AssistantItem, ToolResult, TurnEnd,
|
/// Everything else (AssistantItem, ToolResult, TurnEnd,
|
||||||
/// RunCompleted, RunErrored, PausedTurnAbandoned, LlmUsage, Extension,
|
/// RunCompleted, RunErrored, PausedTurnAbandoned, LlmUsage, Extension,
|
||||||
@@ -120,11 +120,8 @@ impl SegmentLogSink {
|
|||||||
fn is_live_relevant(entry: &LogEntry) -> bool {
|
fn is_live_relevant(entry: &LogEntry) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
entry,
|
entry,
|
||||||
LogEntry::SegmentStart { .. }
|
LogEntry::AnnotatedSegmentStart { .. }
|
||||||
| LogEntry::AnnotatedSegmentStart { .. }
|
|
||||||
| LogEntry::UserInput { .. }
|
|
||||||
| LogEntry::AnnotatedUserInput { .. }
|
| LogEntry::AnnotatedUserInput { .. }
|
||||||
| LogEntry::SystemItem { .. }
|
|
||||||
| LogEntry::AnnotatedSystemItem { .. }
|
| LogEntry::AnnotatedSystemItem { .. }
|
||||||
| LogEntry::Invoke { .. }
|
| LogEntry::Invoke { .. }
|
||||||
)
|
)
|
||||||
@@ -132,7 +129,7 @@ impl SegmentLogSink {
|
|||||||
|
|
||||||
/// Atomically swap the mirror to `[initial]` and broadcast the new
|
/// Atomically swap the mirror to `[initial]` and broadcast the new
|
||||||
/// session-start entry. Used during compaction / fork: 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
|
/// session, and existing subscribers transition by replaying it
|
||||||
/// like any other live entry.
|
/// like any other live entry.
|
||||||
///
|
///
|
||||||
@@ -234,7 +231,7 @@ mod tests {
|
|||||||
use session_store::segment_log::now_millis;
|
use session_store::segment_log::now_millis;
|
||||||
|
|
||||||
fn session_start() -> LogEntry {
|
fn session_start() -> LogEntry {
|
||||||
LogEntry::SegmentStart {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
ts: now_millis(),
|
ts: now_millis(),
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -253,9 +250,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn user_input(text: &str) -> LogEntry {
|
fn user_input(text: &str) -> LogEntry {
|
||||||
LogEntry::UserInput {
|
LogEntry::AnnotatedUserInput {
|
||||||
ts: now_millis(),
|
ts: now_millis(),
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
|
history: vec![crate::session_history::test_logged_history_entry(
|
||||||
|
agen::Item::user_message(text),
|
||||||
|
)],
|
||||||
segments: vec![protocol::Segment::Text {
|
segments: vec![protocol::Segment::Text {
|
||||||
content: text.to_owned(),
|
content: text.to_owned(),
|
||||||
}],
|
}],
|
||||||
@@ -270,7 +270,10 @@ mod tests {
|
|||||||
|
|
||||||
let (snapshot, mut rx) = sink.subscribe_with_snapshot();
|
let (snapshot, mut rx) = sink.subscribe_with_snapshot();
|
||||||
assert_eq!(snapshot.len(), 2);
|
assert_eq!(snapshot.len(), 2);
|
||||||
assert!(matches!(snapshot[0], LogEntry::SegmentStart { .. }));
|
assert!(matches!(
|
||||||
|
snapshot[0],
|
||||||
|
LogEntry::AnnotatedSegmentStart { .. }
|
||||||
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
snapshot[1],
|
snapshot[1],
|
||||||
LogEntry::TurnEnd { turn_count: 1, .. }
|
LogEntry::TurnEnd { turn_count: 1, .. }
|
||||||
@@ -279,13 +282,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn notification_entry(text: &str) -> LogEntry {
|
fn notification_entry(text: &str) -> LogEntry {
|
||||||
LogEntry::SystemItem {
|
LogEntry::AnnotatedSystemItem {
|
||||||
ts: now_millis(),
|
ts: now_millis(),
|
||||||
item: session_store::SystemItem::Notification {
|
entry: crate::session_history::test_logged_system_entry(
|
||||||
message: text.to_owned(),
|
session_store::SystemItem::Notification {
|
||||||
body: format!("[Notification] {text}"),
|
message: text.to_owned(),
|
||||||
prompt_provenance: None,
|
body: format!("[Notification] {text}"),
|
||||||
},
|
prompt_provenance: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +310,7 @@ mod tests {
|
|||||||
// for Event::UserMessage.
|
// for Event::UserMessage.
|
||||||
sink.publish(user_input("hi from log"));
|
sink.publish(user_input("hi from log"));
|
||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(LogEntry::UserInput { segments, .. }) => {
|
Ok(LogEntry::AnnotatedUserInput { segments, .. }) => {
|
||||||
assert_eq!(segments.len(), 1);
|
assert_eq!(segments.len(), 1);
|
||||||
}
|
}
|
||||||
other => panic!("expected UserInput, got {other:?}"),
|
other => panic!("expected UserInput, got {other:?}"),
|
||||||
@@ -314,7 +319,7 @@ mod tests {
|
|||||||
// SystemItem is live-relevant.
|
// SystemItem is live-relevant.
|
||||||
sink.publish(notification_entry("hi"));
|
sink.publish(notification_entry("hi"));
|
||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(LogEntry::SystemItem { .. }) => {}
|
Ok(LogEntry::AnnotatedSystemItem { .. }) => {}
|
||||||
other => panic!("expected SystemItem, got {other:?}"),
|
other => panic!("expected SystemItem, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +337,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(snapshot.len(), 1);
|
assert_eq!(snapshot.len(), 1);
|
||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(LogEntry::SystemItem { .. }) => {}
|
Ok(LogEntry::AnnotatedSystemItem { .. }) => {}
|
||||||
other => panic!("unexpected: {other:?}"),
|
other => panic!("unexpected: {other:?}"),
|
||||||
}
|
}
|
||||||
assert!(rx.try_recv().is_err());
|
assert!(rx.try_recv().is_err());
|
||||||
@@ -348,13 +353,16 @@ mod tests {
|
|||||||
sink.reset_with_initial(session_start());
|
sink.reset_with_initial(session_start());
|
||||||
|
|
||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(LogEntry::SegmentStart { .. }) => {}
|
Ok(LogEntry::AnnotatedSegmentStart { .. }) => {}
|
||||||
other => panic!("expected SegmentStart broadcast, got {other:?}"),
|
other => panic!("expected SegmentStart broadcast, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
let (post_snapshot, _) = sink.subscribe_with_snapshot();
|
let (post_snapshot, _) = sink.subscribe_with_snapshot();
|
||||||
assert_eq!(post_snapshot.len(), 1);
|
assert_eq!(post_snapshot.len(), 1);
|
||||||
assert!(matches!(post_snapshot[0], LogEntry::SegmentStart { .. }));
|
assert!(matches!(
|
||||||
|
post_snapshot[0],
|
||||||
|
LogEntry::AnnotatedSegmentStart { .. }
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
|
use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
|
||||||
use agen::{HistoryEntry, Item, Role};
|
use agen::{HistoryEntry, Item, Role};
|
||||||
|
use protocol::{
|
||||||
|
SessionContentPart, SessionEntryProvenance, SessionMessageRole, SessionSnapshot,
|
||||||
|
SessionSnapshotEntryData,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
const DEFAULT_SEARCH_LIMIT: usize = 20;
|
const DEFAULT_SEARCH_LIMIT: usize = 20;
|
||||||
@@ -105,7 +109,7 @@ impl ToolPart {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct OverviewItem {
|
pub(crate) struct OverviewItem {
|
||||||
pub id: SessionEntryRef,
|
pub id: SessionEntryRef,
|
||||||
pub origin: WorkerHistoryProvenance,
|
pub origin: SessionEntryProvenance,
|
||||||
pub entry_range: [u64; 2],
|
pub entry_range: [u64; 2],
|
||||||
pub kind: ReferenceKind,
|
pub kind: ReferenceKind,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -116,7 +120,7 @@ pub(crate) struct OverviewItem {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct ReferenceEntry {
|
pub(crate) struct ReferenceEntry {
|
||||||
pub id: SessionEntryRef,
|
pub id: SessionEntryRef,
|
||||||
pub origin: WorkerHistoryProvenance,
|
pub origin: SessionEntryProvenance,
|
||||||
pub entry_range: [u64; 2],
|
pub entry_range: [u64; 2],
|
||||||
pub kind: ReferenceKind,
|
pub kind: ReferenceKind,
|
||||||
pub tool_part: Option<ToolPart>,
|
pub tool_part: Option<ToolPart>,
|
||||||
@@ -142,7 +146,7 @@ pub(crate) struct SearchOptions {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct SearchHit {
|
pub(crate) struct SearchHit {
|
||||||
pub id: SessionEntryRef,
|
pub id: SessionEntryRef,
|
||||||
pub origin: WorkerHistoryProvenance,
|
pub origin: SessionEntryProvenance,
|
||||||
pub kind: ReferenceKind,
|
pub kind: ReferenceKind,
|
||||||
pub tool_part: Option<ToolPart>,
|
pub tool_part: Option<ToolPart>,
|
||||||
pub tool_name: Option<String>,
|
pub tool_name: Option<String>,
|
||||||
@@ -188,7 +192,7 @@ impl Default for ReadOptions {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct ReadEntry {
|
pub(crate) struct ReadEntry {
|
||||||
pub id: SessionEntryRef,
|
pub id: SessionEntryRef,
|
||||||
pub origin: WorkerHistoryProvenance,
|
pub origin: SessionEntryProvenance,
|
||||||
pub kind: ReferenceKind,
|
pub kind: ReferenceKind,
|
||||||
pub tool_part: Option<ToolPart>,
|
pub tool_part: Option<ToolPart>,
|
||||||
pub tool_name: Option<String>,
|
pub tool_name: Option<String>,
|
||||||
@@ -207,7 +211,7 @@ pub(crate) struct ReadResult {
|
|||||||
pub(crate) struct SessionEntryEvidence {
|
pub(crate) struct SessionEntryEvidence {
|
||||||
pub segment_id: String,
|
pub segment_id: String,
|
||||||
pub entry_ref: SessionEntryRef,
|
pub entry_ref: SessionEntryRef,
|
||||||
pub origin: WorkerHistoryProvenance,
|
pub origin: SessionEntryProvenance,
|
||||||
pub entry_range: [u64; 2],
|
pub entry_range: [u64; 2],
|
||||||
pub kind: ReferenceKind,
|
pub kind: ReferenceKind,
|
||||||
pub tool_part: Option<ToolPart>,
|
pub tool_part: Option<ToolPart>,
|
||||||
@@ -216,35 +220,116 @@ pub(crate) struct SessionEntryEvidence {
|
|||||||
pub excerpt: String,
|
pub excerpt: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct CapturedHistoryEntry {
|
||||||
|
item: Item,
|
||||||
|
entry_id: session_store::LoggedSessionHistoryEntryId,
|
||||||
|
origin: SessionEntryProvenance,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct SessionCapture {
|
pub(crate) struct SessionCapture {
|
||||||
segment_id: String,
|
segment_id: String,
|
||||||
entries: Arc<Vec<HistoryEntry<SessionHistoryMetadata>>>,
|
entries: Arc<Vec<CapturedHistoryEntry>>,
|
||||||
overview: Vec<OverviewItem>,
|
overview: Vec<OverviewItem>,
|
||||||
index: Vec<ReferenceEntry>,
|
index: Vec<ReferenceEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionCapture {
|
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 {
|
pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self {
|
||||||
let entries = items
|
let entries = items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, item)| {
|
.map(|(index, item)| CapturedHistoryEntry {
|
||||||
let mut metadata = SessionHistoryMetadata::legacy_unknown();
|
item,
|
||||||
metadata.entry_id =
|
entry_id: session_store::LoggedSessionHistoryEntryId(format!("{index:08}")),
|
||||||
session_store::LoggedSessionHistoryEntryId(format!("{index:08}"));
|
origin: SessionEntryProvenance::LegacyUnknown,
|
||||||
HistoryEntry::new(item, metadata)
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Self::from_history_entries(segment_id, entries)
|
Self::from_captured_entries(segment_id, entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn from_history_entries(
|
pub(crate) fn from_history_entries(
|
||||||
segment_id: impl Into<String>,
|
segment_id: impl Into<String>,
|
||||||
entries: Vec<HistoryEntry<SessionHistoryMetadata>>,
|
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 {
|
) -> Self {
|
||||||
let segment_id = segment_id.into();
|
let segment_id = segment_id.into();
|
||||||
let entries = Arc::new(entries);
|
|
||||||
let mut overview = Vec::new();
|
let mut overview = Vec::new();
|
||||||
let mut index = Vec::new();
|
let mut index = Vec::new();
|
||||||
|
|
||||||
@@ -253,7 +338,7 @@ impl SessionCapture {
|
|||||||
let entry_range = [idx as u64, idx as u64];
|
let entry_range = [idx as u64, idx as u64];
|
||||||
match item {
|
match item {
|
||||||
Item::Message { role, content, .. } => {
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
let text = content
|
let text = content
|
||||||
@@ -263,10 +348,10 @@ impl SessionCapture {
|
|||||||
.join("");
|
.join("");
|
||||||
let label = format!("{} message", kind.as_str());
|
let label = format!("{} message", kind.as_str());
|
||||||
let summary = truncate_chars(&text, 240);
|
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 {
|
index.push(ReferenceEntry {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
origin: entry.annotation.origin.clone(),
|
origin: entry.origin.clone(),
|
||||||
entry_range,
|
entry_range,
|
||||||
kind,
|
kind,
|
||||||
tool_part: None,
|
tool_part: None,
|
||||||
@@ -278,7 +363,7 @@ impl SessionCapture {
|
|||||||
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
|
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
|
||||||
overview.push(OverviewItem {
|
overview.push(OverviewItem {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
origin: entry.annotation.origin.clone(),
|
origin: entry.origin.clone(),
|
||||||
entry_range,
|
entry_range,
|
||||||
kind,
|
kind,
|
||||||
label,
|
label,
|
||||||
@@ -292,8 +377,8 @@ impl SessionCapture {
|
|||||||
} => {
|
} => {
|
||||||
let text = format!("{name}\n{arguments}");
|
let text = format!("{name}\n{arguments}");
|
||||||
index.push(ReferenceEntry {
|
index.push(ReferenceEntry {
|
||||||
id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
|
id: SessionEntryRef::from_history_entry_id(&entry.entry_id),
|
||||||
origin: entry.annotation.origin.clone(),
|
origin: entry.origin.clone(),
|
||||||
entry_range,
|
entry_range,
|
||||||
kind: ReferenceKind::Tool,
|
kind: ReferenceKind::Tool,
|
||||||
tool_part: Some(ToolPart::Input),
|
tool_part: Some(ToolPart::Input),
|
||||||
@@ -319,8 +404,8 @@ impl SessionCapture {
|
|||||||
content.as_deref().unwrap_or_default(),
|
content.as_deref().unwrap_or_default(),
|
||||||
);
|
);
|
||||||
index.push(ReferenceEntry {
|
index.push(ReferenceEntry {
|
||||||
id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
|
id: SessionEntryRef::from_history_entry_id(&entry.entry_id),
|
||||||
origin: entry.annotation.origin.clone(),
|
origin: entry.origin.clone(),
|
||||||
entry_range,
|
entry_range,
|
||||||
kind: ReferenceKind::Tool,
|
kind: ReferenceKind::Tool,
|
||||||
tool_part: Some(ToolPart::Output),
|
tool_part: Some(ToolPart::Output),
|
||||||
@@ -360,7 +445,7 @@ impl SessionCapture {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
segment_id,
|
segment_id,
|
||||||
entries,
|
entries: Arc::new(entries),
|
||||||
overview,
|
overview,
|
||||||
index,
|
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(
|
fn message_reference_kind(
|
||||||
origin: &WorkerHistoryProvenance,
|
origin: &SessionEntryProvenance,
|
||||||
provider_role: &Role,
|
provider_role: &Role,
|
||||||
) -> Option<ReferenceKind> {
|
) -> Option<ReferenceKind> {
|
||||||
match origin {
|
match origin {
|
||||||
WorkerHistoryProvenance::HumanInput { .. }
|
SessionEntryProvenance::HumanInput | SessionEntryProvenance::WorkerInput => {
|
||||||
| WorkerHistoryProvenance::WorkerInput { .. } => Some(ReferenceKind::User),
|
Some(ReferenceKind::User)
|
||||||
WorkerHistoryProvenance::ModelOutput { .. } => Some(ReferenceKind::Assistant),
|
}
|
||||||
WorkerHistoryProvenance::ToolOutput { .. } => Some(ReferenceKind::Tool),
|
SessionEntryProvenance::ModelOutput => Some(ReferenceKind::Assistant),
|
||||||
WorkerHistoryProvenance::LegacyUnknown => match provider_role {
|
SessionEntryProvenance::ToolOutput => Some(ReferenceKind::Tool),
|
||||||
|
SessionEntryProvenance::LegacyUnknown => match provider_role {
|
||||||
Role::User => Some(ReferenceKind::User),
|
Role::User => Some(ReferenceKind::User),
|
||||||
Role::Assistant => Some(ReferenceKind::Assistant),
|
Role::Assistant => Some(ReferenceKind::Assistant),
|
||||||
Role::System => None,
|
Role::System => None,
|
||||||
},
|
},
|
||||||
// Flow/backend/system content remains out of the observation surface
|
// Flow/backend/system content remains out of the observation surface
|
||||||
// even when represented with a provider user/system role.
|
// even when represented with a provider user/system role.
|
||||||
WorkerHistoryProvenance::FlowInstruction { .. }
|
SessionEntryProvenance::FlowInstruction
|
||||||
| WorkerHistoryProvenance::BackendInstruction { .. }
|
| SessionEntryProvenance::BackendInstruction
|
||||||
| WorkerHistoryProvenance::DerivedSummary => None,
|
| SessionEntryProvenance::DerivedSummary => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -658,13 +759,13 @@ mod tests {
|
|||||||
assert_eq!(overview.len(), 1);
|
assert_eq!(overview.len(), 1);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
overview[0].origin,
|
overview[0].origin,
|
||||||
WorkerHistoryProvenance::HumanInput { .. }
|
SessionEntryProvenance::HumanInput
|
||||||
));
|
));
|
||||||
let evidence = capture.evidence_for(overview[0].id.as_str()).unwrap();
|
let evidence = capture.evidence_for(overview[0].id.as_str()).unwrap();
|
||||||
assert!(evidence.excerpt.ends_with("remember my preference"));
|
assert!(evidence.excerpt.ends_with("remember my preference"));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
evidence.origin,
|
evidence.origin,
|
||||||
WorkerHistoryProvenance::HumanInput { .. }
|
SessionEntryProvenance::HumanInput
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
//! retained only as explicit `LegacyUnknown` entries.
|
//! retained only as explicit `LegacyUnknown` entries.
|
||||||
|
|
||||||
use agen::{HistoryEntry, Item};
|
use agen::{HistoryEntry, Item};
|
||||||
use protocol::Segment;
|
|
||||||
use session_store::{
|
use session_store::{
|
||||||
LogEntry, LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
|
LogEntry, LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
|
||||||
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedWorkerSubject, SegmentId,
|
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedWorkerSubject, SegmentId,
|
||||||
@@ -18,6 +17,32 @@ pub type WorkerHistoryProvenance = LoggedSessionHistoryOrigin;
|
|||||||
pub type SessionHistoryDerivation = LoggedHistoryDerivation;
|
pub type SessionHistoryDerivation = LoggedHistoryDerivation;
|
||||||
pub type WorkerSubjectSnapshot = LoggedWorkerSubject;
|
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 {
|
pub(crate) fn worker_subject(session_id: SessionId) -> WorkerSubjectSnapshot {
|
||||||
WorkerSubjectSnapshot {
|
WorkerSubjectSnapshot {
|
||||||
workspace_id: None,
|
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> {
|
fn from_logged(entry: &LoggedHistoryEntry) -> HistoryEntry<SessionHistoryMetadata> {
|
||||||
HistoryEntry::new(Item::from(entry.item.clone()), entry.metadata.clone())
|
HistoryEntry::new(Item::from(entry.item.clone()), entry.metadata.clone())
|
||||||
}
|
}
|
||||||
@@ -74,32 +95,15 @@ pub(crate) fn restore_history_entries(
|
|||||||
LogEntry::AnnotatedSegmentStart { history: seed, .. } => {
|
LogEntry::AnnotatedSegmentStart { history: seed, .. } => {
|
||||||
history = seed.iter().map(from_logged).collect();
|
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, .. } => {
|
LogEntry::AnnotatedUserInput { history: input, .. } => {
|
||||||
history.extend(input.iter().map(from_logged))
|
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::AnnotatedAssistantItem { entry, .. }
|
||||||
| LogEntry::AnnotatedToolResult { entry, .. } => history.push(from_logged(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(
|
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
|
||||||
entry.item.to_history_item(),
|
entry.item.to_history_item(),
|
||||||
entry.metadata.clone(),
|
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 {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use agen::llm_client::RequestConfig;
|
use agen::llm_client::RequestConfig;
|
||||||
|
use protocol::Segment;
|
||||||
use session_store::LogEntry;
|
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]
|
#[test]
|
||||||
fn typed_flow_and_unknown_caller_input_round_trip_without_role_inference() {
|
fn typed_flow_and_unknown_caller_input_round_trip_without_role_inference() {
|
||||||
let session_id = SessionId::now_v7();
|
let session_id = SessionId::now_v7();
|
||||||
|
|||||||
@@ -278,7 +278,21 @@ mod tests {
|
|||||||
|
|
||||||
fn snapshot(entries: Vec<serde_json::Value>) -> Event {
|
fn snapshot(entries: Vec<serde_json::Value>) -> Event {
|
||||||
Event::Snapshot {
|
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 {
|
greeting: Greeting {
|
||||||
worker_name: "server".into(),
|
worker_name: "server".into(),
|
||||||
cwd: "/tmp".into(),
|
cwd: "/tmp".into(),
|
||||||
|
|||||||
@@ -112,8 +112,12 @@ impl InternalSpawnedWorkerRecord {
|
|||||||
fn stop_summary(&self) -> SubWorkerStopSummary {
|
fn stop_summary(&self) -> SubWorkerStopSummary {
|
||||||
let mut counts = BTreeMap::<String, u64>::new();
|
let mut counts = BTreeMap::<String, u64>::new();
|
||||||
for entry in self.session.entries() {
|
for entry in self.session.entries() {
|
||||||
if let session_store::LogEntry::AssistantItem {
|
if let session_store::LogEntry::AnnotatedAssistantItem {
|
||||||
item: LoggedItem::ToolCall { name, .. },
|
entry:
|
||||||
|
session_store::LoggedHistoryEntry {
|
||||||
|
item: LoggedItem::ToolCall { name, .. },
|
||||||
|
..
|
||||||
|
},
|
||||||
..
|
..
|
||||||
} = entry
|
} = entry
|
||||||
{
|
{
|
||||||
@@ -806,11 +810,7 @@ fn internal_worker_snapshot(
|
|||||||
InternalWorkerSnapshot {
|
InternalWorkerSnapshot {
|
||||||
worker,
|
worker,
|
||||||
revision,
|
revision,
|
||||||
entries: snapshot
|
session: snapshot.session,
|
||||||
.entries
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|entry| serde_json::to_value(entry).ok())
|
|
||||||
.collect(),
|
|
||||||
status: snapshot.status,
|
status: snapshot.status,
|
||||||
error: snapshot.error,
|
error: snapshot.error,
|
||||||
in_flight: snapshot.in_flight,
|
in_flight: snapshot.in_flight,
|
||||||
@@ -1028,11 +1028,16 @@ mod tests {
|
|||||||
&& worker.parent_session_id.as_deref() == Some("parent-session")
|
&& worker.parent_session_id.as_deref() == Some("parent-session")
|
||||||
&& matches!(*event, Event::TextDone { ref text } if text == "answer")
|
&& matches!(*event, Event::TextDone { ref text } if text == "answer")
|
||||||
));
|
));
|
||||||
record.session.publish_test_entry(LogEntry::UserInput {
|
record
|
||||||
ts: 1,
|
.session
|
||||||
segments: vec![protocol::Segment::text("question")],
|
.publish_test_entry(LogEntry::AnnotatedUserInput {
|
||||||
extensions: Vec::new(),
|
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())
|
let committed = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1045,7 +1050,7 @@ mod tests {
|
|||||||
let snapshots = registry.internal_worker_snapshots();
|
let snapshots = registry.internal_worker_snapshots();
|
||||||
assert_eq!(snapshots.len(), 1);
|
assert_eq!(snapshots.len(), 1);
|
||||||
assert_eq!(snapshots[0].revision, 2);
|
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");
|
record.session.emit_test_text_delta("partial");
|
||||||
let streamed = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
|
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;
|
let (mut record, _events) = record("child", InternalWorkerVisibility::ParentClient).await;
|
||||||
record.change_tracker = Some(tracker);
|
record.change_tracker = Some(tracker);
|
||||||
for (index, name) in ["Read", "Read", "Grep"].into_iter().enumerate() {
|
for (index, name) in ["Read", "Read", "Grep"].into_iter().enumerate() {
|
||||||
record.session.publish_test_entry(LogEntry::AssistantItem {
|
record
|
||||||
ts: index as u64,
|
.session
|
||||||
item: LoggedItem::ToolCall {
|
.publish_test_entry(LogEntry::AnnotatedAssistantItem {
|
||||||
call_id: format!("call-{index}"),
|
ts: index as u64,
|
||||||
name: name.to_string(),
|
entry: crate::session_history::test_logged_history_entry(
|
||||||
arguments: "{}".to_string(),
|
LoggedItem::ToolCall {
|
||||||
},
|
call_id: format!("call-{index}"),
|
||||||
});
|
name: name.to_string(),
|
||||||
|
arguments: "{}".to_string(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
registry.start_protocol_forwarding(record.clone());
|
registry.start_protocol_forwarding(record.clone());
|
||||||
install_record(®istry, record);
|
install_record(®istry, record);
|
||||||
|
|||||||
@@ -960,9 +960,7 @@ mod tests {
|
|||||||
|
|
||||||
use crate::WorkspaceId;
|
use crate::WorkspaceId;
|
||||||
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
|
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::llm_client::{ClientError, LlmClient, Request};
|
||||||
use agen::{Item, Role};
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
use manifest::{AuthRef, ModelManifest, SchemeKind, WorkerManifest};
|
use manifest::{AuthRef, ModelManifest, SchemeKind, WorkerManifest};
|
||||||
@@ -1252,9 +1250,11 @@ extract_threshold = 4000
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(first_capture.entries.iter().map(|entry| &entry.item).any(|item| {
|
assert!(
|
||||||
matches!(item, Item::Message { role: Role::Assistant, content, .. } if content.iter().any(|part| matches!(part, ContentPart::Text { text } if text.contains("reviewed"))))
|
serde_json::to_string(&first_capture.session)
|
||||||
}));
|
.unwrap()
|
||||||
|
.contains("reviewed")
|
||||||
|
);
|
||||||
|
|
||||||
let send = (crate::spawn::comm_tools::sub_worker_send_tool(registry.clone()))().1;
|
let send = (crate::spawn::comm_tools::sub_worker_send_tool(registry.clone()))().1;
|
||||||
send.execute(
|
send.execute(
|
||||||
@@ -1274,7 +1274,7 @@ extract_threshold = 4000
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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);
|
fail_requests.store(true, Ordering::SeqCst);
|
||||||
send.execute(
|
send.execute(
|
||||||
|
|||||||
+51
-42
@@ -900,7 +900,6 @@ where
|
|||||||
self.state.increment_entries();
|
self.state.increment_entries();
|
||||||
if let Some(in_flight) = &self.in_flight {
|
if let Some(in_flight) = &self.in_flight {
|
||||||
let committed_item = match &entry {
|
let committed_item = match &entry {
|
||||||
LogEntry::AssistantItem { item, .. } => Some(item.clone()),
|
|
||||||
LogEntry::AnnotatedAssistantItem { entry, .. } => Some(entry.item.clone()),
|
LogEntry::AnnotatedAssistantItem { entry, .. } => Some(entry.item.clone()),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
@@ -1207,11 +1206,11 @@ pub struct Worker<C: LlmClient, St: Store> {
|
|||||||
memory_task: Option<JoinHandle<()>>,
|
memory_task: Option<JoinHandle<()>>,
|
||||||
/// Typed user submissions in submit order. K-th entry corresponds to
|
/// Typed user submissions in submit order. K-th entry corresponds to
|
||||||
/// the K-th `Item::user_message` in `worker.history()` (modulo seed
|
/// 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`,
|
/// are not preserved). Populated from log on `restore_from_manifest`,
|
||||||
/// appended after `save_user_input` on each `run`. Pre-`Event::Snapshot`
|
/// appended after `save_user_input` on each `run`. Pre-`Event::Snapshot`
|
||||||
/// this fed `WorkerSharedState.user_segments`; the new wire format
|
/// 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.
|
/// this remains purely an in-memory tracker for compact alignment.
|
||||||
user_segments: Vec<Vec<Segment>>,
|
user_segments: Vec<Vec<Segment>>,
|
||||||
/// Worker-side session-log mirror + broadcast sink. Populated alongside
|
/// Worker-side session-log mirror + broadcast sink. Populated alongside
|
||||||
@@ -1221,7 +1220,8 @@ pub struct Worker<C: LlmClient, St: Store> {
|
|||||||
sink: SegmentLogSink,
|
sink: SegmentLogSink,
|
||||||
/// `true` once `wire_history_persistence` has installed the
|
/// `true` once `wire_history_persistence` has installed the
|
||||||
/// `Engine::on_history_append` callback that commits each appended
|
/// `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
|
/// directly through the writer. Tests that drive `Worker::new` without
|
||||||
/// going through the controller leave this `false`; `persist_turn`
|
/// going through the controller leave this `false`; `persist_turn`
|
||||||
/// then walks the post-`history_before` slice inline so entries
|
/// 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
|
/// 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
|
/// through the writer. The controller calls this once per spawned
|
||||||
/// Worker after the worker is built; tests that drive `Worker::new` may
|
/// 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
|
/// opt in to the same wiring or leave it off (in which case
|
||||||
/// `persist_turn`'s inline fallback writes entries at turn end).
|
/// `persist_turn`'s inline fallback writes entries at turn end).
|
||||||
///
|
///
|
||||||
/// `user_message` items are skipped because they are committed
|
/// `user_message` items are skipped because they are committed
|
||||||
/// up-front via `commit_entry(LogEntry::UserInput { segments })`.
|
/// up-front via `commit_entry(LogEntry::AnnotatedUserInput { segments })`.
|
||||||
/// `role:system` items are committed as typed `LogEntry::SystemItem`
|
/// `role:system` items are committed as typed `LogEntry::AnnotatedSystemItem`
|
||||||
/// entries by their producers (for example `WorkerInterceptor` and
|
/// entries by their producers (for example `WorkerInterceptor` and
|
||||||
/// interrupted-turn prep) before they reach the worker's history, so this
|
/// interrupted-turn prep) before they reach the worker's history, so this
|
||||||
/// callback would otherwise double-write them.
|
/// 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) {
|
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(
|
return Err(RewindError::Invalid(
|
||||||
"rewind target is no longer a user message".into(),
|
"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>`.
|
/// Cheap clone via `Option<Clone>`.
|
||||||
/// Snapshot of the typed user segments tracked alongside worker
|
/// Snapshot of the typed user segments tracked alongside worker
|
||||||
/// history. The K-th entry corresponds to the K-th `Item::user_message`
|
/// history. The K-th entry corresponds to the K-th `Item::user_message`
|
||||||
/// derived from `LogEntry::UserInput` entries (post-compaction); seed
|
/// derived from `LogEntry::AnnotatedUserInput` entries (post-compaction); seed
|
||||||
/// history loaded via `SegmentStart.history` does not contribute,
|
/// history loaded via `AnnotatedSegmentStart.history` does not contribute,
|
||||||
/// which is acceptable because the original segments are unrecoverable.
|
/// which is acceptable because the original segments are unrecoverable.
|
||||||
pub fn user_segments(&self) -> &[Vec<Segment>] {
|
pub fn user_segments(&self) -> &[Vec<Segment>] {
|
||||||
&self.user_segments
|
&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
|
// slice from `history_before` inline so the test's
|
||||||
// `restore`-style assertions still see entries on disk.
|
// `restore`-style assertions still see entries on disk.
|
||||||
if !self.history_persistence_wired {
|
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()
|
.iter()
|
||||||
.map(|entry| entry.item.clone())
|
.map(to_logged_history_entry)
|
||||||
.collect();
|
.collect();
|
||||||
let ts = segment_log::now_millis();
|
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() {
|
if item.is_user_message() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -3551,7 +3552,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
) {
|
) {
|
||||||
continue;
|
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)?;
|
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();
|
let mut targets = Vec::new();
|
||||||
for (entry_index, entry) in entries.iter().enumerate() {
|
for (entry_index, entry) in entries.iter().enumerate() {
|
||||||
let (segments, ts) = match entry {
|
let (segments, ts) = match entry {
|
||||||
LogEntry::UserInput { segments, ts, .. }
|
LogEntry::AnnotatedUserInput { segments, ts, .. } => (segments, ts),
|
||||||
| LogEntry::AnnotatedUserInput { segments, ts, .. } => (segments, ts),
|
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
turn_index += 1;
|
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 {
|
fn suffix_has_tool_side_effects(entries: &[LogEntry]) -> bool {
|
||||||
entries.iter().any(|entry| match entry {
|
entries.iter().any(|entry| match entry {
|
||||||
LogEntry::ToolResult { .. } | LogEntry::AnnotatedToolResult { .. } => true,
|
LogEntry::AnnotatedToolResult { .. } => true,
|
||||||
LogEntry::AssistantItem { item, .. } => logged_item_is_tool_call(item),
|
|
||||||
LogEntry::AnnotatedAssistantItem { entry, .. } => logged_item_is_tool_call(&entry.item),
|
LogEntry::AnnotatedAssistantItem { entry, .. } => logged_item_is_tool_call(&entry.item),
|
||||||
_ => false,
|
_ => false,
|
||||||
})
|
})
|
||||||
@@ -7636,7 +7635,7 @@ mod build_summary_prompt_tests {
|
|||||||
);
|
);
|
||||||
assert!(checkpoint.is_none());
|
assert!(checkpoint.is_none());
|
||||||
|
|
||||||
let mut replacement_entries = vec![LogEntry::SegmentStart {
|
let mut replacement_entries = vec![LogEntry::AnnotatedSegmentStart {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
session_id: uuid::Uuid::nil(),
|
session_id: uuid::Uuid::nil(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
@@ -7964,9 +7963,12 @@ mod build_summary_prompt_tests {
|
|||||||
);
|
);
|
||||||
append_test_entry(
|
append_test_entry(
|
||||||
worker,
|
worker,
|
||||||
LogEntry::UserInput {
|
LogEntry::AnnotatedUserInput {
|
||||||
ts: ts + 1,
|
ts: ts + 1,
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
|
history: vec![crate::session_history::test_logged_history_entry(
|
||||||
|
Item::user_message(text),
|
||||||
|
)],
|
||||||
segments: vec![text_segment(text)],
|
segments: vec![text_segment(text)],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -7986,16 +7988,18 @@ mod build_summary_prompt_tests {
|
|||||||
append_user_turn(&worker, 20, "second message");
|
append_user_turn(&worker, 20, "second message");
|
||||||
append_test_entry(
|
append_test_entry(
|
||||||
&worker,
|
&worker,
|
||||||
LogEntry::ToolResult {
|
LogEntry::AnnotatedToolResult {
|
||||||
ts: 30,
|
ts: 30,
|
||||||
item: session_store::LoggedItem::ToolResult {
|
entry: crate::session_history::test_logged_history_entry(
|
||||||
call_id: "call-1".into(),
|
session_store::LoggedItem::ToolResult {
|
||||||
summary: "wrote a file".into(),
|
call_id: "call-1".into(),
|
||||||
content: None,
|
summary: "wrote a file".into(),
|
||||||
attachments: Vec::new(),
|
content: None,
|
||||||
disposition: Default::default(),
|
attachments: Vec::new(),
|
||||||
is_error: false,
|
disposition: Default::default(),
|
||||||
},
|
is_error: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -8029,16 +8033,18 @@ mod build_summary_prompt_tests {
|
|||||||
append_user_turn(&worker, 20, "second message");
|
append_user_turn(&worker, 20, "second message");
|
||||||
append_test_entry(
|
append_test_entry(
|
||||||
&worker,
|
&worker,
|
||||||
LogEntry::ToolResult {
|
LogEntry::AnnotatedToolResult {
|
||||||
ts: 30,
|
ts: 30,
|
||||||
item: session_store::LoggedItem::ToolResult {
|
entry: crate::session_history::test_logged_history_entry(
|
||||||
call_id: "call-1".into(),
|
session_store::LoggedItem::ToolResult {
|
||||||
summary: "wrote a file".into(),
|
call_id: "call-1".into(),
|
||||||
content: None,
|
summary: "wrote a file".into(),
|
||||||
attachments: Vec::new(),
|
content: None,
|
||||||
disposition: Default::default(),
|
attachments: Vec::new(),
|
||||||
is_error: false,
|
disposition: Default::default(),
|
||||||
},
|
is_error: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
|
let (head_entries, targets) = worker.list_rewind_targets().unwrap();
|
||||||
@@ -8456,9 +8462,9 @@ mod build_summary_prompt_tests {
|
|||||||
worker.wire_history_persistence();
|
worker.wire_history_persistence();
|
||||||
let dangling_call = Item::tool_call("call-1", "SideEffect", "{}");
|
let dangling_call = Item::tool_call("call-1", "SideEffect", "{}");
|
||||||
worker
|
worker
|
||||||
.commit_entry(LogEntry::AssistantItem {
|
.commit_entry(LogEntry::AnnotatedAssistantItem {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
item: dangling_call.clone().into(),
|
entry: crate::session_history::test_logged_history_entry(dangling_call.clone()),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
worker.set_history_for_test(vec![dangling_call]);
|
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.set_history_for_test(vec![evidence.clone()]);
|
||||||
worker
|
worker
|
||||||
.commit_entry(LogEntry::UserInput {
|
.commit_entry(LogEntry::AnnotatedUserInput {
|
||||||
ts: segment_log::now_millis(),
|
ts: segment_log::now_millis(),
|
||||||
extensions: vec![],
|
extensions: vec![],
|
||||||
|
history: vec![crate::session_history::test_logged_history_entry(
|
||||||
|
evidence.clone(),
|
||||||
|
)],
|
||||||
segments: vec![text_segment(
|
segments: vec![text_segment(
|
||||||
"The cancellation regression must leave this evidence available for retry.",
|
"The cancellation regression must leave this evidence available for retry.",
|
||||||
)],
|
)],
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ use worker::{Worker, WorkerController};
|
|||||||
|
|
||||||
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
|
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)]
|
#[derive(Clone)]
|
||||||
struct MockClient {
|
struct MockClient {
|
||||||
responses: Arc<Vec<Vec<LlmEvent>>>,
|
responses: Arc<Vec<Vec<LlmEvent>>>,
|
||||||
@@ -210,7 +221,6 @@ fn system_texts_in_sink_session_start(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|entry| entry.item)
|
.map(|entry| entry.item)
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
session_store::LogEntry::SegmentStart { history, .. } => history,
|
|
||||||
_ => continue,
|
_ => continue,
|
||||||
};
|
};
|
||||||
return history
|
return history
|
||||||
@@ -310,17 +320,14 @@ permission = "write"
|
|||||||
// Simulate a foreign writer appending to the same segment. This bumps
|
// Simulate a foreign writer appending to the same segment. This bumps
|
||||||
// the on-disk entry count past the Worker's own append tally without
|
// the on-disk entry count past the Worker's own append tally without
|
||||||
// updating the Worker's `entries_written`.
|
// updating the Worker's `entries_written`.
|
||||||
store
|
session_store::save_user_input(
|
||||||
.append(
|
&store,
|
||||||
session_id,
|
session_id,
|
||||||
source_segment_id,
|
source_segment_id,
|
||||||
&LogEntry::UserInput {
|
vec![protocol::Segment::text("interloper")],
|
||||||
ts: 9999,
|
vec![annotated(Item::user_message("interloper"))],
|
||||||
segments: vec![protocol::Segment::text("interloper")],
|
)
|
||||||
extensions: vec![],
|
.unwrap();
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Next run triggers ensure_segment_head, which sees the drift.
|
// Next run triggers ensure_segment_head, which sees the drift.
|
||||||
worker.run_text("second").await.unwrap();
|
worker.run_text("second").await.unwrap();
|
||||||
@@ -348,11 +355,6 @@ permission = "write"
|
|||||||
session_id: seg_session,
|
session_id: seg_session,
|
||||||
forked_from: Some(origin),
|
forked_from: Some(origin),
|
||||||
..
|
..
|
||||||
}
|
|
||||||
| LogEntry::SegmentStart {
|
|
||||||
session_id: seg_session,
|
|
||||||
forked_from: Some(origin),
|
|
||||||
..
|
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(*seg_session, session_id);
|
assert_eq!(*seg_session, session_id);
|
||||||
assert_eq!(origin.segment_id, source_segment_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_eq!(source_after.len(), source_len_before + 1);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
source_after.last(),
|
source_after.last(),
|
||||||
Some(LogEntry::UserInput { .. })
|
Some(LogEntry::AnnotatedUserInput { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,29 +35,16 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
|
|||||||
LogEntry::AnnotatedSegmentStart { history, .. } => {
|
LogEntry::AnnotatedSegmentStart { history, .. } => {
|
||||||
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
|
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, .. } => {
|
LogEntry::AnnotatedUserInput { history, .. } => {
|
||||||
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
|
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::AnnotatedAssistantItem { entry, .. }
|
||||||
| LogEntry::AnnotatedToolResult { entry, .. } => {
|
| LogEntry::AnnotatedToolResult { entry, .. } => {
|
||||||
items.push(Item::from(entry.item));
|
items.push(Item::from(entry.item));
|
||||||
}
|
}
|
||||||
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
|
|
||||||
items.push(Item::from(item));
|
|
||||||
}
|
|
||||||
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
LogEntry::AnnotatedSystemItem { entry, .. } => {
|
||||||
items.push(entry.item.to_history_item());
|
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> {
|
fn system_item(entry: &LogEntry) -> Option<&session_store::SystemItem> {
|
||||||
match entry {
|
match entry {
|
||||||
LogEntry::AnnotatedSystemItem { entry, .. } => Some(&entry.item),
|
LogEntry::AnnotatedSystemItem { entry, .. } => Some(&entry.item),
|
||||||
LogEntry::SystemItem { item, .. } => Some(item),
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -839,26 +825,26 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
|
|||||||
loop {
|
loop {
|
||||||
let event = reader.next::<Event>().await.unwrap().unwrap();
|
let event = reader.next::<Event>().await.unwrap().unwrap();
|
||||||
match event {
|
match event {
|
||||||
Event::Snapshot { entries, .. } => {
|
Event::Snapshot { session, .. } => {
|
||||||
// Walk the entries, find a `LogEntry::UserInput` and
|
let found = session.entries.iter().any(|entry| match &entry.data {
|
||||||
// confirm its segments flatten to our submitted text.
|
protocol::SessionSnapshotEntryData::UserInput { segments } => {
|
||||||
let mut found = false;
|
protocol::Segment::flatten_to_text(segments) == "hello in-flight"
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
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!(
|
assert!(
|
||||||
found,
|
found,
|
||||||
"snapshot must carry the in-flight UserInput entry: {entries:?}"
|
"snapshot must carry the in-flight UserInput entry: {session:?}"
|
||||||
);
|
);
|
||||||
return;
|
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
|
// Mixed input: plain text + a paste chip + trailing text. Worker must
|
||||||
// flatten this into one user-message string (paste content inlined,
|
// flatten this into one user-message string (paste content inlined,
|
||||||
// no `[Clipboard ...]` label leaking to the LLM); the committed
|
// 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.
|
// socket clients can derive `Event::UserMessage` and re-render the chip.
|
||||||
let segments = vec![
|
let segments = vec![
|
||||||
protocol::Segment::text("see "),
|
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 {
|
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);
|
user_input_segments = Some(segments);
|
||||||
if saw_turn_end {
|
if saw_turn_end {
|
||||||
break;
|
break;
|
||||||
@@ -2410,17 +2396,12 @@ async fn snapshot_contains_user_input(handle: &WorkerHandle, needle: &str) -> bo
|
|||||||
loop {
|
loop {
|
||||||
let event = reader.next::<Event>().await.unwrap().unwrap();
|
let event = reader.next::<Event>().await.unwrap().unwrap();
|
||||||
match event {
|
match event {
|
||||||
Event::Snapshot { entries, .. } => {
|
Event::Snapshot { session, .. } => {
|
||||||
return entries.into_iter().any(|value| {
|
return session.entries.into_iter().any(|entry| match entry.data {
|
||||||
let entry: session_store::LogEntry =
|
protocol::SessionSnapshotEntryData::UserInput { segments } => {
|
||||||
serde_json::from_value(value).expect("LogEntry deserialise");
|
protocol::Segment::flatten_to_text(&segments).contains(needle)
|
||||||
match entry {
|
|
||||||
session_store::LogEntry::UserInput { segments, .. }
|
|
||||||
| session_store::LogEntry::AnnotatedUserInput { segments, .. } => {
|
|
||||||
protocol::Segment::flatten_to_text(&segments).contains(needle)
|
|
||||||
}
|
|
||||||
_ => false,
|
|
||||||
}
|
}
|
||||||
|
_ => false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Event::Alert(_) => continue,
|
Event::Alert(_) => continue,
|
||||||
|
|||||||
@@ -203,12 +203,12 @@ async fn session_start_state_captures_rendered_prompt() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let first = entries.first().expect("at least one entry");
|
let first = entries.first().expect("at least one entry");
|
||||||
match first {
|
match first {
|
||||||
LogEntry::SegmentStart { system_prompt, .. } => {
|
LogEntry::AnnotatedSegmentStart { system_prompt, .. } => {
|
||||||
let sp = system_prompt.as_deref().expect("system prompt set");
|
let sp = system_prompt.as_deref().expect("system prompt set");
|
||||||
assert!(sp.starts_with("hello"));
|
assert!(sp.starts_with("hello"));
|
||||||
assert!(sp.contains(&pwd.display().to_string()));
|
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:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,8 +105,13 @@ pub fn token_hash(token: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn new_user_code() -> String {
|
pub fn new_user_code() -> String {
|
||||||
let hex = Uuid::now_v7().simple().to_string().to_ascii_uppercase();
|
user_code_from_uuid(Uuid::now_v7())
|
||||||
format!("{}-{}", &hex[0..4], &hex[4..8])
|
}
|
||||||
|
|
||||||
|
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> {
|
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(),
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6448,9 +6448,18 @@ fn canonical_ticket_resource_key(resource_key: &str) -> Option<&str> {
|
|||||||
.then_some(resource_key)
|
.then_some(resource_key)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ticket_notification_content(resource_key: &str, current_state: &str) -> String {
|
fn ticket_notification_content(
|
||||||
|
resource_key: &str,
|
||||||
|
previous_state: &str,
|
||||||
|
current_state: &str,
|
||||||
|
) -> String {
|
||||||
|
if previous_state == current_state {
|
||||||
|
return format!(
|
||||||
|
"Ticket {resource_key} has new activity while {current_state}. Reread the current Ticket before acting."
|
||||||
|
);
|
||||||
|
}
|
||||||
format!(
|
format!(
|
||||||
"Ticket {resource_key} changed to {current_state}. Reread the current Ticket before acting."
|
"Ticket {resource_key} changed state from {previous_state} to {current_state}. Reread the current Ticket before acting."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6528,7 +6537,7 @@ fn notify_ticket_recipients(
|
|||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
ticket_id: &str,
|
ticket_id: &str,
|
||||||
_previous_state: &str,
|
previous_state: &str,
|
||||||
current_state: &str,
|
current_state: &str,
|
||||||
source: Option<RuntimeWorkerRef>,
|
source: Option<RuntimeWorkerRef>,
|
||||||
) {
|
) {
|
||||||
@@ -6562,7 +6571,7 @@ fn notify_ticket_recipients(
|
|||||||
recipients.sort();
|
recipients.sort();
|
||||||
recipients.dedup();
|
recipients.dedup();
|
||||||
|
|
||||||
let content = ticket_notification_content(resource_key, current_state);
|
let content = ticket_notification_content(resource_key, previous_state, current_state);
|
||||||
for recipient in recipients {
|
for recipient in recipients {
|
||||||
if source.as_ref().is_some_and(|source| source == &recipient) {
|
if source.as_ref().is_some_and(|source| source == &recipient) {
|
||||||
continue;
|
continue;
|
||||||
@@ -8548,7 +8557,7 @@ async fn scoped_capture_worker_observation_session(
|
|||||||
message: "worker protocol closed before the session snapshot".to_string(),
|
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 {
|
return Err(ApiError::from(Error::RuntimeOperationFailed {
|
||||||
runtime_id: target.runtime_id.clone(),
|
runtime_id: target.runtime_id.clone(),
|
||||||
code: "worker_observation_missing_snapshot".to_string(),
|
code: "worker_observation_missing_snapshot".to_string(),
|
||||||
@@ -8557,7 +8566,7 @@ async fn scoped_capture_worker_observation_session(
|
|||||||
};
|
};
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"segment_id": format!("runtime:{}:worker:{}", target.runtime_id, target.worker_id),
|
"segment_id": format!("runtime:{}:worker:{}", target.runtime_id, target.worker_id),
|
||||||
"entries": entries,
|
"session": session,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10567,35 +10576,48 @@ async fn post_device_login_start(
|
|||||||
State(api): State<ServerAuthApi>,
|
State(api): State<ServerAuthApi>,
|
||||||
Json(request): Json<DeviceLoginStartRequest>,
|
Json(request): Json<DeviceLoginStartRequest>,
|
||||||
) -> ApiResult<Json<DeviceLoginStartResponse>> {
|
) -> ApiResult<Json<DeviceLoginStartResponse>> {
|
||||||
|
const MAX_CODE_ALLOCATION_ATTEMPTS: usize = 8;
|
||||||
|
|
||||||
let auth = auth_public_config(&api.config);
|
let auth = auth_public_config(&api.config);
|
||||||
let device_code = mint_secret("yoi_device");
|
|
||||||
let user_code = new_user_code();
|
|
||||||
let verification_uri = format!(
|
let verification_uri = format!(
|
||||||
"{}/login/device",
|
"{}/login/device",
|
||||||
auth.public_base_url.trim_end_matches('/')
|
auth.public_base_url.trim_end_matches('/')
|
||||||
);
|
);
|
||||||
let verification_uri_complete = format!("{verification_uri}?user_code={user_code}");
|
for _ in 0..MAX_CODE_ALLOCATION_ATTEMPTS {
|
||||||
api.store.create_device_login_flow(&DeviceLoginFlowRecord {
|
let device_code = mint_secret("yoi_device");
|
||||||
device_code: device_code.clone(),
|
let user_code = new_user_code();
|
||||||
user_code: user_code.clone(),
|
let verification_uri_complete = format!("{verification_uri}?user_code={user_code}");
|
||||||
verification_uri: verification_uri.clone(),
|
let flow = DeviceLoginFlowRecord {
|
||||||
client_name: request.client_name,
|
device_code: device_code.clone(),
|
||||||
user_id: None,
|
user_code: user_code.clone(),
|
||||||
api_token_id: None,
|
verification_uri: verification_uri.clone(),
|
||||||
issued_access_token: None,
|
client_name: request.client_name.clone(),
|
||||||
created_at: crate::auth::now_rfc3339(),
|
user_id: None,
|
||||||
expires_at: rfc3339_after(Duration::minutes(10)),
|
api_token_id: None,
|
||||||
approved_at: None,
|
issued_access_token: None,
|
||||||
consumed_at: None,
|
created_at: crate::auth::now_rfc3339(),
|
||||||
})?;
|
expires_at: rfc3339_after(Duration::minutes(10)),
|
||||||
Ok(Json(DeviceLoginStartResponse {
|
approved_at: None,
|
||||||
device_code,
|
consumed_at: None,
|
||||||
user_code,
|
};
|
||||||
verification_uri,
|
if !api.store.try_create_device_login_flow(&flow)? {
|
||||||
verification_uri_complete,
|
continue;
|
||||||
expires_in: 600,
|
}
|
||||||
interval: 5,
|
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(
|
async fn post_device_login_approve(
|
||||||
@@ -16820,7 +16842,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(capture["entries"].is_array());
|
assert!(capture["session"]["entries"].is_array());
|
||||||
|
|
||||||
let revoked = api
|
let revoked = api
|
||||||
.store
|
.store
|
||||||
@@ -17753,6 +17775,49 @@ mod tests {
|
|||||||
assert_ne!(csrf_accepted.status(), StatusCode::FORBIDDEN);
|
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]
|
#[tokio::test]
|
||||||
async fn direct_workspace_router_enforces_origin_on_browser_auth_mutations() {
|
async fn direct_workspace_router_enforces_origin_on_browser_auth_mutations() {
|
||||||
let workspace = tempfile::tempdir().unwrap();
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
@@ -18312,16 +18377,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ticket_notification_projection_exposes_only_resource_key_and_current_state() {
|
fn ticket_notification_projection_distinguishes_state_changes_from_new_activity() {
|
||||||
const INTERNAL_ID: &str = "00001KZ9SR97B";
|
const INTERNAL_ID: &str = "00001KZ9SR97B";
|
||||||
for current_state in ["queued", "inprogress"] {
|
let state_change = ticket_notification_content("T-429", "ready", "queued");
|
||||||
let content = ticket_notification_content("T-429", current_state);
|
assert_eq!(
|
||||||
assert_eq!(
|
state_change,
|
||||||
content,
|
"Ticket T-429 changed state from ready to queued. Reread the current Ticket before acting."
|
||||||
format!(
|
);
|
||||||
"Ticket T-429 changed to {current_state}. Reread the current Ticket before acting."
|
let new_activity = ticket_notification_content("T-429", "inprogress", "inprogress");
|
||||||
)
|
assert_eq!(
|
||||||
);
|
new_activity,
|
||||||
|
"Ticket T-429 has new activity while inprogress. Reread the current Ticket before acting."
|
||||||
|
);
|
||||||
|
for content in [state_change, new_activity] {
|
||||||
assert!(!content.contains(INTERNAL_ID));
|
assert!(!content.contains(INTERNAL_ID));
|
||||||
for forbidden in [
|
for forbidden in [
|
||||||
"workspace_id",
|
"workspace_id",
|
||||||
@@ -18496,15 +18564,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let inputs = execution.take_inputs();
|
let inputs = execution.take_inputs();
|
||||||
let expected_states = ["queued", "inprogress", "inprogress", "inprogress"];
|
let expected_states = [
|
||||||
|
("ready", "queued"),
|
||||||
|
("inprogress", "inprogress"),
|
||||||
|
("inprogress", "inprogress"),
|
||||||
|
("inprogress", "inprogress"),
|
||||||
|
];
|
||||||
assert_eq!(inputs.len(), expected_states.len());
|
assert_eq!(inputs.len(), expected_states.len());
|
||||||
for ((recipient, content), current_state) in inputs.iter().zip(expected_states) {
|
for ((recipient, content), (previous_state, current_state)) in
|
||||||
|
inputs.iter().zip(expected_states)
|
||||||
|
{
|
||||||
assert_eq!(recipient.worker_id.to_string(), orchestrator.worker_id);
|
assert_eq!(recipient.worker_id.to_string(), orchestrator.worker_id);
|
||||||
assert!(!content.contains(&ticket.id));
|
assert!(!content.contains(&ticket.id));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
content,
|
content,
|
||||||
&ticket_notification_content(
|
&ticket_notification_content(
|
||||||
ticket.resource_key.as_deref().unwrap(),
|
ticket.resource_key.as_deref().unwrap(),
|
||||||
|
previous_state,
|
||||||
current_state,
|
current_state,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -19623,6 +19699,7 @@ mod tests {
|
|||||||
notifications[0].1,
|
notifications[0].1,
|
||||||
ticket_notification_content(
|
ticket_notification_content(
|
||||||
ticket_ref.resource_key.as_deref().unwrap(),
|
ticket_ref.resource_key.as_deref().unwrap(),
|
||||||
|
TicketWorkflowState::Queued.as_str(),
|
||||||
TicketWorkflowState::Queued.as_str()
|
TicketWorkflowState::Queued.as_str()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -996,7 +996,7 @@ pub trait ControlPlaneStore: Send + Sync {
|
|||||||
fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()>;
|
fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()>;
|
||||||
fn resolve_api_token(&self, token_hash: &str) -> Result<Option<ApiTokenRecord>>;
|
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 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(
|
fn get_device_login_flow_by_user_code(
|
||||||
&self,
|
&self,
|
||||||
user_code: &str,
|
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| {
|
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)
|
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],
|
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,
|
approved_at: None,
|
||||||
consumed_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
|
store
|
||||||
.approve_device_login_flow(
|
.approve_device_login_flow(
|
||||||
"device",
|
"device",
|
||||||
|
|||||||
+7
-56
@@ -22,7 +22,7 @@ use cli_connection::{
|
|||||||
};
|
};
|
||||||
use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login};
|
use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login};
|
||||||
use memory_lint::{LintCliOptions, LintStatus};
|
use memory_lint::{LintCliOptions, LintStatus};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use session_store::SegmentId;
|
use session_store::SegmentId;
|
||||||
use tui::{LaunchMode, LaunchOptions};
|
use tui::{LaunchMode, LaunchOptions};
|
||||||
|
|
||||||
@@ -1220,65 +1220,16 @@ async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| ParseError(error.to_string()))?;
|
.map_err(|error| ParseError(error.to_string()))?;
|
||||||
save_backend_token(backend_url, &token)?;
|
let token_path = client::save_backend_token(backend_url, "Bearer", &token)
|
||||||
println!("Saved Backend API token for {backend_url}");
|
.map_err(|error| ParseError(error.to_string()))?;
|
||||||
Ok(())
|
println!(
|
||||||
}
|
"Saved Backend API token for {} in {}",
|
||||||
|
client::BackendOrigin::parse(backend_url).map_err(|error| ParseError(error.to_string()))?,
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
token_path.display()
|
||||||
struct BackendTokenFile {
|
|
||||||
#[serde(default)]
|
|
||||||
tokens: BTreeMap<String, BackendTokenEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
struct BackendTokenEntry {
|
|
||||||
token_type: String,
|
|
||||||
access_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_backend_token(backend_url: &str, access_token: &str) -> Result<(), ParseError> {
|
|
||||||
let path = backend_token_path().ok_or_else(|| {
|
|
||||||
ParseError("HOME or XDG_CONFIG_HOME is required to save Backend token".to_string())
|
|
||||||
})?;
|
|
||||||
let mut file = if path.is_file() {
|
|
||||||
let contents = fs::read_to_string(&path)
|
|
||||||
.map_err(|error| ParseError(format!("failed to read {}: {error}", path.display())))?;
|
|
||||||
serde_json::from_str::<BackendTokenFile>(&contents)
|
|
||||||
.map_err(|error| ParseError(format!("failed to parse {}: {error}", path.display())))?
|
|
||||||
} else {
|
|
||||||
BackendTokenFile::default()
|
|
||||||
};
|
|
||||||
file.tokens.insert(
|
|
||||||
backend_url.trim_end_matches('/').to_string(),
|
|
||||||
BackendTokenEntry {
|
|
||||||
token_type: "Bearer".to_string(),
|
|
||||||
access_token: access_token.to_string(),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent).map_err(|error| {
|
|
||||||
ParseError(format!("failed to create {}: {error}", parent.display()))
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
let serialized = serde_json::to_string_pretty(&file)
|
|
||||||
.map_err(|error| ParseError(format!("failed to serialize Backend token file: {error}")))?;
|
|
||||||
fs::write(&path, format!("{serialized}\n"))
|
|
||||||
.map_err(|error| ParseError(format!("failed to write {}: {error}", path.display())))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backend_token_path() -> Option<PathBuf> {
|
|
||||||
yoi_config_dir().map(|dir| dir.join("backend-tokens.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn yoi_config_dir() -> Option<PathBuf> {
|
|
||||||
if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") {
|
|
||||||
return Some(PathBuf::from(home).join("yoi"));
|
|
||||||
}
|
|
||||||
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("yoi"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
|
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
|
||||||
let Some((subcommand, rest)) = args.split_first() else {
|
let Some((subcommand, rest)) = args.split_first() else {
|
||||||
return Err(ParseError(
|
return Err(ParseError(
|
||||||
|
|||||||
@@ -73,11 +73,39 @@ export type InFlightBlock = { "kind": "text", text: string, finished?: boolean,
|
|||||||
|
|
||||||
export type InFlightSnapshot = { blocks?: Array<InFlightBlock>, commands?: Array<CommandSnapshot>, };
|
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 InternalWorkerKind = "sub_worker" | { "service": { kind: string, } };
|
||||||
|
|
||||||
export type InternalWorkerRef = { session_id: string, name: string, parent_session_id?: string | null, kind: InternalWorkerKind, };
|
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>,
|
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
|
* Full tool output. Absent when the tool chose to return
|
||||||
* summary-only, or when the result was pruned.
|
* 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
|
* Unfinished model output that has already streamed in the current
|
||||||
* run but is not yet represented by committed snapshot entries.
|
* 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.
|
* Parent-owned Internal Worker sessions visible to this client.
|
||||||
* Service-private Internal Workers are deliberately excluded.
|
* 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" };
|
||||||
|
|||||||
@@ -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 {
|
function snapshotEvent(cwd: string, entries: unknown[] = []): Event {
|
||||||
return {
|
return {
|
||||||
event: "snapshot",
|
event: "snapshot",
|
||||||
data: {
|
data: {
|
||||||
entries,
|
session: canonicalSession(entries),
|
||||||
greeting: {
|
greeting: {
|
||||||
worker_name: "Worker",
|
worker_name: "Worker",
|
||||||
cwd,
|
cwd,
|
||||||
@@ -138,7 +211,7 @@ Deno.test("segment rotation retains a live error beside the real SegmentStart hi
|
|||||||
event: {
|
event: {
|
||||||
event: "segment_rotated",
|
event: "segment_rotated",
|
||||||
data: {
|
data: {
|
||||||
entry: {
|
session: canonicalSession([{
|
||||||
kind: "segment_start",
|
kind: "segment_start",
|
||||||
ts: 5,
|
ts: 5,
|
||||||
session_id: "session-1",
|
session_id: "session-1",
|
||||||
@@ -149,7 +222,7 @@ Deno.test("segment rotation retains a live error beside the real SegmentStart hi
|
|||||||
role: "user",
|
role: "user",
|
||||||
content: [{ kind: "text", text: "retained conversation" }],
|
content: [{ kind: "text", text: "retained conversation" }],
|
||||||
}],
|
}],
|
||||||
},
|
}]),
|
||||||
},
|
},
|
||||||
} satisfies Event,
|
} 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", () => {
|
Deno.test("workerConsoleHref encodes runtime and worker target authority", () => {
|
||||||
assert(
|
assert(
|
||||||
workerConsoleHref({
|
workerConsoleHref({
|
||||||
@@ -852,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([{
|
const projection = projectConsole([{
|
||||||
eventId: "snapshot",
|
eventId: "snapshot",
|
||||||
observedAtMs: 9_000,
|
observedAtMs: 9_000,
|
||||||
@@ -876,10 +1051,7 @@ Deno.test("snapshot normalizes orphaned running compaction to interrupted", () =
|
|||||||
}]),
|
}]),
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
assertEquals(projection.lines.length, 1);
|
assertEquals(projection.lines.length, 0);
|
||||||
assertEquals(projection.lines[0].compaction?.state, "interrupted");
|
|
||||||
assertEquals(projection.lines[0].compaction?.endedAtMs, 9_000);
|
|
||||||
assertEquals(projection.lines[0].streaming, false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("createConsoleProjector ignores stale compaction revisions", () => {
|
Deno.test("createConsoleProjector ignores stale compaction revisions", () => {
|
||||||
@@ -1238,7 +1410,7 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
|
|||||||
event: {
|
event: {
|
||||||
event: "snapshot",
|
event: "snapshot",
|
||||||
data: {
|
data: {
|
||||||
entries: [
|
session: canonicalSession([
|
||||||
{
|
{
|
||||||
kind: "segment_start",
|
kind: "segment_start",
|
||||||
ts: 1,
|
ts: 1,
|
||||||
@@ -1300,7 +1472,7 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
|
|||||||
message: "Compacting…",
|
message: "Compacting…",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
]),
|
||||||
greeting: {
|
greeting: {
|
||||||
worker_name: "Worker",
|
worker_name: "Worker",
|
||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
@@ -1332,7 +1504,6 @@ Deno.test("projectConsole renders snapshot entries and in-flight output", () =>
|
|||||||
"user:new user:false",
|
"user:new user:false",
|
||||||
"assistant:assistant reply:false",
|
"assistant:assistant reply:false",
|
||||||
"tool:Read(1 file)\n /tmp/a.md:false",
|
"tool:Read(1 file)\n /tmp/a.md:false",
|
||||||
"status:Compacting…:true",
|
|
||||||
"in_flight:partial:true",
|
"in_flight:partial:true",
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1344,7 +1515,7 @@ Deno.test("projectConsole restores system items from snapshot entries", () => {
|
|||||||
event: {
|
event: {
|
||||||
event: "snapshot",
|
event: "snapshot",
|
||||||
data: {
|
data: {
|
||||||
entries: [{
|
session: canonicalSession([{
|
||||||
kind: "system_item",
|
kind: "system_item",
|
||||||
ts: 1,
|
ts: 1,
|
||||||
item: {
|
item: {
|
||||||
@@ -1352,7 +1523,7 @@ Deno.test("projectConsole restores system items from snapshot entries", () => {
|
|||||||
message: "Worker completed",
|
message: "Worker completed",
|
||||||
body: "Child Worker coder-1 completed.",
|
body: "Child Worker coder-1 completed.",
|
||||||
},
|
},
|
||||||
}],
|
}]),
|
||||||
greeting: {
|
greeting: {
|
||||||
worker_name: "Worker",
|
worker_name: "Worker",
|
||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
@@ -1388,7 +1559,7 @@ Deno.test("projectConsole reseeds visible rows from segment rotation", () => {
|
|||||||
event: {
|
event: {
|
||||||
event: "segment_rotated",
|
event: "segment_rotated",
|
||||||
data: {
|
data: {
|
||||||
entry: {
|
session: canonicalSession([{
|
||||||
kind: "segment_start",
|
kind: "segment_start",
|
||||||
ts: 10,
|
ts: 10,
|
||||||
session_id: "00000000-0000-0000-0000-000000000001",
|
session_id: "00000000-0000-0000-0000-000000000001",
|
||||||
@@ -1401,7 +1572,7 @@ Deno.test("projectConsole reseeds visible rows from segment rotation", () => {
|
|||||||
content: [{ kind: "text", text: "after rotation seed" }],
|
content: [{ kind: "text", text: "after rotation seed" }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
}]),
|
||||||
},
|
},
|
||||||
} satisfies Event,
|
} satisfies Event,
|
||||||
},
|
},
|
||||||
@@ -1773,7 +1944,7 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections"
|
|||||||
kind: "sub_worker",
|
kind: "sub_worker",
|
||||||
},
|
},
|
||||||
revision: 4,
|
revision: 4,
|
||||||
entries: [{
|
session: canonicalSession([{
|
||||||
kind: "assistant_item",
|
kind: "assistant_item",
|
||||||
ts: 1,
|
ts: 1,
|
||||||
item: {
|
item: {
|
||||||
@@ -1792,7 +1963,7 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections"
|
|||||||
content: "content",
|
content: "content",
|
||||||
is_error: false,
|
is_error: false,
|
||||||
},
|
},
|
||||||
}],
|
}]),
|
||||||
status: "idle",
|
status: "idle",
|
||||||
in_flight: {
|
in_flight: {
|
||||||
blocks: [{
|
blocks: [{
|
||||||
@@ -1934,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\`\`\``;
|
`[Session TaskStore snapshot]\n\n\`\`\`json\n{\n "tasks": [{"taskid": 3, "status": "pending", "subject": "Restored", "description": "From compaction"}]\n}\n\`\`\``;
|
||||||
const event = snapshotEvent("/repo");
|
const event = snapshotEvent("/repo");
|
||||||
if (event.event !== "snapshot") throw new Error("snapshot fixture expected");
|
if (event.event !== "snapshot") throw new Error("snapshot fixture expected");
|
||||||
event.data.entries = [{
|
event.data.session = {
|
||||||
kind: "segment_start",
|
entries: [{
|
||||||
ts: 1,
|
entry_id: "task-reminder-1",
|
||||||
session_id: "00000000-0000-0000-0000-000000000001",
|
timestamp: 1,
|
||||||
system_prompt: null,
|
provenance: "backend_instruction",
|
||||||
config: {},
|
kind: "system_item",
|
||||||
history: [{
|
item_kind: "task_reminder",
|
||||||
kind: "message",
|
content: taskSnapshot,
|
||||||
role: "system",
|
data: {
|
||||||
content: [{ kind: "text", text: taskSnapshot }],
|
kind: "task_reminder",
|
||||||
|
body: taskSnapshot,
|
||||||
|
source: "automatic",
|
||||||
|
},
|
||||||
}],
|
}],
|
||||||
}];
|
};
|
||||||
|
|
||||||
const projection = projectConsole([{ eventId: "task-snapshot", event }]);
|
const projection = projectConsole([{ eventId: "task-snapshot", event }]);
|
||||||
assertEquals(projection.tasks, [{
|
assertEquals(projection.tasks, [{
|
||||||
|
|||||||
@@ -650,9 +650,9 @@ function projectInternalWorkerSnapshot(
|
|||||||
eventId: string,
|
eventId: string,
|
||||||
cwd: string | null,
|
cwd: string | null,
|
||||||
): InternalWorkerProjection {
|
): InternalWorkerProjection {
|
||||||
const console = snapshotProjectionFromEntries(
|
const console = snapshotProjectionFromSession(
|
||||||
`${eventId}:internal:${snapshot.worker.session_id}:snapshot`,
|
`${eventId}:internal:${snapshot.worker.session_id}:snapshot`,
|
||||||
snapshot.entries,
|
snapshot.session,
|
||||||
cwd,
|
cwd,
|
||||||
);
|
);
|
||||||
console.status = snapshot.status;
|
console.status = snapshot.status;
|
||||||
@@ -905,9 +905,9 @@ export function applyProtocolEvent(
|
|||||||
case "snapshot": {
|
case "snapshot": {
|
||||||
next.status = event.data.status;
|
next.status = event.data.status;
|
||||||
next.cwd = event.data.greeting.cwd;
|
next.cwd = event.data.greeting.cwd;
|
||||||
const snapshot = snapshotProjectionFromEntries(
|
const snapshot = snapshotProjectionFromSession(
|
||||||
envelope.eventId,
|
envelope.eventId,
|
||||||
event.data.entries,
|
event.data.session,
|
||||||
next.cwd,
|
next.cwd,
|
||||||
);
|
);
|
||||||
next.lines = snapshot.lines;
|
next.lines = snapshot.lines;
|
||||||
@@ -1008,9 +1008,9 @@ export function applyProtocolEvent(
|
|||||||
break;
|
break;
|
||||||
case "segment_rotated": {
|
case "segment_rotated": {
|
||||||
const retainedErrors = next.lines.filter((line) => line.kind === "error");
|
const retainedErrors = next.lines.filter((line) => line.kind === "error");
|
||||||
const segment = snapshotProjectionFromEntries(
|
const segment = snapshotProjectionFromSession(
|
||||||
envelope.eventId,
|
envelope.eventId,
|
||||||
[event.data.entry],
|
event.data.session,
|
||||||
next.cwd,
|
next.cwd,
|
||||||
);
|
);
|
||||||
next.lines = [...segment.lines, ...retainedErrors];
|
next.lines = [...segment.lines, ...retainedErrors];
|
||||||
@@ -1925,9 +1925,9 @@ function applyTaskSystemItem(
|
|||||||
if (typeof body === "string") applyTaskSnapshot(projection, body);
|
if (typeof body === "string") applyTaskSnapshot(projection, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshotProjectionFromEntries(
|
function snapshotProjectionFromSession(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
entries: unknown[],
|
snapshot: unknown,
|
||||||
cwd: string | null,
|
cwd: string | null,
|
||||||
): ConsoleProjection {
|
): ConsoleProjection {
|
||||||
const projection: ConsoleProjection = {
|
const projection: ConsoleProjection = {
|
||||||
@@ -1942,58 +1942,74 @@ function snapshotProjectionFromEntries(
|
|||||||
internalWorkers: [],
|
internalWorkers: [],
|
||||||
removedInternalWorkers: {},
|
removedInternalWorkers: {},
|
||||||
};
|
};
|
||||||
|
const entries = isRecord(snapshot) ? arrayField(snapshot, "entries") : [];
|
||||||
entries.forEach((entry, index) =>
|
entries.forEach((entry, index) =>
|
||||||
applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry)
|
applySessionEntry(projection, `${eventId}-snapshot-${index}`, entry)
|
||||||
);
|
);
|
||||||
return projection;
|
return projection;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyLogEntry(
|
function applySessionEntry(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
eventId: string,
|
fallbackEventId: string,
|
||||||
entry: unknown,
|
value: unknown,
|
||||||
): void {
|
): void {
|
||||||
if (!isRecord(entry)) return;
|
if (!isRecord(value)) return;
|
||||||
switch (stringField(entry, "kind")) {
|
const eventId = stringField(value, "entry_id") ?? fallbackEventId;
|
||||||
case "segment_start":
|
switch (stringField(value, "kind")) {
|
||||||
arrayField(entry, "history").forEach((item, index) =>
|
|
||||||
applyLoggedItem(projection, `${eventId}-history-${index}`, item)
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case "user_input":
|
case "user_input":
|
||||||
projection.lines.push(
|
projection.lines.push(
|
||||||
line(
|
line(
|
||||||
eventId,
|
eventId,
|
||||||
"user",
|
"user",
|
||||||
"User",
|
"User",
|
||||||
segmentsToText(arrayField(entry, "segments") as Segment[]),
|
segmentsToText(arrayField(value, "segments") as Segment[]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "system_item":
|
case "message":
|
||||||
projection.lines.push(systemItemLine(eventId, entry["item"]));
|
applyLoggedItem(projection, eventId, {
|
||||||
applyTaskSystemItem(projection, entry["item"]);
|
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;
|
break;
|
||||||
case "assistant_item":
|
|
||||||
case "tool_result":
|
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;
|
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(
|
projection.lines.push(
|
||||||
line(
|
line(
|
||||||
eventId,
|
eventId,
|
||||||
"error",
|
"error",
|
||||||
"Run error",
|
"Run error",
|
||||||
stringField(entry, "message") ?? "Worker run failed.",
|
stringField(value, "message") ?? "Worker run failed.",
|
||||||
undefined,
|
undefined,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "extension":
|
|
||||||
applyExtensionEntry(projection, eventId, entry);
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
break;
|
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(
|
function applyLoggedItem(
|
||||||
projection: ConsoleProjection,
|
projection: ConsoleProjection,
|
||||||
eventId: string,
|
eventId: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user