Take rustfmt's defaults
The code was hand-formatted -- close to rustfmt's output but not it, mostly in keeping chains and call arguments on one line where the formatter would break them. That is a per-line decision every future change has to make again, and reproducing it would mean a config whose only job is to preserve how the code already looks. So this is `cargo fmt` at its defaults, with no rustfmt.toml, which is where the sibling dev-updater checkout already sits: it is clean at the defaults today, so the two repos now agree on layout without either of them configuring it. Formatting only -- no behaviour, no renames, nothing reordered. Verified after: cargo test (35 pass), cargo clippy --all-targets clean, cargo fmt --check clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
f014094fcd
commit
c12ab7f098
13 files changed
+530
-163
No files matched your search
+11
-5
@@ -72,8 +72,11 @@ pub async fn require_token(
|
|||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.and_then(|value| value.strip_prefix("Bearer "));
|
.and_then(|value| value.strip_prefix("Bearer "));
|
||||||
if let Some(token) = presented {
|
if let Some(token) = presented {
|
||||||
let hashes: Vec<String> =
|
let hashes: Vec<String> = manager
|
||||||
manager.tokens().into_iter().map(|entry| entry.sha256).collect();
|
.tokens()
|
||||||
|
.into_iter()
|
||||||
|
.map(|entry| entry.sha256)
|
||||||
|
.collect();
|
||||||
if token_matches(token, &hashes) {
|
if token_matches(token, &hashes) {
|
||||||
return next.run(request).await;
|
return next.run(request).await;
|
||||||
}
|
}
|
||||||
@@ -105,8 +108,7 @@ mod tests {
|
|||||||
|
|
||||||
fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc<SessionManager> {
|
fn manager_with_token(dir: &std::path::Path, token: &str) -> Arc<SessionManager> {
|
||||||
let manager = Arc::new(
|
let manager = Arc::new(
|
||||||
SessionManager::new(dir.join("config.ron"), dir.join("sessions"))
|
SessionManager::new(dir.join("config.ron"), dir.join("sessions")).expect("manager"),
|
||||||
.expect("manager"),
|
|
||||||
);
|
);
|
||||||
manager
|
manager
|
||||||
.set_tokens(vec![TokenEntry {
|
.set_tokens(vec![TokenEntry {
|
||||||
@@ -195,7 +197,11 @@ mod tests {
|
|||||||
.oneshot(request(path, auth.as_deref()))
|
.oneshot(request(path, auth.as_deref()))
|
||||||
.await
|
.await
|
||||||
.expect("response");
|
.expect("response");
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path} {auth:?}");
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"{path} {auth:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let ok = router
|
let ok = router
|
||||||
|
|||||||
+32
-13
@@ -73,14 +73,22 @@ pub fn ensure(dir: &Path, addresses: &[IpAddr]) -> Result<Certificates> {
|
|||||||
private::write_file(&leaf_key, leaf_key_pem.as_bytes())?;
|
private::write_file(&leaf_key, leaf_key_pem.as_bytes())?;
|
||||||
private::write_file(&leaf_cert, leaf_pem.as_bytes())?;
|
private::write_file(&leaf_cert, leaf_pem.as_bytes())?;
|
||||||
|
|
||||||
Ok(Certificates { leaf_cert, leaf_key, ca_is_new })
|
Ok(Certificates {
|
||||||
|
leaf_cert,
|
||||||
|
leaf_key,
|
||||||
|
ca_is_new,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_ca() -> Result<(String, String)> {
|
fn generate_ca() -> Result<(String, String)> {
|
||||||
let key = KeyPair::generate().context("generate CA key")?;
|
let key = KeyPair::generate().context("generate CA key")?;
|
||||||
let mut params = CertificateParams::default();
|
let mut params = CertificateParams::default();
|
||||||
params.distinguished_name.push(DnType::OrganizationName, "ai-app dev");
|
params
|
||||||
params.distinguished_name.push(DnType::CommonName, "ai-app dev CA");
|
.distinguished_name
|
||||||
|
.push(DnType::OrganizationName, "ai-app dev");
|
||||||
|
params
|
||||||
|
.distinguished_name
|
||||||
|
.push(DnType::CommonName, "ai-app dev CA");
|
||||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||||
// Explicit, because strict verifiers reject a CA without them -- and
|
// Explicit, because strict verifiers reject a CA without them -- and
|
||||||
// that rejection surfaces as an opaque handshake failure on a phone.
|
// that rejection surfaces as an opaque handshake failure on a phone.
|
||||||
@@ -89,20 +97,21 @@ fn generate_ca() -> Result<(String, String)> {
|
|||||||
Ok((certificate.pem(), key.serialize_pem()))
|
Ok((certificate.pem(), key.serialize_pem()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_leaf(
|
fn generate_leaf(ca_pem: &str, ca_key_pem: &str, addresses: &[IpAddr]) -> Result<(String, String)> {
|
||||||
ca_pem: &str,
|
|
||||||
ca_key_pem: &str,
|
|
||||||
addresses: &[IpAddr],
|
|
||||||
) -> Result<(String, String)> {
|
|
||||||
let ca_key = KeyPair::from_pem(ca_key_pem).context("read CA key")?;
|
let ca_key = KeyPair::from_pem(ca_key_pem).context("read CA key")?;
|
||||||
let issuer = Issuer::from_ca_cert_pem(ca_pem, ca_key).context("read CA certificate")?;
|
let issuer = Issuer::from_ca_cert_pem(ca_pem, ca_key).context("read CA certificate")?;
|
||||||
|
|
||||||
let key = KeyPair::generate().context("generate leaf key")?;
|
let key = KeyPair::generate().context("generate leaf key")?;
|
||||||
let mut params = CertificateParams::default();
|
let mut params = CertificateParams::default();
|
||||||
params.distinguished_name.push(DnType::OrganizationName, "ai-app dev");
|
params
|
||||||
|
.distinguished_name
|
||||||
|
.push(DnType::OrganizationName, "ai-app dev");
|
||||||
params.distinguished_name.push(
|
params.distinguished_name.push(
|
||||||
DnType::CommonName,
|
DnType::CommonName,
|
||||||
addresses.first().map(|a| a.to_string()).unwrap_or_else(|| "ai-app".to_string()),
|
addresses
|
||||||
|
.first()
|
||||||
|
.map(|a| a.to_string())
|
||||||
|
.unwrap_or_else(|| "ai-app".to_string()),
|
||||||
);
|
);
|
||||||
params.subject_alt_names = addresses.iter().map(|a| SanType::IpAddress(*a)).collect();
|
params.subject_alt_names = addresses.iter().map(|a| SanType::IpAddress(*a)).collect();
|
||||||
params.is_ca = IsCa::ExplicitNoCa;
|
params.is_ca = IsCa::ExplicitNoCa;
|
||||||
@@ -135,10 +144,16 @@ mod tests {
|
|||||||
// The CA is the pinned one: replacing it would strand every
|
// The CA is the pinned one: replacing it would strand every
|
||||||
// installed app, so it must survive a restart untouched.
|
// installed app, so it must survive a restart untouched.
|
||||||
assert!(!second.ca_is_new);
|
assert!(!second.ca_is_new);
|
||||||
assert_eq!(ca, std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca"));
|
assert_eq!(
|
||||||
|
ca,
|
||||||
|
std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca")
|
||||||
|
);
|
||||||
// The leaf is not pinned, and is reissued so a new address is just
|
// The leaf is not pinned, and is reissued so a new address is just
|
||||||
// a restart away.
|
// a restart away.
|
||||||
assert_ne!(leaf, std::fs::read_to_string(&second.leaf_cert).expect("leaf"));
|
assert_ne!(
|
||||||
|
leaf,
|
||||||
|
std::fs::read_to_string(&second.leaf_cert).expect("leaf")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -146,7 +161,11 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let certs = ensure(dir.path(), &addresses()).expect("generate");
|
let certs = ensure(dir.path(), &addresses()).expect("generate");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::metadata(dir.path()).expect("dir").permissions().mode() & 0o777,
|
std::fs::metadata(dir.path())
|
||||||
|
.expect("dir")
|
||||||
|
.permissions()
|
||||||
|
.mode()
|
||||||
|
& 0o777,
|
||||||
0o700,
|
0o700,
|
||||||
);
|
);
|
||||||
for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] {
|
for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] {
|
||||||
|
|||||||
+25
-6
@@ -63,7 +63,10 @@ pub(crate) mod format {
|
|||||||
/// one silently mangled. `parse` round-trips either way, since a
|
/// one silently mangled. `parse` round-trips either way, since a
|
||||||
/// wrapped body parses the same as an unwrapped one re-wrapped.
|
/// wrapped body parses the same as an unwrapped one re-wrapped.
|
||||||
fn unwrap_outer(text: &str) -> String {
|
fn unwrap_outer(text: &str) -> String {
|
||||||
let Some(body) = text.strip_prefix("(\n").and_then(|rest| rest.strip_suffix("\n)")) else {
|
let Some(body) = text
|
||||||
|
.strip_prefix("(\n")
|
||||||
|
.and_then(|rest| rest.strip_suffix("\n)"))
|
||||||
|
else {
|
||||||
return text.to_string();
|
return text.to_string();
|
||||||
};
|
};
|
||||||
let mut out: String = body
|
let mut out: String = body
|
||||||
@@ -305,7 +308,10 @@ mod tests {
|
|||||||
assert!(first_run.tokens.is_empty());
|
assert!(first_run.tokens.is_empty());
|
||||||
assert!(first_run.sessions.is_empty());
|
assert!(first_run.sessions.is_empty());
|
||||||
assert_eq!(first_run.providers().len(), 1);
|
assert_eq!(first_run.providers().len(), 1);
|
||||||
assert_eq!(first_run.provider(ECHO_PROVIDER).expect("built-in").kind, DriverKind::Echo);
|
assert_eq!(
|
||||||
|
first_run.provider(ECHO_PROVIDER).expect("built-in").kind,
|
||||||
|
DriverKind::Echo
|
||||||
|
);
|
||||||
|
|
||||||
let config = Config {
|
let config = Config {
|
||||||
tokens: vec![TokenEntry {
|
tokens: vec![TokenEntry {
|
||||||
@@ -347,7 +353,11 @@ mod tests {
|
|||||||
// Built-in echo plus the configured one; any provider can run on
|
// Built-in echo plus the configured one; any provider can run on
|
||||||
// any host, so they are listed independently.
|
// any host, so they are listed independently.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded.providers().iter().map(|p| p.name.clone()).collect::<Vec<_>>(),
|
loaded
|
||||||
|
.providers()
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.name.clone())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
["echo", "claude-cli"],
|
["echo", "claude-cli"],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -361,8 +371,14 @@ mod tests {
|
|||||||
// and only `skip_serializing_if` keeps this from writing it back.
|
// and only `skip_serializing_if` keeps this from writing it back.
|
||||||
let text = std::fs::read_to_string(&path).expect("read back");
|
let text = std::fs::read_to_string(&path).expect("read back");
|
||||||
assert!(!text.trim_start().starts_with('('), "outer parens: {text}");
|
assert!(!text.trim_start().starts_with('('), "outer parens: {text}");
|
||||||
assert!(text.starts_with("tokens: ["), "top level should sit at column 0: {text}");
|
assert!(
|
||||||
assert!(text.contains("port: 2222"), "optional written long-hand: {text}");
|
text.starts_with("tokens: ["),
|
||||||
|
"top level should sit at column 0: {text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("port: 2222"),
|
||||||
|
"optional written long-hand: {text}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -378,6 +394,9 @@ mod tests {
|
|||||||
};
|
};
|
||||||
// One entry, not two: the built-in is skipped rather than shadowed.
|
// One entry, not two: the built-in is skipped rather than shadowed.
|
||||||
assert_eq!(config.providers().len(), 1);
|
assert_eq!(config.providers().len(), 1);
|
||||||
assert_eq!(config.provider(ECHO_PROVIDER).expect("provider").kind, DriverKind::ClaudeCli);
|
assert_eq!(
|
||||||
|
config.provider(ECHO_PROVIDER).expect("provider").kind,
|
||||||
|
DriverKind::ClaudeCli
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+23
-7
@@ -57,7 +57,9 @@ fn xdg_dir(base: Option<std::ffi::OsString>, fallback: &str) -> PathBuf {
|
|||||||
base.map(PathBuf::from)
|
base.map(PathBuf::from)
|
||||||
.filter(|path| path.is_absolute())
|
.filter(|path| path.is_absolute())
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
std::env::home_dir().unwrap_or_else(|| PathBuf::from(".")).join(fallback)
|
std::env::home_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
.join(fallback)
|
||||||
})
|
})
|
||||||
.join("ai-app")
|
.join("ai-app")
|
||||||
}
|
}
|
||||||
@@ -171,8 +173,12 @@ async fn main() -> Result<()> {
|
|||||||
tracing_subscriber::fmt().with_env_filter("info").init();
|
tracing_subscriber::fmt().with_env_filter("info").init();
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
let config_path = args.config.unwrap_or_else(|| config_home().join("config.ron"));
|
let config_path = args
|
||||||
let data_dir = args.data_dir.unwrap_or_else(|| data_home().join("sessions"));
|
.config
|
||||||
|
.unwrap_or_else(|| config_home().join("config.ron"));
|
||||||
|
let data_dir = args
|
||||||
|
.data_dir
|
||||||
|
.unwrap_or_else(|| data_home().join("sessions"));
|
||||||
let manager = Arc::new(
|
let manager = Arc::new(
|
||||||
SessionManager::new(config_path.clone(), data_dir)
|
SessionManager::new(config_path.clone(), data_dir)
|
||||||
.with_context(|| format!("failed to load {}", config_path.display()))?,
|
.with_context(|| format!("failed to load {}", config_path.display()))?,
|
||||||
@@ -185,7 +191,12 @@ async fn main() -> Result<()> {
|
|||||||
tracing::info!(" host {} -> {}", host.name, host.address);
|
tracing::info!(" host {} -> {}", host.name, host.address);
|
||||||
}
|
}
|
||||||
for info in manager.sessions() {
|
for info in manager.sessions() {
|
||||||
tracing::info!(" session {} ({}, {:?})", info.id, info.provider, info.status);
|
tracing::info!(
|
||||||
|
" session {} ({}, {:?})",
|
||||||
|
info.id,
|
||||||
|
info.provider,
|
||||||
|
info.status
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before the interface check below, deliberately: the certificates are
|
// Before the interface check below, deliberately: the certificates are
|
||||||
@@ -237,11 +248,13 @@ async fn main() -> Result<()> {
|
|||||||
.await
|
.await
|
||||||
.context("failed to load TLS cert/key")?;
|
.context("failed to load TLS cert/key")?;
|
||||||
|
|
||||||
let monitor = Arc::new(usage::UsageMonitor::new(vec![Box::new(usage::ClaudeUsage {
|
let monitor = Arc::new(usage::UsageMonitor::new(vec![Box::new(
|
||||||
|
usage::ClaudeUsage {
|
||||||
credentials_path: std::env::home_dir()
|
credentials_path: std::env::home_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("/"))
|
.unwrap_or_else(|| PathBuf::from("/"))
|
||||||
.join(".claude/.credentials.json"),
|
.join(".claude/.credentials.json"),
|
||||||
})]));
|
},
|
||||||
|
)]));
|
||||||
|
|
||||||
// The bearer-token middleware wraps the entire router -- routes and
|
// The bearer-token middleware wraps the entire router -- routes and
|
||||||
// fallback alike -- here and only here, so a new route can't forget
|
// fallback alike -- here and only here, so a new route can't forget
|
||||||
@@ -281,6 +294,9 @@ mod tests {
|
|||||||
// Relative values are ignored per the spec, rather than resolving
|
// Relative values are ignored per the spec, rather than resolving
|
||||||
// against whatever the working directory happens to be -- so a
|
// against whatever the working directory happens to be -- so a
|
||||||
// relative setting lands on the same path as no setting at all.
|
// relative setting lands on the same path as no setting at all.
|
||||||
assert_eq!(xdg_dir(Some("relative/path".into()), ".config"), home_fallback);
|
assert_eq!(
|
||||||
|
xdg_dir(Some("relative/path".into()), ".config"),
|
||||||
|
home_fallback
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+4
-1
@@ -45,7 +45,10 @@ mod tests {
|
|||||||
fn the_two_directions_agree() {
|
fn the_two_directions_agree() {
|
||||||
for (media_type, extension) in IMAGE_TYPES {
|
for (media_type, extension) in IMAGE_TYPES {
|
||||||
assert_eq!(extension_for(media_type), Some(extension));
|
assert_eq!(extension_for(media_type), Some(extension));
|
||||||
assert_eq!(media_type_for(&format!("abc123.{extension}")), Some(media_type));
|
assert_eq!(
|
||||||
|
media_type_for(&format!("abc123.{extension}")),
|
||||||
|
Some(media_type)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
-10
@@ -103,7 +103,9 @@ fn bad_request(err: anyhow::Error) -> ApiError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn lookup(manager: &SessionManager, id: &str) -> Result<Arc<LiveSession>, ApiError> {
|
fn lookup(manager: &SessionManager, id: &str) -> Result<Arc<LiveSession>, ApiError> {
|
||||||
manager.session(id).ok_or_else(|| ApiError::NotFound(format!("no session {id}")))
|
manager
|
||||||
|
.session(id)
|
||||||
|
.ok_or_else(|| ApiError::NotFound(format!("no session {id}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SessionInfo>> {
|
async fn list_sessions(State(manager): State<Arc<SessionManager>>) -> axum::Json<Vec<SessionInfo>> {
|
||||||
@@ -154,7 +156,10 @@ async fn list_hosts(State(manager): State<Arc<SessionManager>>) -> axum::Json<Ve
|
|||||||
manager
|
manager
|
||||||
.hosts()
|
.hosts()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|host| HostInfo { name: host.name, address: host.address })
|
.map(|host| HostInfo {
|
||||||
|
name: host.name,
|
||||||
|
address: host.address,
|
||||||
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -190,7 +195,12 @@ async fn spawn_session(
|
|||||||
permission_mode: body.permission_mode,
|
permission_mode: body.permission_mode,
|
||||||
})
|
})
|
||||||
.map_err(bad_request)?;
|
.map_err(bad_request)?;
|
||||||
tracing::info!("spawned {} session {} ({})", info.provider, info.id, info.title);
|
tracing::info!(
|
||||||
|
"spawned {} session {} ({})",
|
||||||
|
info.provider,
|
||||||
|
info.id,
|
||||||
|
info.title
|
||||||
|
);
|
||||||
Ok(axum::Json(info))
|
Ok(axum::Json(info))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +263,9 @@ async fn interrupt(
|
|||||||
/// Separate router because its state is the usage monitor, not the
|
/// Separate router because its state is the usage monitor, not the
|
||||||
/// session manager; merged (and auth-wrapped) with the rest in `main`.
|
/// session manager; merged (and auth-wrapped) with the rest in `main`.
|
||||||
pub fn usage_router(monitor: Arc<crate::usage::UsageMonitor>) -> Router {
|
pub fn usage_router(monitor: Arc<crate::usage::UsageMonitor>) -> Router {
|
||||||
Router::new().route("/usage", get(usage)).with_state(monitor)
|
Router::new()
|
||||||
|
.route("/usage", get(usage))
|
||||||
|
.with_state(monitor)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn usage(
|
async fn usage(
|
||||||
@@ -276,7 +288,9 @@ async fn set_model(
|
|||||||
UrlPath(id): UrlPath<String>,
|
UrlPath(id): UrlPath<String>,
|
||||||
axum::Json(body): axum::Json<ModelRequest>,
|
axum::Json(body): axum::Json<ModelRequest>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
manager.set_session_model(&id, &body.model).map_err(bad_request)?;
|
manager
|
||||||
|
.set_session_model(&id, &body.model)
|
||||||
|
.map_err(bad_request)?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +320,9 @@ async fn upload_attachment(
|
|||||||
.bytes()
|
.bytes()
|
||||||
.await
|
.await
|
||||||
.map_err(|err| ApiError::BadRequest(format!("upload read failed: {err}")))?;
|
.map_err(|err| ApiError::BadRequest(format!("upload read failed: {err}")))?;
|
||||||
let name = session.save_attachment(&bytes, &content_type).map_err(bad_request)?;
|
let name = session
|
||||||
|
.save_attachment(&bytes, &content_type)
|
||||||
|
.map_err(bad_request)?;
|
||||||
Ok(axum::Json(serde_json::json!({ "id": name })))
|
Ok(axum::Json(serde_json::json!({ "id": name })))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,10 +339,14 @@ async fn serve_file(
|
|||||||
return Err(ApiError::BadRequest("invalid file id".to_string()));
|
return Err(ApiError::BadRequest("invalid file id".to_string()));
|
||||||
}
|
}
|
||||||
let session = lookup(&manager, &id)?;
|
let session = lookup(&manager, &id)?;
|
||||||
let candidates =
|
let candidates = [
|
||||||
[session.dir().join("files").join(&name), session.dir().join("attachments").join(&name)];
|
session.dir().join("files").join(&name),
|
||||||
|
session.dir().join("attachments").join(&name),
|
||||||
|
];
|
||||||
let Some(path) = candidates.iter().find(|path| path.is_file()) else {
|
let Some(path) = candidates.iter().find(|path| path.is_file()) else {
|
||||||
return Err(ApiError::NotFound(format!("no file {name} in session {id}")));
|
return Err(ApiError::NotFound(format!(
|
||||||
|
"no file {name} in session {id}"
|
||||||
|
)));
|
||||||
};
|
};
|
||||||
// A file that is there but unreadable is this server's fault, not the
|
// A file that is there but unreadable is this server's fault, not the
|
||||||
// request's -- Internal logs it and says nothing more to the caller.
|
// request's -- Internal logs it and says nothing more to the caller.
|
||||||
@@ -433,5 +453,6 @@ async fn send_event(
|
|||||||
entry: &SeqEvent,
|
entry: &SeqEvent,
|
||||||
) -> Result<(), mpsc::error::SendError<SseEvent>> {
|
) -> Result<(), mpsc::error::SendError<SseEvent>> {
|
||||||
let data = serde_json::to_string(entry).expect("events always serialize");
|
let data = serde_json::to_string(entry).expect("events always serialize");
|
||||||
tx.send(SseEvent::default().id(entry.seq.to_string()).data(data)).await
|
tx.send(SseEvent::default().id(entry.seq.to_string()).data(data))
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
+202
-60
@@ -173,11 +173,17 @@ impl ClaudeDriver {
|
|||||||
let _ = sink.send(Event::Error {
|
let _ = sink.send(Event::Error {
|
||||||
message: format!(
|
message: format!(
|
||||||
"{label} exited with {status}{}",
|
"{label} exited with {status}{}",
|
||||||
if detail.is_empty() { String::new() } else { format!(": {detail}") }
|
if detail.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(": {detail}")
|
||||||
|
}
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let _ = sink.send(Event::Status { state: SessionStatus::Exited });
|
let _ = sink.send(Event::Status {
|
||||||
|
state: SessionStatus::Exited,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +228,9 @@ impl Driver for ClaudeDriver {
|
|||||||
}
|
}
|
||||||
// Sent mid-turn this queues for injection at the next tool
|
// Sent mid-turn this queues for injection at the next tool
|
||||||
// boundary; sent while idle it starts a turn.
|
// boundary; sent while idle it starts a turn.
|
||||||
let _ = self.sink.send(Event::Status { state: SessionStatus::Running });
|
let _ = self.sink.send(Event::Status {
|
||||||
|
state: SessionStatus::Running,
|
||||||
|
});
|
||||||
self.send_line(
|
self.send_line(
|
||||||
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string(),
|
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string(),
|
||||||
);
|
);
|
||||||
@@ -235,7 +243,9 @@ impl Driver for ClaudeDriver {
|
|||||||
};
|
};
|
||||||
match response {
|
match response {
|
||||||
AnswerOutcome::Respond(control_response) => {
|
AnswerOutcome::Respond(control_response) => {
|
||||||
let _ = self.sink.send(Event::Status { state: SessionStatus::Running });
|
let _ = self.sink.send(Event::Status {
|
||||||
|
state: SessionStatus::Running,
|
||||||
|
});
|
||||||
self.send_line(control_response.to_string());
|
self.send_line(control_response.to_string());
|
||||||
}
|
}
|
||||||
// A multi-question AskUserQuestion still waiting on the rest.
|
// A multi-question AskUserQuestion still waiting on the rest.
|
||||||
@@ -288,7 +298,10 @@ async fn read_stdout(
|
|||||||
let mut lines = BufReader::new(stdout).lines();
|
let mut lines = BufReader::new(stdout).lines();
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
let Ok(message) = serde_json::from_str::<Value>(&line) else {
|
let Ok(message) = serde_json::from_str::<Value>(&line) else {
|
||||||
tracing::warn!("unparseable claude output line: {}", &line[..line.len().min(200)]);
|
tracing::warn!(
|
||||||
|
"unparseable claude output line: {}",
|
||||||
|
&line[..line.len().min(200)]
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let (events, new_session_id) = {
|
let (events, new_session_id) = {
|
||||||
@@ -329,7 +342,10 @@ fn write_resume_token(session_dir: &Path, session_id: &str) {
|
|||||||
fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
|
fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
|
||||||
// Ids are server-generated hex (see routes::upload_attachment); the
|
// Ids are server-generated hex (see routes::upload_attachment); the
|
||||||
// check keeps a crafted "id" from naming an arbitrary file.
|
// check keeps a crafted "id" from naming an arbitrary file.
|
||||||
if !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') {
|
if !id
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
|
||||||
|
{
|
||||||
anyhow::bail!("invalid attachment id");
|
anyhow::bail!("invalid attachment id");
|
||||||
}
|
}
|
||||||
let path = session_dir.join("attachments").join(id);
|
let path = session_dir.join("attachments").join(id);
|
||||||
@@ -380,13 +396,20 @@ struct Translator {
|
|||||||
|
|
||||||
impl Translator {
|
impl Translator {
|
||||||
fn new(session_dir: PathBuf) -> Self {
|
fn new(session_dir: PathBuf) -> Self {
|
||||||
Self { session_id: None, pending: HashMap::new(), session_dir }
|
Self {
|
||||||
|
session_id: None,
|
||||||
|
pending: HashMap::new(),
|
||||||
|
session_dir,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fn translate(&mut self, message: &Value) -> Vec<Event> {
|
fn translate(&mut self, message: &Value) -> Vec<Event> {
|
||||||
// Events from subagents (Task tool internals) carry a
|
// Events from subagents (Task tool internals) carry a
|
||||||
// parent_tool_use_id; the transcript shows the Task tool's own
|
// parent_tool_use_id; the transcript shows the Task tool's own
|
||||||
// start/end instead of every nested step.
|
// start/end instead of every nested step.
|
||||||
if message.get("parent_tool_use_id").is_some_and(|id| !id.is_null()) {
|
if message
|
||||||
|
.get("parent_tool_use_id")
|
||||||
|
.is_some_and(|id| !id.is_null())
|
||||||
|
{
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
match message.get("type").and_then(Value::as_str) {
|
match message.get("type").and_then(Value::as_str) {
|
||||||
@@ -405,18 +428,33 @@ impl Translator {
|
|||||||
Some("control_response") => {
|
Some("control_response") => {
|
||||||
let response = &message["response"];
|
let response = &message["response"];
|
||||||
if response.get("subtype").and_then(Value::as_str) == Some("error") {
|
if response.get("subtype").and_then(Value::as_str) == Some("error") {
|
||||||
let error = response.get("error").and_then(Value::as_str).unwrap_or("unknown");
|
let error = response
|
||||||
vec![Event::Error { message: format!("claude rejected a request: {error}") }]
|
.get("error")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
vec![Event::Error {
|
||||||
|
message: format!("claude rejected a request: {error}"),
|
||||||
|
}]
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some("result") => {
|
Some("result") => {
|
||||||
let usage = &message["usage"];
|
let usage = &message["usage"];
|
||||||
let tokens = usage.get("input_tokens").and_then(Value::as_u64).unwrap_or(0)
|
let tokens = usage
|
||||||
+ usage.get("output_tokens").and_then(Value::as_u64).unwrap_or(0);
|
.get("input_tokens")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
+ usage
|
||||||
|
.get("output_tokens")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.unwrap_or(0);
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
if message.get("is_error").and_then(Value::as_bool).unwrap_or(false) {
|
if message
|
||||||
|
.get("is_error")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
events.push(Event::Error {
|
events.push(Event::Error {
|
||||||
message: message
|
message: message
|
||||||
.get("result")
|
.get("result")
|
||||||
@@ -428,7 +466,9 @@ impl Translator {
|
|||||||
if tokens > 0 {
|
if tokens > 0 {
|
||||||
events.push(Event::UsageDelta { tokens });
|
events.push(Event::UsageDelta { tokens });
|
||||||
}
|
}
|
||||||
events.push(Event::Status { state: SessionStatus::Idle });
|
events.push(Event::Status {
|
||||||
|
state: SessionStatus::Idle,
|
||||||
|
});
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
@@ -444,7 +484,9 @@ impl Translator {
|
|||||||
&& event["delta"].get("type").and_then(Value::as_str) == Some("text_delta")
|
&& event["delta"].get("type").and_then(Value::as_str) == Some("text_delta")
|
||||||
&& let Some(text) = delta.as_str()
|
&& let Some(text) = delta.as_str()
|
||||||
{
|
{
|
||||||
return vec![Event::AssistantText { delta: text.to_string() }];
|
return vec![Event::AssistantText {
|
||||||
|
delta: text.to_string(),
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -457,8 +499,16 @@ impl Translator {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
|
.filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
|
||||||
.map(|block| Event::ToolStart {
|
.map(|block| Event::ToolStart {
|
||||||
id: block.get("id").and_then(Value::as_str).unwrap_or_default().to_string(),
|
id: block
|
||||||
tool: block.get("name").and_then(Value::as_str).unwrap_or_default().to_string(),
|
.get("id")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
tool: block
|
||||||
|
.get("name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
input: block.get("input").cloned().unwrap_or(Value::Null),
|
input: block.get("input").cloned().unwrap_or(Value::Null),
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -469,9 +519,15 @@ impl Translator {
|
|||||||
if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
|
if request.get("subtype").and_then(Value::as_str) != Some("can_use_tool") {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
let request_id =
|
let request_id = message
|
||||||
message.get("request_id").and_then(Value::as_str).unwrap_or_default().to_string();
|
.get("request_id")
|
||||||
let tool_name = request.get("tool_name").and_then(Value::as_str).unwrap_or("a tool");
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
let tool_name = request
|
||||||
|
.get("tool_name")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("a tool");
|
||||||
let input = request.get("input").cloned().unwrap_or(Value::Null);
|
let input = request.get("input").cloned().unwrap_or(Value::Null);
|
||||||
|
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
@@ -515,9 +571,16 @@ impl Translator {
|
|||||||
}
|
}
|
||||||
self.pending.insert(
|
self.pending.insert(
|
||||||
request_id.clone(),
|
request_id.clone(),
|
||||||
PendingRequest { request_id, input, questions, answers: HashMap::new() },
|
PendingRequest {
|
||||||
|
request_id,
|
||||||
|
input,
|
||||||
|
questions,
|
||||||
|
answers: HashMap::new(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
events.push(Event::Status { state: SessionStatus::AwaitingInput });
|
events.push(Event::Status {
|
||||||
|
state: SessionStatus::AwaitingInput,
|
||||||
|
});
|
||||||
events
|
events
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,7 +673,9 @@ impl Translator {
|
|||||||
let source = part.get("source")?;
|
let source = part.get("source")?;
|
||||||
let data = source.get("data")?.as_str()?;
|
let data = source.get("data")?.as_str()?;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
let bytes = base64::engine::general_purpose::STANDARD.decode(data).ok()?;
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(data)
|
||||||
|
.ok()?;
|
||||||
// Screenshots are the overwhelming case, and they are PNG; an
|
// Screenshots are the overwhelming case, and they are PNG; an
|
||||||
// unrecognized type is more likely a dialect change than a JPEG.
|
// unrecognized type is more likely a dialect change than a JPEG.
|
||||||
let extension = source
|
let extension = source
|
||||||
@@ -620,8 +685,7 @@ impl Translator {
|
|||||||
.unwrap_or("png");
|
.unwrap_or("png");
|
||||||
let name = format!("{}.{extension}", super::random_hex());
|
let name = format!("{}.{extension}", super::random_hex());
|
||||||
let dir = self.session_dir.join("files");
|
let dir = self.session_dir.join("files");
|
||||||
if let Err(err) =
|
if let Err(err) = crate::private::create_dir(&dir)
|
||||||
crate::private::create_dir(&dir)
|
|
||||||
.map_err(std::io::Error::other)
|
.map_err(std::io::Error::other)
|
||||||
.and_then(|()| std::fs::write(dir.join(&name), bytes))
|
.and_then(|()| std::fs::write(dir.join(&name), bytes))
|
||||||
{
|
{
|
||||||
@@ -649,7 +713,9 @@ mod tests {
|
|||||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||||
let events = translate_lines(
|
let events = translate_lines(
|
||||||
&mut translator,
|
&mut translator,
|
||||||
&[r#"{"type":"system","subtype":"init","cwd":"/x","session_id":"5ecf21da-d53f","tools":[],"model":"claude-haiku-4-5-20251001"}"#],
|
&[
|
||||||
|
r#"{"type":"system","subtype":"init","cwd":"/x","session_id":"5ecf21da-d53f","tools":[],"model":"claude-haiku-4-5-20251001"}"#,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
assert!(events.is_empty());
|
assert!(events.is_empty());
|
||||||
assert_eq!(translator.session_id.as_deref(), Some("5ecf21da-d53f"));
|
assert_eq!(translator.session_id.as_deref(), Some("5ecf21da-d53f"));
|
||||||
@@ -660,39 +726,59 @@ mod tests {
|
|||||||
// Real lines (trimmed) from the 2.1.237 probe.
|
// Real lines (trimmed) from the 2.1.237 probe.
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||||
let events = translate_lines(&mut translator, &[
|
let events = translate_lines(
|
||||||
|
&mut translator,
|
||||||
|
&[
|
||||||
r#"{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Done."}},"session_id":"s","parent_tool_use_id":null}"#,
|
r#"{"type":"stream_event","event":{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Done."}},"session_id":"s","parent_tool_use_id":null}"#,
|
||||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Done."}]},"parent_tool_use_id":null,"session_id":"s"}"#,
|
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Done."}]},"parent_tool_use_id":null,"session_id":"s"}"#,
|
||||||
r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}},"session_id":"s","parent_tool_use_id":null}"#,
|
r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hmm"}},"session_id":"s","parent_tool_use_id":null}"#,
|
||||||
]);
|
],
|
||||||
assert_eq!(events, vec![Event::AssistantText { delta: "Done.".to_string() }]);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![Event::AssistantText {
|
||||||
|
delta: "Done.".to_string()
|
||||||
|
}]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_use_and_result_become_tool_events() {
|
fn tool_use_and_result_become_tool_events() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||||
let events = translate_lines(&mut translator, &[
|
let events = translate_lines(
|
||||||
|
&mut translator,
|
||||||
|
&[
|
||||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"command":"echo probe-ok"}}]},"parent_tool_use_id":null}"#,
|
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_01","name":"Bash","input":{"command":"echo probe-ok"}}]},"parent_tool_use_id":null}"#,
|
||||||
r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01","type":"tool_result","content":"probe-ok","is_error":false}]},"parent_tool_use_id":null}"#,
|
r#"{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01","type":"tool_result","content":"probe-ok","is_error":false}]},"parent_tool_use_id":null}"#,
|
||||||
]);
|
],
|
||||||
assert_eq!(events, vec![
|
);
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![
|
||||||
Event::ToolStart {
|
Event::ToolStart {
|
||||||
id: "toolu_01".to_string(),
|
id: "toolu_01".to_string(),
|
||||||
tool: "Bash".to_string(),
|
tool: "Bash".to_string(),
|
||||||
input: serde_json::json!({"command": "echo probe-ok"}),
|
input: serde_json::json!({"command": "echo probe-ok"}),
|
||||||
},
|
},
|
||||||
Event::ToolEnd { id: "toolu_01".to_string(), output: "probe-ok".to_string() },
|
Event::ToolEnd {
|
||||||
]);
|
id: "toolu_01".to_string(),
|
||||||
|
output: "probe-ok".to_string()
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn subagent_events_are_not_duplicated_into_the_transcript() {
|
fn subagent_events_are_not_duplicated_into_the_transcript() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||||
let events = translate_lines(&mut translator, &[
|
let events = translate_lines(
|
||||||
|
&mut translator,
|
||||||
|
&[
|
||||||
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_02","name":"Bash","input":{}}]},"parent_tool_use_id":"toolu_parent"}"#,
|
r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_02","name":"Bash","input":{}}]},"parent_tool_use_id":"toolu_parent"}"#,
|
||||||
]);
|
],
|
||||||
|
);
|
||||||
assert!(events.is_empty());
|
assert!(events.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -700,16 +786,29 @@ mod tests {
|
|||||||
fn a_permission_request_becomes_an_allow_deny_question() {
|
fn a_permission_request_becomes_an_allow_deny_question() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||||
let events = translate_lines(&mut translator, &[
|
let events = translate_lines(
|
||||||
|
&mut translator,
|
||||||
|
&[
|
||||||
r#"{"type":"control_request","request_id":"req-1","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"rm -rf /tmp/x"},"tool_use_id":"toolu_03"}}"#,
|
r#"{"type":"control_request","request_id":"req-1","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"rm -rf /tmp/x"},"tool_use_id":"toolu_03"}}"#,
|
||||||
]);
|
],
|
||||||
let Event::Question { id, prompt, options } = &events[0] else {
|
);
|
||||||
|
let Event::Question {
|
||||||
|
id,
|
||||||
|
prompt,
|
||||||
|
options,
|
||||||
|
} = &events[0]
|
||||||
|
else {
|
||||||
panic!("expected a question, got {events:?}");
|
panic!("expected a question, got {events:?}");
|
||||||
};
|
};
|
||||||
assert_eq!(id, "req-1");
|
assert_eq!(id, "req-1");
|
||||||
assert!(prompt.contains("Bash") && prompt.contains("rm -rf /tmp/x"));
|
assert!(prompt.contains("Bash") && prompt.contains("rm -rf /tmp/x"));
|
||||||
assert_eq!(options, &["Allow", "Deny"]);
|
assert_eq!(options, &["Allow", "Deny"]);
|
||||||
assert_eq!(events[1], Event::Status { state: SessionStatus::AwaitingInput });
|
assert_eq!(
|
||||||
|
events[1],
|
||||||
|
Event::Status {
|
||||||
|
state: SessionStatus::AwaitingInput
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Allowing echoes the input back; the request is then gone.
|
// Allowing echoes the input back; the request is then gone.
|
||||||
let AnswerOutcome::Respond(response) = translator.answer("req-1", "Allow") else {
|
let AnswerOutcome::Respond(response) = translator.answer("req-1", "Allow") else {
|
||||||
@@ -721,16 +820,22 @@ mod tests {
|
|||||||
response["response"]["response"]["updatedInput"]["command"],
|
response["response"]["response"]["updatedInput"]["command"],
|
||||||
"rm -rf /tmp/x"
|
"rm -rf /tmp/x"
|
||||||
);
|
);
|
||||||
assert!(matches!(translator.answer("req-1", "Allow"), AnswerOutcome::Unknown));
|
assert!(matches!(
|
||||||
|
translator.answer("req-1", "Allow"),
|
||||||
|
AnswerOutcome::Unknown
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn denying_a_permission_sends_deny() {
|
fn denying_a_permission_sends_deny() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||||
translate_lines(&mut translator, &[
|
translate_lines(
|
||||||
|
&mut translator,
|
||||||
|
&[
|
||||||
r#"{"type":"control_request","request_id":"req-2","request":{"subtype":"can_use_tool","tool_name":"Write","input":{"file_path":"/etc/passwd"}}}"#,
|
r#"{"type":"control_request","request_id":"req-2","request":{"subtype":"can_use_tool","tool_name":"Write","input":{"file_path":"/etc/passwd"}}}"#,
|
||||||
]);
|
],
|
||||||
|
);
|
||||||
let AnswerOutcome::Respond(response) = translator.answer("req-2", "Deny") else {
|
let AnswerOutcome::Respond(response) = translator.answer("req-2", "Deny") else {
|
||||||
panic!("expected a control response");
|
panic!("expected a control response");
|
||||||
};
|
};
|
||||||
@@ -743,13 +848,20 @@ mod tests {
|
|||||||
// updatedInput, keyed by the question text.
|
// updatedInput, keyed by the question text.
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||||
let events = translate_lines(&mut translator, &[
|
let events = translate_lines(
|
||||||
|
&mut translator,
|
||||||
|
&[
|
||||||
r#"{"type":"control_request","request_id":"req-3","request":{"subtype":"can_use_tool","tool_name":"AskUserQuestion","input":{"questions":[{"question":"Which color?","header":"Color","options":[{"label":"Red"},{"label":"Blue"}],"multiSelect":false},{"question":"Which size?","header":"Size","options":[{"label":"S"},{"label":"L"}],"multiSelect":false}]},"tool_use_id":"toolu_04","requires_user_interaction":true}}"#,
|
r#"{"type":"control_request","request_id":"req-3","request":{"subtype":"can_use_tool","tool_name":"AskUserQuestion","input":{"questions":[{"question":"Which color?","header":"Color","options":[{"label":"Red"},{"label":"Blue"}],"multiSelect":false},{"question":"Which size?","header":"Size","options":[{"label":"S"},{"label":"L"}],"multiSelect":false}]},"tool_use_id":"toolu_04","requires_user_interaction":true}}"#,
|
||||||
]);
|
],
|
||||||
|
);
|
||||||
let questions: Vec<_> = events
|
let questions: Vec<_> = events
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|event| match event {
|
.filter_map(|event| match event {
|
||||||
Event::Question { id, prompt, options } => Some((id.clone(), prompt.clone(), options.clone())),
|
Event::Question {
|
||||||
|
id,
|
||||||
|
prompt,
|
||||||
|
options,
|
||||||
|
} => Some((id.clone(), prompt.clone(), options.clone())),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -760,7 +872,10 @@ mod tests {
|
|||||||
|
|
||||||
// First answer alone isn't enough; the response goes out when the
|
// First answer alone isn't enough; the response goes out when the
|
||||||
// last sub-question is answered, with all answers aboard.
|
// last sub-question is answered, with all answers aboard.
|
||||||
assert!(matches!(translator.answer("req-3#0", "Blue"), AnswerOutcome::Pending));
|
assert!(matches!(
|
||||||
|
translator.answer("req-3#0", "Blue"),
|
||||||
|
AnswerOutcome::Pending
|
||||||
|
));
|
||||||
let AnswerOutcome::Respond(response) = translator.answer("req-3#1", "L") else {
|
let AnswerOutcome::Respond(response) = translator.answer("req-3#1", "L") else {
|
||||||
panic!("expected a control response");
|
panic!("expected a control response");
|
||||||
};
|
};
|
||||||
@@ -787,44 +902,71 @@ mod tests {
|
|||||||
assert!(image.ends_with(".png"));
|
assert!(image.ends_with(".png"));
|
||||||
let saved = dir.path().join("files").join(image);
|
let saved = dir.path().join("files").join(image);
|
||||||
assert!(saved.is_file(), "image not saved at {}", saved.display());
|
assert!(saved.is_file(), "image not saved at {}", saved.display());
|
||||||
assert_eq!(events[1], Event::ToolEnd {
|
assert_eq!(
|
||||||
|
events[1],
|
||||||
|
Event::ToolEnd {
|
||||||
id: "toolu_05".to_string(),
|
id: "toolu_05".to_string(),
|
||||||
output: "took a screenshot".to_string(),
|
output: "took a screenshot".to_string(),
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_turn_result_reports_usage_and_returns_to_idle() {
|
fn a_turn_result_reports_usage_and_returns_to_idle() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||||
let events = translate_lines(&mut translator, &[
|
let events = translate_lines(
|
||||||
|
&mut translator,
|
||||||
|
&[
|
||||||
r#"{"type":"result","subtype":"success","is_error":false,"num_turns":2,"stop_reason":"end_turn","session_id":"s","total_cost_usd":0.0149,"usage":{"input_tokens":18,"output_tokens":164}}"#,
|
r#"{"type":"result","subtype":"success","is_error":false,"num_turns":2,"stop_reason":"end_turn","session_id":"s","total_cost_usd":0.0149,"usage":{"input_tokens":18,"output_tokens":164}}"#,
|
||||||
]);
|
],
|
||||||
assert_eq!(events, vec![
|
);
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![
|
||||||
Event::UsageDelta { tokens: 182 },
|
Event::UsageDelta { tokens: 182 },
|
||||||
Event::Status { state: SessionStatus::Idle },
|
Event::Status {
|
||||||
]);
|
state: SessionStatus::Idle
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_error_result_surfaces_the_message() {
|
fn an_error_result_surfaces_the_message() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||||
let events = translate_lines(&mut translator, &[
|
let events = translate_lines(
|
||||||
|
&mut translator,
|
||||||
|
&[
|
||||||
r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"something broke","usage":{}}"#,
|
r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"something broke","usage":{}}"#,
|
||||||
]);
|
],
|
||||||
assert_eq!(events[0], Event::Error { message: "something broke".to_string() });
|
);
|
||||||
assert_eq!(*events.last().unwrap(), Event::Status { state: SessionStatus::Idle });
|
assert_eq!(
|
||||||
|
events[0],
|
||||||
|
Event::Error {
|
||||||
|
message: "something broke".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*events.last().unwrap(),
|
||||||
|
Event::Status {
|
||||||
|
state: SessionStatus::Idle
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replayed_and_synthetic_user_text_is_skipped() {
|
fn replayed_and_synthetic_user_text_is_skipped() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let mut translator = Translator::new(dir.path().to_path_buf());
|
let mut translator = Translator::new(dir.path().to_path_buf());
|
||||||
let events = translate_lines(&mut translator, &[
|
let events = translate_lines(
|
||||||
|
&mut translator,
|
||||||
|
&[
|
||||||
r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hi"}]},"isReplay":true,"parent_tool_use_id":null}"#,
|
r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hi"}]},"isReplay":true,"parent_tool_use_id":null}"#,
|
||||||
r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"[continue]"}]},"isSynthetic":true,"parent_tool_use_id":null}"#,
|
r#"{"type":"user","message":{"role":"user","content":[{"type":"text","text":"[continue]"}]},"isSynthetic":true,"parent_tool_use_id":null}"#,
|
||||||
]);
|
],
|
||||||
|
);
|
||||||
assert!(events.is_empty());
|
assert!(events.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -26,17 +26,27 @@ pub enum Event {
|
|||||||
/// What the user sent, echoed into the transcript by the manager (not
|
/// What the user sent, echoed into the transcript by the manager (not
|
||||||
/// by drivers) so every device renders the full conversation from the
|
/// by drivers) so every device renders the full conversation from the
|
||||||
/// one stream.
|
/// one stream.
|
||||||
UserMessage { text: String },
|
UserMessage {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
/// Streaming assistant text; the phone renders the concatenation as
|
/// Streaming assistant text; the phone renders the concatenation as
|
||||||
/// markdown.
|
/// markdown.
|
||||||
AssistantText { delta: String },
|
AssistantText {
|
||||||
|
delta: String,
|
||||||
|
},
|
||||||
ToolStart {
|
ToolStart {
|
||||||
id: String,
|
id: String,
|
||||||
tool: String,
|
tool: String,
|
||||||
input: serde_json::Value,
|
input: serde_json::Value,
|
||||||
},
|
},
|
||||||
ToolUpdate { id: String, output: String },
|
ToolUpdate {
|
||||||
ToolEnd { id: String, output: String },
|
id: String,
|
||||||
|
output: String,
|
||||||
|
},
|
||||||
|
ToolEnd {
|
||||||
|
id: String,
|
||||||
|
output: String,
|
||||||
|
},
|
||||||
/// An image the session produced or was sent, saved under the session
|
/// An image the session produced or was sent, saved under the session
|
||||||
/// dir and referenced by id; the phone fetches it by URL.
|
/// dir and referenced by id; the phone fetches it by URL.
|
||||||
Image {
|
Image {
|
||||||
@@ -53,11 +63,20 @@ pub enum Event {
|
|||||||
/// The manager's record of a question being answered, so a rendered
|
/// The manager's record of a question being answered, so a rendered
|
||||||
/// question card resolves on every device, not just the one that
|
/// question card resolves on every device, not just the one that
|
||||||
/// answered it.
|
/// answered it.
|
||||||
Answered { id: String, answer: String },
|
Answered {
|
||||||
Status { state: SessionStatus },
|
id: String,
|
||||||
|
answer: String,
|
||||||
|
},
|
||||||
|
Status {
|
||||||
|
state: SessionStatus,
|
||||||
|
},
|
||||||
/// Per-turn token counts, where the dialect reports them.
|
/// Per-turn token counts, where the dialect reports them.
|
||||||
UsageDelta { tokens: u64 },
|
UsageDelta {
|
||||||
Error { message: String },
|
tokens: u64,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|||||||
+45
-14
@@ -28,8 +28,13 @@ pub struct EchoDriver {
|
|||||||
|
|
||||||
impl EchoDriver {
|
impl EchoDriver {
|
||||||
pub fn new(sink: EventSink) -> Self {
|
pub fn new(sink: EventSink) -> Self {
|
||||||
let driver = Self { sink, pending_question: Mutex::new(None) };
|
let driver = Self {
|
||||||
driver.emit(Event::Status { state: SessionStatus::Idle });
|
sink,
|
||||||
|
pending_question: Mutex::new(None),
|
||||||
|
};
|
||||||
|
driver.emit(Event::Status {
|
||||||
|
state: SessionStatus::Idle,
|
||||||
|
});
|
||||||
driver
|
driver
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,22 +58,30 @@ impl Driver for EchoDriver {
|
|||||||
format!("Echo asks: {}", rest.trim())
|
format!("Echo asks: {}", rest.trim())
|
||||||
};
|
};
|
||||||
*self.pending_question.lock().unwrap() = Some(id.clone());
|
*self.pending_question.lock().unwrap() = Some(id.clone());
|
||||||
self.emit(Event::Status { state: SessionStatus::Running });
|
self.emit(Event::Status {
|
||||||
|
state: SessionStatus::Running,
|
||||||
|
});
|
||||||
self.emit(Event::Question {
|
self.emit(Event::Question {
|
||||||
id,
|
id,
|
||||||
prompt,
|
prompt,
|
||||||
options: vec!["Yes".to_string(), "No".to_string()],
|
options: vec!["Yes".to_string(), "No".to_string()],
|
||||||
});
|
});
|
||||||
self.emit(Event::Status { state: SessionStatus::AwaitingInput });
|
self.emit(Event::Status {
|
||||||
|
state: SessionStatus::AwaitingInput,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let run_tool = text.strip_prefix("/tool").map(|rest| rest.trim().to_string());
|
let run_tool = text
|
||||||
|
.strip_prefix("/tool")
|
||||||
|
.map(|rest| rest.trim().to_string());
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let send = |event: Event| {
|
let send = |event: Event| {
|
||||||
let _ = sink.send(event);
|
let _ = sink.send(event);
|
||||||
};
|
};
|
||||||
send(Event::Status { state: SessionStatus::Running });
|
send(Event::Status {
|
||||||
|
state: SessionStatus::Running,
|
||||||
|
});
|
||||||
|
|
||||||
if let Some(input) = run_tool {
|
if let Some(input) = run_tool {
|
||||||
let id = format!("t-{}", super::random_hex());
|
let id = format!("t-{}", super::random_hex());
|
||||||
@@ -78,18 +91,30 @@ impl Driver for EchoDriver {
|
|||||||
input: serde_json::json!({ "input": input }),
|
input: serde_json::json!({ "input": input }),
|
||||||
});
|
});
|
||||||
tokio::time::sleep(DELTA_DELAY).await;
|
tokio::time::sleep(DELTA_DELAY).await;
|
||||||
send(Event::ToolUpdate { id: id.clone(), output: "working...".to_string() });
|
send(Event::ToolUpdate {
|
||||||
|
id: id.clone(),
|
||||||
|
output: "working...".to_string(),
|
||||||
|
});
|
||||||
tokio::time::sleep(DELTA_DELAY).await;
|
tokio::time::sleep(DELTA_DELAY).await;
|
||||||
send(Event::ToolEnd { id, output: format!("echoed: {input}") });
|
send(Event::ToolEnd {
|
||||||
|
id,
|
||||||
|
output: format!("echoed: {input}"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Word-at-a-time so streaming is visibly streaming.
|
// Word-at-a-time so streaming is visibly streaming.
|
||||||
for word in format!("You said: {text}").split_inclusive(' ') {
|
for word in format!("You said: {text}").split_inclusive(' ') {
|
||||||
send(Event::AssistantText { delta: word.to_string() });
|
send(Event::AssistantText {
|
||||||
|
delta: word.to_string(),
|
||||||
|
});
|
||||||
tokio::time::sleep(DELTA_DELAY).await;
|
tokio::time::sleep(DELTA_DELAY).await;
|
||||||
}
|
}
|
||||||
send(Event::UsageDelta { tokens: text.split_whitespace().count() as u64 });
|
send(Event::UsageDelta {
|
||||||
send(Event::Status { state: SessionStatus::Idle });
|
tokens: text.split_whitespace().count() as u64,
|
||||||
|
});
|
||||||
|
send(Event::Status {
|
||||||
|
state: SessionStatus::Idle,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +126,9 @@ impl Driver for EchoDriver {
|
|||||||
self.emit(Event::AssistantText {
|
self.emit(Event::AssistantText {
|
||||||
delta: format!("You answered: {answer}"),
|
delta: format!("You answered: {answer}"),
|
||||||
});
|
});
|
||||||
self.emit(Event::Status { state: SessionStatus::Idle });
|
self.emit(Event::Status {
|
||||||
|
state: SessionStatus::Idle,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
_ => self.emit(Event::Error {
|
_ => self.emit(Event::Error {
|
||||||
message: format!("no question {id} is awaiting an answer"),
|
message: format!("no question {id} is awaiting an answer"),
|
||||||
@@ -113,7 +140,9 @@ impl Driver for EchoDriver {
|
|||||||
// Nothing real to stop; a pending question is abandoned so the
|
// Nothing real to stop; a pending question is abandoned so the
|
||||||
// session isn't stuck awaiting input forever.
|
// session isn't stuck awaiting input forever.
|
||||||
*self.pending_question.lock().unwrap() = None;
|
*self.pending_question.lock().unwrap() = None;
|
||||||
self.emit(Event::Status { state: SessionStatus::Idle });
|
self.emit(Event::Status {
|
||||||
|
state: SessionStatus::Idle,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_model(&self, model: &str) {
|
fn set_model(&self, model: &str) {
|
||||||
@@ -129,6 +158,8 @@ impl Driver for EchoDriver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn shutdown(&self) {
|
fn shutdown(&self) {
|
||||||
self.emit(Event::Status { state: SessionStatus::Exited });
|
self.emit(Event::Status {
|
||||||
|
state: SessionStatus::Exited,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+46
-17
@@ -35,7 +35,10 @@ use transcript::{SeqEvent, Transcript};
|
|||||||
const EVENT_BUFFER: usize = 256;
|
const EVENT_BUFFER: usize = 256;
|
||||||
|
|
||||||
pub fn now() -> f64 {
|
pub fn now() -> f64 {
|
||||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64()
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the phone needs to spawn a session -- the spawn screen's fields.
|
/// What the phone needs to spawn a session -- the spawn screen's fields.
|
||||||
@@ -99,7 +102,9 @@ impl LiveSession {
|
|||||||
// Attachments render in the transcript like any produced image --
|
// Attachments render in the transcript like any produced image --
|
||||||
// the files route serves uploads by the same ref.
|
// the files route serves uploads by the same ref.
|
||||||
for image in &images {
|
for image in &images {
|
||||||
let _ = self.sink.send(Event::Image { image: image.clone() });
|
let _ = self.sink.send(Event::Image {
|
||||||
|
image: image.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let _ = self.sink.send(Event::UserMessage { text: text.clone() });
|
let _ = self.sink.send(Event::UserMessage { text: text.clone() });
|
||||||
self.driver.send_user_message(text, images);
|
self.driver.send_user_message(text, images);
|
||||||
@@ -132,7 +137,9 @@ impl LiveSession {
|
|||||||
/// The session's directory (attachments in, produced files out live in
|
/// The session's directory (attachments in, produced files out live in
|
||||||
/// `attachments/` and `files/` under it).
|
/// `attachments/` and `files/` under it).
|
||||||
pub fn dir(&self) -> &Path {
|
pub fn dir(&self) -> &Path {
|
||||||
self.transcript_path.parent().expect("transcript lives in the session dir")
|
self.transcript_path
|
||||||
|
.parent()
|
||||||
|
.expect("transcript lives in the session dir")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stores one uploaded attachment, returning the id `POST /message`
|
/// Stores one uploaded attachment, returning the id `POST /message`
|
||||||
@@ -194,9 +201,9 @@ impl SessionManager {
|
|||||||
// unreachable ssh host, a provider that was edited away --
|
// unreachable ssh host, a provider that was edited away --
|
||||||
// shows as exited rather than taking the whole server down
|
// shows as exited rather than taking the whole server down
|
||||||
// with it, and can still be deleted from the phone.
|
// with it, and can still be deleted from the phone.
|
||||||
match resolve(&config, meta)
|
match resolve(&config, meta).and_then(|(provider, host)| {
|
||||||
.and_then(|(provider, host)| launch(meta.clone(), &provider, host.as_ref(), &data_dir))
|
launch(meta.clone(), &provider, host.as_ref(), &data_dir)
|
||||||
{
|
}) {
|
||||||
Ok(session) => {
|
Ok(session) => {
|
||||||
live.insert(meta.id.clone(), session);
|
live.insert(meta.id.clone(), session);
|
||||||
}
|
}
|
||||||
@@ -462,12 +469,21 @@ fn launch(
|
|||||||
|
|
||||||
let driver: Box<dyn Driver> = match provider.kind {
|
let driver: Box<dyn Driver> = match provider.kind {
|
||||||
DriverKind::Echo => Box::new(EchoDriver::new(sink.clone())),
|
DriverKind::Echo => Box::new(EchoDriver::new(sink.clone())),
|
||||||
DriverKind::ClaudeCli => {
|
DriverKind::ClaudeCli => Box::new(ClaudeDriver::spawn(
|
||||||
Box::new(ClaudeDriver::spawn(&meta, provider, host, &dir, sink.clone())?)
|
&meta,
|
||||||
}
|
provider,
|
||||||
|
host,
|
||||||
|
&dir,
|
||||||
|
sink.clone(),
|
||||||
|
)?),
|
||||||
};
|
};
|
||||||
|
|
||||||
tokio::spawn(pump(transcript, source, Arc::clone(&shared), events.clone()));
|
tokio::spawn(pump(
|
||||||
|
transcript,
|
||||||
|
source,
|
||||||
|
Arc::clone(&shared),
|
||||||
|
events.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
Ok(Arc::new(LiveSession {
|
Ok(Arc::new(LiveSession {
|
||||||
meta,
|
meta,
|
||||||
@@ -547,7 +563,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_idle(event: &Event) -> bool {
|
fn is_idle(event: &Event) -> bool {
|
||||||
matches!(event, Event::Status { state: SessionStatus::Idle })
|
matches!(
|
||||||
|
event,
|
||||||
|
Event::Status {
|
||||||
|
state: SessionStatus::Idle
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collects one full echo turn: everything up to the idle that follows
|
/// Collects one full echo turn: everything up to the idle that follows
|
||||||
@@ -611,17 +632,20 @@ mod tests {
|
|||||||
assert!(manager.sessions().is_empty());
|
assert!(manager.sessions().is_empty());
|
||||||
assert!(manager.session(&info.id).is_none());
|
assert!(manager.session(&info.id).is_none());
|
||||||
assert!(!data_dir.join(&info.id).exists());
|
assert!(!data_dir.join(&info.id).exists());
|
||||||
assert!(Config::load(&config_path).expect("reload").sessions.is_empty());
|
assert!(
|
||||||
|
Config::load(&config_path)
|
||||||
|
.expect("reload")
|
||||||
|
.sessions
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
assert!(manager.delete_session(&info.id).is_err());
|
assert!(manager.delete_session(&info.id).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn questions_round_trip_through_answer() {
|
async fn questions_round_trip_through_answer() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
let manager = SessionManager::new(
|
let manager =
|
||||||
dir.path().join("config.ron"),
|
SessionManager::new(dir.path().join("config.ron"), dir.path().join("sessions"))
|
||||||
dir.path().join("sessions"),
|
|
||||||
)
|
|
||||||
.expect("manager");
|
.expect("manager");
|
||||||
let info = manager.spawn_session(echo_spec()).expect("spawn");
|
let info = manager.spawn_session(echo_spec()).expect("spawn");
|
||||||
let session = manager.session(&info.id).expect("live session");
|
let session = manager.session(&info.id).expect("live session");
|
||||||
@@ -629,7 +653,12 @@ mod tests {
|
|||||||
let mut rx = session.subscribe();
|
let mut rx = session.subscribe();
|
||||||
session.send_message("/question deploy?".to_string(), Vec::new());
|
session.send_message("/question deploy?".to_string(), Vec::new());
|
||||||
let seen = collect_until(&mut rx, |event| {
|
let seen = collect_until(&mut rx, |event| {
|
||||||
matches!(event, Event::Status { state: SessionStatus::AwaitingInput })
|
matches!(
|
||||||
|
event,
|
||||||
|
Event::Status {
|
||||||
|
state: SessionStatus::AwaitingInput
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
let question_id = seen
|
let question_id = seen
|
||||||
|
|||||||
@@ -45,17 +45,26 @@ impl Transcript {
|
|||||||
.mode(0o600)
|
.mode(0o600)
|
||||||
.open(path)
|
.open(path)
|
||||||
.with_context(|| format!("open transcript {}", path.display()))?;
|
.with_context(|| format!("open transcript {}", path.display()))?;
|
||||||
Ok(Self { file, next_seq: last_seq + 1 })
|
Ok(Self {
|
||||||
|
file,
|
||||||
|
next_seq: last_seq + 1,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Appends `event`, assigning it the next sequence number. Flushed per
|
/// Appends `event`, assigning it the next sequence number. Flushed per
|
||||||
/// event: each line is tiny, and the transcript is the source of truth
|
/// event: each line is tiny, and the transcript is the source of truth
|
||||||
/// a crash must not lose the tail of.
|
/// a crash must not lose the tail of.
|
||||||
pub fn append(&mut self, event: Event, ts: f64) -> Result<SeqEvent> {
|
pub fn append(&mut self, event: Event, ts: f64) -> Result<SeqEvent> {
|
||||||
let entry = SeqEvent { seq: self.next_seq, ts, event };
|
let entry = SeqEvent {
|
||||||
|
seq: self.next_seq,
|
||||||
|
ts,
|
||||||
|
event,
|
||||||
|
};
|
||||||
let mut line = serde_json::to_string(&entry).context("serialize event")?;
|
let mut line = serde_json::to_string(&entry).context("serialize event")?;
|
||||||
line.push('\n');
|
line.push('\n');
|
||||||
self.file.write_all(line.as_bytes()).context("append to transcript")?;
|
self.file
|
||||||
|
.write_all(line.as_bytes())
|
||||||
|
.context("append to transcript")?;
|
||||||
self.next_seq += 1;
|
self.next_seq += 1;
|
||||||
Ok(entry)
|
Ok(entry)
|
||||||
}
|
}
|
||||||
@@ -86,7 +95,10 @@ pub fn read_after(path: &Path, after: u64) -> Result<Vec<SeqEvent>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn last_seq(path: &Path) -> Result<u64> {
|
fn last_seq(path: &Path) -> Result<u64> {
|
||||||
Ok(read_after(path, 0)?.last().map(|entry| entry.seq).unwrap_or(0))
|
Ok(read_after(path, 0)?
|
||||||
|
.last()
|
||||||
|
.map(|entry| entry.seq)
|
||||||
|
.unwrap_or(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -95,7 +107,9 @@ mod tests {
|
|||||||
use crate::session::driver::SessionStatus;
|
use crate::session::driver::SessionStatus;
|
||||||
|
|
||||||
fn text(delta: &str) -> Event {
|
fn text(delta: &str) -> Event {
|
||||||
Event::AssistantText { delta: delta.to_string() }
|
Event::AssistantText {
|
||||||
|
delta: delta.to_string(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -135,7 +149,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_missing_file_reads_as_empty() {
|
fn a_missing_file_reads_as_empty() {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
assert!(read_after(&dir.path().join("nope.jsonl"), 0).expect("read").is_empty());
|
assert!(
|
||||||
|
read_after(&dir.path().join("nope.jsonl"), 0)
|
||||||
|
.expect("read")
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -150,18 +168,33 @@ mod tests {
|
|||||||
tool: "bash".into(),
|
tool: "bash".into(),
|
||||||
input: serde_json::json!({"command": "ls"}),
|
input: serde_json::json!({"command": "ls"}),
|
||||||
},
|
},
|
||||||
Event::ToolUpdate { id: "t1".into(), output: "partial".into() },
|
Event::ToolUpdate {
|
||||||
Event::ToolEnd { id: "t1".into(), output: "done".into() },
|
id: "t1".into(),
|
||||||
Event::Image { image: "img1".into() },
|
output: "partial".into(),
|
||||||
|
},
|
||||||
|
Event::ToolEnd {
|
||||||
|
id: "t1".into(),
|
||||||
|
output: "done".into(),
|
||||||
|
},
|
||||||
|
Event::Image {
|
||||||
|
image: "img1".into(),
|
||||||
|
},
|
||||||
Event::Question {
|
Event::Question {
|
||||||
id: "q1".into(),
|
id: "q1".into(),
|
||||||
prompt: "Allow?".into(),
|
prompt: "Allow?".into(),
|
||||||
options: vec!["Yes".into(), "No".into()],
|
options: vec!["Yes".into(), "No".into()],
|
||||||
},
|
},
|
||||||
Event::Answered { id: "q1".into(), answer: "Yes".into() },
|
Event::Answered {
|
||||||
Event::Status { state: SessionStatus::Idle },
|
id: "q1".into(),
|
||||||
|
answer: "Yes".into(),
|
||||||
|
},
|
||||||
|
Event::Status {
|
||||||
|
state: SessionStatus::Idle,
|
||||||
|
},
|
||||||
Event::UsageDelta { tokens: 42 },
|
Event::UsageDelta { tokens: 42 },
|
||||||
Event::Error { message: "boom".into() },
|
Event::Error {
|
||||||
|
message: "boom".into(),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let mut transcript = Transcript::open(&path).expect("open");
|
let mut transcript = Transcript::open(&path).expect("open");
|
||||||
|
|||||||
+19
-5
@@ -22,8 +22,11 @@ use crate::config::HostConfig;
|
|||||||
/// password prompt that nothing can answer; the keepalives turn a silently
|
/// password prompt that nothing can answer; the keepalives turn a silently
|
||||||
/// dropped link into a process exit, which the session reports as `exited`
|
/// dropped link into a process exit, which the session reports as `exited`
|
||||||
/// rather than appearing to hang forever.
|
/// rather than appearing to hang forever.
|
||||||
const SSH_OPTIONS: [&str; 3] =
|
const SSH_OPTIONS: [&str; 3] = [
|
||||||
["BatchMode=yes", "ServerAliveInterval=30", "ServerAliveCountMax=3"];
|
"BatchMode=yes",
|
||||||
|
"ServerAliveInterval=30",
|
||||||
|
"ServerAliveCountMax=3",
|
||||||
|
];
|
||||||
|
|
||||||
/// Builds the child process for `program args…`, run in `cwd`, either on
|
/// Builds the child process for `program args…`, run in `cwd`, either on
|
||||||
/// this machine (`host` absent) or on `host`.
|
/// this machine (`host` absent) or on `host`.
|
||||||
@@ -128,9 +131,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_session_with_no_host_runs_the_command_directly() {
|
fn a_session_with_no_host_runs_the_command_directly() {
|
||||||
let command = command(None, "claude", &args(["-p", "--verbose"]), Some(Path::new("/tmp/x")));
|
let command = command(
|
||||||
|
None,
|
||||||
|
"claude",
|
||||||
|
&args(["-p", "--verbose"]),
|
||||||
|
Some(Path::new("/tmp/x")),
|
||||||
|
);
|
||||||
assert_eq!(argv(&command), ["claude", "-p", "--verbose"]);
|
assert_eq!(argv(&command), ["claude", "-p", "--verbose"]);
|
||||||
assert_eq!(command.as_std().get_current_dir(), Some(Path::new("/tmp/x")));
|
assert_eq!(
|
||||||
|
command.as_std().get_current_dir(),
|
||||||
|
Some(Path::new("/tmp/x"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -199,7 +210,10 @@ mod tests {
|
|||||||
let evil = Path::new("/tmp/'; touch /tmp/pwned; '");
|
let evil = Path::new("/tmp/'; touch /tmp/pwned; '");
|
||||||
let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil)));
|
let rendered = argv(&command(Some(&ssh), "claude", &[], Some(evil)));
|
||||||
let script = rendered.last().unwrap();
|
let script = rendered.last().unwrap();
|
||||||
assert_eq!(script, r"cd '/tmp/'\''; touch /tmp/pwned; '\''' && exec 'claude'");
|
assert_eq!(
|
||||||
|
script,
|
||||||
|
r"cd '/tmp/'\''; touch /tmp/pwned; '\''' && exec 'claude'"
|
||||||
|
);
|
||||||
assert!(!script.contains("; touch /tmp/pwned; '\" "));
|
assert!(!script.contains("; touch /tmp/pwned; '\" "));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+20
-5
@@ -90,7 +90,11 @@ impl ClaudeUsage {
|
|||||||
serde_json::from_str::<Value>(&text)
|
serde_json::from_str::<Value>(&text)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|creds| {
|
.and_then(|creds| {
|
||||||
creds.get("claudeAiOauth")?.get("accessToken")?.as_str().map(String::from)
|
creds
|
||||||
|
.get("claudeAiOauth")?
|
||||||
|
.get("accessToken")?
|
||||||
|
.as_str()
|
||||||
|
.map(String::from)
|
||||||
})
|
})
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
"credential file has no claudeAiOauth.accessToken -- log in with `claude` once"
|
"credential file has no claudeAiOauth.accessToken -- log in with `claude` once"
|
||||||
@@ -148,7 +152,10 @@ fn parse_windows(body: &Value) -> Vec<UsageWindow> {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter_map(|limit| {
|
.filter_map(|limit| {
|
||||||
let percent = limit.get("percent")?.as_f64()?;
|
let percent = limit.get("percent")?.as_f64()?;
|
||||||
let kind = limit.get("kind").and_then(Value::as_str).unwrap_or("unknown");
|
let kind = limit
|
||||||
|
.get("kind")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown");
|
||||||
let scope_model = limit
|
let scope_model = limit
|
||||||
.get("scope")
|
.get("scope")
|
||||||
.and_then(|scope| scope.get("model"))
|
.and_then(|scope| scope.get("model"))
|
||||||
@@ -168,7 +175,10 @@ fn parse_windows(body: &Value) -> Vec<UsageWindow> {
|
|||||||
.get("resets_at")
|
.get("resets_at")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(String::from),
|
.map(String::from),
|
||||||
active: limit.get("is_active").and_then(Value::as_bool).unwrap_or(false),
|
active: limit
|
||||||
|
.get("is_active")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -184,7 +194,10 @@ pub struct UsageMonitor {
|
|||||||
|
|
||||||
impl UsageMonitor {
|
impl UsageMonitor {
|
||||||
pub fn new(providers: Vec<Box<dyn UsageProvider>>) -> Self {
|
pub fn new(providers: Vec<Box<dyn UsageProvider>>) -> Self {
|
||||||
Self { providers, cache: Mutex::new(Vec::new()) }
|
Self {
|
||||||
|
providers,
|
||||||
|
cache: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Blocking -- call via `spawn_blocking`.
|
/// Blocking -- call via `spawn_blocking`.
|
||||||
@@ -241,7 +254,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_credentials_degrade_to_unavailable() {
|
fn missing_credentials_degrade_to_unavailable() {
|
||||||
let provider = ClaudeUsage { credentials_path: PathBuf::from("/nonexistent/creds.json") };
|
let provider = ClaudeUsage {
|
||||||
|
credentials_path: PathBuf::from("/nonexistent/creds.json"),
|
||||||
|
};
|
||||||
let snapshot = provider.fetch();
|
let snapshot = provider.fetch();
|
||||||
assert!(!snapshot.available);
|
assert!(!snapshot.available);
|
||||||
assert!(snapshot.error.expect("reason").contains("logged in"));
|
assert!(snapshot.error.expect("reason").contains("logged in"));
|
||||||
|
|||||||
Reference in new issue
Block a user