use crate::types::SessionId;
use crate::AgentProviderManifest;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
use thiserror::Error;
pub const OBSERVED_CAP: usize = 32;
pub const TASK_METADATA_MAX_BYTES: usize = 4096;
pub const OBSERVED_TEXT_MAX_BYTES: usize = 1024;
pub const OPERATOR_SESSION_MAX_IDLE_SECS: u64 = 24 * 60 * 60;
pub mod inmemory;
pub mod sqlite;
pub use inmemory::InMemoryOperatorSessionStore;
pub use sqlite::SqliteOperatorSessionStore;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct OperatorSessionRecord {
pub sid: SessionId,
pub token_digest: String,
pub capability_manifest: Option<AgentProviderManifest>,
pub joined_at_secs: u64,
#[serde(default)]
pub last_access_secs: u64,
#[serde(default)]
pub desc: Option<String>,
#[serde(default)]
pub observed: Vec<ObservedAssignment>,
#[serde(default)]
pub observed_total: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ObservedAssignment {
pub run_id: String,
pub slot: String,
#[serde(default)]
pub goal: Option<String>,
#[serde(default)]
pub project_root: Option<String>,
#[serde(default)]
pub work_dir: Option<String>,
#[serde(default)]
pub task_metadata: Option<serde_json::Value>,
#[serde(default)]
pub task_metadata_omitted: bool,
#[serde(default)]
pub text_truncated: bool,
pub at_secs: u64,
}
impl ObservedAssignment {
pub fn new(
run_id: String,
slot: String,
goal: Option<String>,
project_root: Option<String>,
work_dir: Option<String>,
task_metadata: Option<serde_json::Value>,
at_secs: u64,
) -> Self {
let (task_metadata, task_metadata_omitted) = match task_metadata {
None => (None, false),
Some(value) => match serde_json::to_string(&value) {
Ok(text) if text.len() <= TASK_METADATA_MAX_BYTES => (Some(value), false),
_ => (None, true),
},
};
let mut text_truncated = false;
let goal = cap_text(goal, &mut text_truncated);
let project_root = cap_text(project_root, &mut text_truncated);
let work_dir = cap_text(work_dir, &mut text_truncated);
Self {
run_id,
slot,
goal,
project_root,
work_dir,
task_metadata,
task_metadata_omitted,
text_truncated,
at_secs,
}
}
}
fn cap_text(value: Option<String>, truncated: &mut bool) -> Option<String> {
let text = value?;
if text.len() <= OBSERVED_TEXT_MAX_BYTES {
return Some(text);
}
let mut end = OBSERVED_TEXT_MAX_BYTES;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
*truncated = true;
let mut cut = String::with_capacity(end + '…'.len_utf8());
cut.push_str(&text[..end]);
cut.push('…');
Some(cut)
}
impl OperatorSessionRecord {
pub fn record_observed(&mut self, entry: ObservedAssignment) {
self.observed_total = self.observed_total.saturating_add(1);
if let Some(pos) = self
.observed
.iter()
.position(|e| e.run_id == entry.run_id && e.slot == entry.slot)
{
self.observed.remove(pos);
}
self.observed.push(entry);
while self.observed.len() > OBSERVED_CAP {
self.observed.remove(0);
}
}
pub fn last_activity_secs(&self) -> u64 {
self.observed
.iter()
.map(|e| e.at_secs)
.max()
.unwrap_or(0)
.max(self.joined_at_secs)
}
pub fn last_access_secs(&self) -> u64 {
self.last_access_secs.max(self.last_activity_secs())
}
pub fn touch(&mut self, now: u64) -> bool {
if now <= self.last_access_secs {
return false;
}
self.last_access_secs = now;
true
}
pub fn is_expired_at(&self, now: u64) -> bool {
now.saturating_sub(self.last_access_secs()) >= OPERATOR_SESSION_MAX_IDLE_SECS
}
pub fn digest_of(bearer: &str) -> String {
crate::types::token_fingerprint(bearer)
}
pub fn verify_bearer(&self, bearer: &str) -> bool {
crate::types::ct_eq(
self.token_digest.as_bytes(),
Self::digest_of(bearer).as_bytes(),
)
}
}
#[derive(Debug, Error)]
pub enum OperatorSessionStoreError {
#[error("operator session not found: {0}")]
NotFound(SessionId),
#[error("other: {0}")]
Other(String),
}
#[async_trait]
pub trait OperatorSessionStore: Send + Sync {
fn name(&self) -> &str;
async fn put(&self, record: OperatorSessionRecord) -> Result<(), OperatorSessionStoreError>;
async fn delete(&self, sid: &SessionId) -> Result<(), OperatorSessionStoreError>;
async fn get(
&self,
sid: &SessionId,
) -> Result<Option<OperatorSessionRecord>, OperatorSessionStoreError>;
async fn list(&self) -> Result<Vec<OperatorSessionRecord>, OperatorSessionStoreError>;
}
pub(crate) fn expiry_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub(crate) fn partition_expired(
records: Vec<OperatorSessionRecord>,
now: u64,
backend: &str,
) -> (Vec<OperatorSessionRecord>, Vec<SessionId>) {
let mut live = Vec::with_capacity(records.len());
let mut expired = Vec::new();
for record in records {
if record.is_expired_at(now) {
tracing::info!(
sid = %record.sid,
backend,
last_access_secs = record.last_access_secs(),
idle_secs = now.saturating_sub(record.last_access_secs()),
desc = record.desc.as_deref().unwrap_or("<none>"),
"operator session expired (24h since last access); dropping the row \
instead of restoring it"
);
expired.push(record.sid);
} else {
live.push(record);
}
}
(live, expired)
}
#[derive(Default)]
pub(crate) struct Inner {
pub(crate) order: Vec<SessionId>,
pub(crate) records: HashMap<SessionId, OperatorSessionRecord>,
}
pub(crate) type SharedInner = Mutex<Inner>;
#[cfg(test)]
mod record_tests {
use super::*;
use serde_json::json;
fn record() -> OperatorSessionRecord {
OperatorSessionRecord {
sid: SessionId::parse("S-1").expect("a well-formed sid"),
token_digest: OperatorSessionRecord::digest_of("bearer"),
capability_manifest: None,
joined_at_secs: 100,
last_access_secs: 100,
desc: None,
observed: Vec::new(),
observed_total: 0,
}
}
fn entry(run: &str, slot: &str, at_secs: u64) -> ObservedAssignment {
ObservedAssignment::new(
run.to_string(),
slot.to_string(),
Some("resolve issue #10".to_string()),
Some("/repo".to_string()),
Some("/repo/.worktrees/topic".to_string()),
Some(json!({"issue": 10})),
at_secs,
)
}
#[test]
fn re_assigning_the_same_seat_folds_into_one_entry() {
let mut r = record();
r.record_observed(entry("R-a", "phase-a-op", 110));
r.record_observed(entry("R-b", "phase-a-op", 120));
r.record_observed(entry("R-a", "phase-a-op", 130));
let seen: Vec<(&str, u64)> = r
.observed
.iter()
.map(|e| (e.run_id.as_str(), e.at_secs))
.collect();
assert_eq!(seen, vec![("R-b", 120), ("R-a", 130)]);
assert_eq!(
r.observed_total, 3,
"the fold is not a deletion: the count still says three Assigns happened"
);
}
#[test]
fn the_same_run_in_another_seat_is_its_own_entry() {
let mut r = record();
r.record_observed(entry("R-a", "phase-a-op", 110));
r.record_observed(entry("R-a", "phase-b-op", 111));
assert_eq!(r.observed.len(), 2);
}
#[test]
fn the_log_is_a_ring_bounded_by_the_cap() {
let mut r = record();
for i in 0..(OBSERVED_CAP + 5) {
r.record_observed(entry(&format!("R-{i}"), "phase-a-op", 200 + i as u64));
}
assert_eq!(r.observed.len(), OBSERVED_CAP);
assert_eq!(r.observed[0].run_id, "R-5", "the oldest five aged out");
assert_eq!(r.observed_total, (OBSERVED_CAP + 5) as u64);
}
#[test]
fn last_activity_falls_back_to_the_join_time() {
let mut r = record();
assert_eq!(r.last_activity_secs(), 100);
r.record_observed(entry("R-a", "phase-a-op", 140));
assert_eq!(r.last_activity_secs(), 140);
}
#[test]
fn oversized_task_metadata_is_dropped_and_flagged() {
let big = json!({ "blob": "x".repeat(TASK_METADATA_MAX_BYTES) });
let e = ObservedAssignment::new(
"R-a".to_string(),
"phase-a-op".to_string(),
None,
None,
None,
Some(big),
1,
);
assert!(e.task_metadata.is_none());
assert!(e.task_metadata_omitted);
let small = ObservedAssignment::new(
"R-a".to_string(),
"phase-a-op".to_string(),
None,
None,
None,
None,
1,
);
assert!(!small.task_metadata_omitted, "absent is not omitted");
}
#[test]
fn an_oversized_goal_is_cut_and_flagged() {
let e = ObservedAssignment::new(
"R-a".to_string(),
"phase-a-op".to_string(),
Some("g".repeat(OBSERVED_TEXT_MAX_BYTES * 4)),
Some("/repo".to_string()),
None,
None,
1,
);
let goal = e.goal.as_deref().expect("the prefix is kept, not dropped");
assert!(
goal.len() <= OBSERVED_TEXT_MAX_BYTES + '…'.len_utf8(),
"a goal must not enter the ring longer than the ceiling (+ the marker), got {}",
goal.len()
);
assert!(goal.ends_with('…'), "the cut names itself in the value");
assert!(e.text_truncated, "and in the flag");
assert_eq!(
e.project_root.as_deref(),
Some("/repo"),
"a field that fits is untouched"
);
}
#[test]
fn the_cut_lands_on_a_char_boundary() {
let text = "あ".repeat(OBSERVED_TEXT_MAX_BYTES);
let e = ObservedAssignment::new(
"R-a".to_string(),
"phase-a-op".to_string(),
Some(text),
None,
None,
None,
1,
);
let goal = e.goal.as_deref().expect("kept");
assert!(e.text_truncated);
assert!(
goal.trim_end_matches('…').chars().all(|c| c == 'あ'),
"the prefix is whole characters"
);
}
#[test]
fn a_short_entry_is_not_flagged() {
let e = entry("R-a", "phase-a-op", 1);
assert!(!e.text_truncated);
assert_eq!(e.goal.as_deref(), Some("resolve issue #10"));
}
}