blob: 170494927eea629e47c59653c32e1626fdc92eb5 [file]
//! What a session started from the web launcher says about itself while it runs: where
//! its model calls go and at what ceiling, the label it has reached, what the calls have
//! cost so far, and what it has written. The launcher is the one process that knows all
//! of it, so it writes the file and the web launcher reads it for the session's strip.
//!
//! The web launcher names the session in `AMEM_WEB_SESSION`: an id, never a path, checked
//! before it becomes one, under `state/web/` in the root.
use dm_core::Label;
use dm_egress::Telemetry;
use dm_server::Memory;
use serde::Serialize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
/// How often a running session's file is written.
const EVERY: Duration = Duration::from_secs(2);
/// The web launcher's id for this session, if it started it: 32 hex digits.
#[must_use]
pub fn web_session() -> Option<String> {
std::env::var("AMEM_WEB_SESSION")
.ok()
.filter(|id| id.len() == 32 && id.bytes().all(|b| b.is_ascii_hexdigit()))
}
/// What does not change over the session.
#[derive(Clone, Debug, Serialize)]
pub struct Fixed {
/// The memory's session id, which telemetry is kept under.
pub memory_session: String,
/// The harness's binary name.
pub harness: String,
/// subscription, key or gateway.
pub auth: String,
/// The destination the model calls go to.
pub destination: String,
/// The grants entry whose ceiling caps the session.
pub ceiling_of: String,
/// The highest label the destination may receive.
pub ceiling: Label,
/// Whether the egress meters the calls; a tunnel is pinned without metering.
pub metered: bool,
/// The model, when one was named.
pub model: Option<String>,
}
/// Calls and tokens so far.
#[derive(Clone, Debug, Default, Serialize)]
pub struct Usage {
/// Model calls.
pub calls: u64,
/// Input tokens.
pub input: u64,
/// Output tokens.
pub output: u64,
}
#[derive(Serialize)]
struct Snapshot<'a> {
#[serde(flatten)]
fixed: &'a Fixed,
/// The highest label the session has read.
label: Label,
usage: Usage,
written: usize,
handoffs: usize,
running: bool,
updated: String,
}
fn usage(telemetry: Option<&Telemetry>, session: &str) -> Usage {
telemetry
.and_then(|t| t.session(session).ok().flatten())
.map(|s| Usage {
calls: s.calls,
input: s.input_tokens,
output: s.output_tokens,
})
.unwrap_or_default()
}
/// What one write needs, behind one lock so the last word is never overtaken by a
/// write that was already under way when the session ended.
struct Source {
path: PathBuf,
fixed: Fixed,
memory: Arc<Memory>,
telemetry: Option<Telemetry>,
}
fn write(src: &Source, running: bool) {
let (written, handoffs) = src.memory.written();
let snap = Snapshot {
fixed: &src.fixed,
label: src.memory.label(),
usage: usage(src.telemetry.as_ref(), &src.fixed.memory_session),
written,
handoffs,
running,
updated: time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_default(),
};
let Ok(text) = serde_json::to_string(&snap) else {
return;
};
// written beside and renamed, so a reader never sees half a file
let tmp = src.path.with_extension("json.tmp");
if std::fs::write(&tmp, text).is_ok() {
let _ = std::fs::rename(&tmp, &src.path);
}
}
/// The writer for one session: a task that rewrites the file while the session runs, and
/// a last write, saying it stopped, when it is dropped.
pub struct Writer {
source: Arc<std::sync::Mutex<Source>>,
task: tokio::task::JoinHandle<()>,
}
fn locked(m: &std::sync::Mutex<Source>) -> std::sync::MutexGuard<'_, Source> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
impl Writer {
/// Start writing, when the web launcher named this session.
#[must_use]
pub fn start(root: &Path, fixed: Fixed, memory: Arc<Memory>) -> Option<Writer> {
let path = dm_web::state::status_file(root, &web_session()?)?;
std::fs::create_dir_all(path.parent()?).ok()?;
let source = Arc::new(std::sync::Mutex::new(Source {
path,
fixed,
memory,
telemetry: Telemetry::open(&root.join("state/telemetry.db")).ok(),
}));
let s = source.clone();
// file and database work, off the runtime the egress proxy serves on
let task = tokio::spawn(async move {
loop {
let s = s.clone();
let _ = tokio::task::spawn_blocking(move || write(&locked(&s), true)).await;
tokio::time::sleep(EVERY).await;
}
});
Some(Writer { source, task })
}
}
impl Drop for Writer {
fn drop(&mut self) {
self.task.abort();
// the lock waits out a write already under way, so this one is the last
write(&locked(&self.source), false);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_an_id_becomes_a_path() {
let root = Path::new("/r");
let id = "0123456789abcdef0123456789abcdef";
assert_eq!(
dm_web::state::status_file(root, id).unwrap(),
root.join("state/web").join(format!("{id}.json"))
);
for bad in [
"../../etc/passwd",
"",
"0123",
"0123456789abcdef0123456789abcdeg",
&format!("{id}/.."),
] {
assert!(dm_web::state::status_file(root, bad).is_none(), "{bad}");
}
}
#[tokio::test]
async fn the_file_says_what_the_session_has_done_and_when_it_stopped() {
let dir = tempfile::tempdir().unwrap();
let m = Arc::new(Memory::init(dir.path(), "human:test:me", "dom-me").unwrap());
let fixed = Fixed {
memory_session: m.session_id().to_string(),
harness: "claude".into(),
auth: "gateway".into(),
destination: "llmao".into(),
ceiling_of: "llmao".into(),
ceiling: Label::Internal,
metered: true,
model: Some("m1".into()),
};
let p = dir.path().join("s.json");
let t = Telemetry::open(&dir.path().join("t.db")).unwrap();
t.record(
std::time::SystemTime::now(),
m.session_id(),
"llmao",
200,
&dm_egress::Usage {
model: Some("m1".into()),
input_tokens: 120,
output_tokens: 30,
..dm_egress::Usage::default()
},
)
.unwrap();
m.write(dm_server::WriteRequest {
entry_type: dm_core::EntryType::Handoff,
body: "Stopped.\n".into(),
..dm_server::WriteRequest::default()
})
.await
.unwrap();
let src = Source {
path: p.clone(),
fixed,
memory: m.clone(),
telemetry: Some(t),
};
write(&src, false);
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&p).unwrap()).unwrap();
assert_eq!(v["destination"], "llmao");
assert_eq!(v["ceiling"], "internal");
assert_eq!(v["usage"]["calls"], 1);
assert_eq!(v["usage"]["input"], 120);
assert_eq!(v["written"], 1);
assert_eq!(v["handoffs"], 1);
assert_eq!(v["running"], false);
assert_eq!(v["model"], "m1");
}
}