use std::{
collections::HashMap,
io::Read as _,
path::{Path, PathBuf},
sync::{Arc, Mutex, MutexGuard},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use scv_protocol::{AgentState, JobOrigin, WorktreeRef};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::delegate::records::{ProcessIdentity, write_private_json};
const MAX_PROMPT_BYTES: usize = 16 * 1024;
const MAX_REPLY_BYTES: usize = 8 * 1024;
const MAX_RECORD_BYTES: u64 = 512 * 1024;
static WRITES: Mutex<()> = Mutex::new(());
fn writing() -> MutexGuard<'static, ()> {
crate::sync::lock(&WRITES)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JobRecord {
pub v: u32,
pub session: String,
pub job: String,
pub agent: String,
pub task: String,
pub prompt: String,
#[serde(default)]
pub arguments: Value,
pub workspace: PathBuf,
pub label: String,
#[serde(default)]
pub started_by: JobOrigin,
#[serde(default)]
pub reviewed: bool,
pub owner: ProcessIdentity,
pub state: AgentState,
pub created_unix: u64,
pub updated_unix: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finished_unix: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree: Option<WorktreeRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reply: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default)]
pub reported: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interrupted: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resumed_as: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resumed_from: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dismissed: bool,
}
impl JobRecord {
pub fn resumable(&self) -> bool {
self.state == AgentState::Interrupted && !self.reviewed && self.resumed_as.is_none()
}
}
pub(crate) struct NewJob<'a> {
pub(crate) job: &'a str,
pub(crate) agent: &'a str,
pub(crate) task: &'a str,
pub(crate) arguments: &'a Value,
pub(crate) started_by: JobOrigin,
pub(crate) reviewed: bool,
pub(crate) queued: bool,
}
#[derive(Debug, Clone)]
pub struct JobLedger {
root: PathBuf,
session: String,
label: String,
workspace: PathBuf,
owner: Option<ProcessIdentity>,
written: Arc<Mutex<HashMap<String, u64>>>,
}
impl JobLedger {
pub fn new(root: &Path, session: &str, label: &str, workspace: &Path) -> Option<Self> {
valid_name(session).then(|| Self {
root: root.to_owned(),
session: session.to_owned(),
label: label.to_owned(),
workspace: workspace.to_owned(),
owner: ProcessIdentity::current(),
written: Arc::default(),
})
}
pub(crate) fn record(&self, new: &NewJob<'_>) -> Option<JobRecord> {
let owner = self.owner?;
let now = unix_now();
let mut rest = new.arguments.clone();
let prompt = rest
.as_object_mut()
.and_then(|object| object.remove("prompt"))
.and_then(|prompt| prompt.as_str().map(str::to_owned))
.unwrap_or_default();
Some(JobRecord {
v: 1,
session: self.session.clone(),
job: new.job.to_owned(),
agent: new.agent.to_owned(),
task: new.task.to_owned(),
prompt: bounded(&prompt, MAX_PROMPT_BYTES),
arguments: rest,
workspace: self.workspace.clone(),
label: self.label.clone(),
started_by: new.started_by,
reviewed: new.reviewed,
owner,
state: if new.queued {
AgentState::Queued
} else {
AgentState::Working
},
created_unix: now,
updated_unix: now,
finished_unix: None,
conversation: None,
worktree: None,
reply: None,
error: None,
mode: None,
reported: false,
interrupted: None,
resumed_as: None,
resumed_from: None,
dismissed: false,
})
}
pub(crate) fn last_number(&self) -> u64 {
let Ok(entries) = std::fs::read_dir(self.root.join(&self.session)) else {
return 0;
};
entries
.filter_map(Result::ok)
.filter_map(|entry| {
entry
.file_name()
.to_str()?
.strip_suffix(".json")?
.strip_prefix("job-")?
.parse::<u64>()
.ok()
})
.max()
.unwrap_or(0)
}
pub(crate) fn conversations(&self) -> Option<Vec<String>> {
let entries = std::fs::read_dir(self.root.join(&self.session)).ok()?;
let handles = entries
.filter_map(Result::ok)
.filter_map(|entry| read_record(&entry.path()))
.flat_map(|record| {
let continued = record
.arguments
.get("session")
.and_then(Value::as_str)
.map(str::to_owned);
record.conversation.into_iter().chain(continued)
})
.collect();
Some(handles)
}
pub(crate) fn save(&self, record: &JobRecord) {
if let Err(error) = save(&self.root, record) {
tracing::debug!(job = %record.job, %error, "could not record a background job");
}
}
pub(crate) fn save_revision(&self, record: &JobRecord, revision: u64) {
let mut written = crate::sync::lock(&self.written);
if written
.get(&record.job)
.is_some_and(|newest| *newest >= revision)
{
return;
}
self.save(record);
written.insert(record.job.clone(), revision);
}
}
pub fn save(root: &Path, record: &JobRecord) -> std::io::Result<()> {
let _writing = writing();
write(root, record)
}
fn write(root: &Path, record: &JobRecord) -> std::io::Result<()> {
if !valid_name(&record.session) || !valid_name(&record.job) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"not a session or job name",
));
}
let dir = root.join(&record.session);
let name = format!("{}.json", record.job);
let mut record = record.clone();
if let Some(existing) = read_record(&dir.join(&name)) {
if existing.dismissed && !record.dismissed {
return Ok(());
}
if record.resumed_as.is_none() {
record.resumed_as = existing.resumed_as;
}
if record.resumed_from.is_none() {
record.resumed_from = existing.resumed_from;
}
}
fit_record(&mut record);
write_private_json(&dir, &name, &record)
}
fn fit_record(record: &mut JobRecord) {
let Some(limit) = usize::try_from(MAX_RECORD_BYTES).ok() else {
return;
};
for _ in 0..64 {
let Ok(bytes) = serde_json::to_vec(record) else {
return;
};
if bytes.len() <= limit {
return;
}
let prompt = record.prompt.len();
let reply = record.reply.as_ref().map_or(0, String::len);
let error = record.error.as_ref().map_or(0, String::len);
let longest = prompt.max(reply).max(error);
if longest == 0 {
record.arguments = Value::Null;
record.task.clear();
continue;
}
let keep = longest / 2;
if prompt == longest {
let next = scv_client::text::utf8_prefix(&record.prompt, keep).to_owned();
record.prompt = next;
} else if reply == longest {
let next = record
.reply
.as_deref()
.map(|text| scv_client::text::utf8_prefix(text, keep).to_owned());
record.reply = next;
} else {
let next = record
.error
.as_deref()
.map(|text| scv_client::text::utf8_prefix(text, keep).to_owned());
record.error = next;
}
}
}
pub fn load(root: &Path, session: &str, job: &str) -> Option<JobRecord> {
load_including_dismissed(root, session, job).filter(|record| !record.dismissed)
}
pub fn load_including_dismissed(root: &Path, session: &str, job: &str) -> Option<JobRecord> {
if !valid_name(session) || !valid_name(job) {
return None;
}
read_record(&root.join(session).join(format!("{job}.json")))
}
pub fn dismiss(root: &Path, session: &str, job: &str) -> std::io::Result<()> {
let _writing = writing();
let mut record = load(root, session, job)
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such job record"))?;
record.dismissed = true;
if !record.state.is_final() {
interrupt(&mut record, "dismissed by the owner");
}
record.prompt.clear();
record.task.clear();
record.arguments = Value::Null;
record.reply = None;
record.error = None;
record.interrupted = None;
record.updated_unix = unix_now();
write(root, &record)
}
pub fn remove(root: &Path, session: &str, job: &str) -> std::io::Result<()> {
if !valid_name(session) || !valid_name(job) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"not a session or job name",
));
}
std::fs::remove_file(root.join(session).join(format!("{job}.json")))?;
let _ = std::fs::remove_dir(root.join(session));
Ok(())
}
pub fn update(
root: &Path,
session: &str,
job: &str,
change: impl FnOnce(&mut JobRecord),
) -> Option<JobRecord> {
update_if(root, session, job, |record| {
change(record);
true
})
}
fn update_if(
root: &Path,
session: &str,
job: &str,
change: impl FnOnce(&mut JobRecord) -> bool,
) -> Option<JobRecord> {
let _writing = writing();
let mut record = load(root, session, job)?;
if !change(&mut record) {
return None;
}
record.updated_unix = unix_now();
write(root, &record).ok()?;
Some(record)
}
pub fn list(root: &Path) -> Vec<JobRecord> {
let mut records = list_all(root);
records.retain(|record| !record.dismissed);
records
}
pub fn list_all(root: &Path) -> Vec<JobRecord> {
let Ok(sessions) = std::fs::read_dir(root) else {
return Vec::new();
};
let mut records = Vec::new();
for session in sessions.filter_map(Result::ok) {
let Some(name) = session.file_name().to_str().map(str::to_owned) else {
continue;
};
if !valid_name(&name)
|| !std::fs::symlink_metadata(session.path()).is_ok_and(|meta| meta.is_dir())
{
continue;
}
let Ok(jobs) = std::fs::read_dir(session.path()) else {
continue;
};
for job in jobs.filter_map(Result::ok) {
if let Some(record) = read_record(&job.path())
&& record.session == name
{
records.push(record);
}
}
}
records
}
fn read_record(path: &Path) -> Option<JobRecord> {
use std::os::unix::fs::OpenOptionsExt as _;
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
.ok()?;
let mut bytes = Vec::new();
file.take(MAX_RECORD_BYTES).read_to_end(&mut bytes).ok()?;
let record: JobRecord = serde_json::from_slice(&bytes).ok()?;
let stem = path.file_stem()?.to_str()?;
(stem == record.job).then_some(record)
}
pub fn settle_orphans(root: &Path) -> Vec<JobRecord> {
let mut settled = Vec::new();
for record in list(root) {
if record.state.is_final() || record.owner.is_alive() {
continue;
}
if let Some(record) = update_if(root, &record.session, &record.job, |record| {
let orphaned = !record.state.is_final() && !record.owner.is_alive();
if orphaned {
interrupt(record, "the SCV process that ran it stopped");
}
orphaned
}) {
settled.push(record);
}
}
settled
}
pub(crate) fn interrupt(record: &mut JobRecord, reason: &str) {
let now = unix_now();
record.state = AgentState::Interrupted;
record.interrupted = Some(reason.to_owned());
record.finished_unix = Some(now);
record.updated_unix = now;
}
pub fn prune(root: &Path, keep: Duration) -> usize {
let now = unix_now();
let mut removed = 0;
let old = |record: &JobRecord| {
record.state.is_final() && now.saturating_sub(record.updated_unix) > keep.as_secs()
};
for record in list_all(root) {
if !old(&record) {
continue;
}
let _writing = writing();
let path = root
.join(&record.session)
.join(format!("{}.json", record.job));
if read_record(&path).is_some_and(|record| old(&record))
&& remove(root, &record.session, &record.job).is_ok()
{
removed += 1;
}
}
removed
}
pub(crate) fn finish(record: &mut JobRecord, state: AgentState, result: &Value, reported: bool) {
let now = unix_now();
record.state = state;
record.finished_unix = Some(now);
record.updated_unix = now;
record.reported |= reported;
let text = |key: &str| result.get(key).and_then(Value::as_str).map(str::to_owned);
record.reply = text("reply").map(|reply| bounded(&reply, MAX_REPLY_BYTES));
record.error = text("error").map(|error| bounded(&error, MAX_REPLY_BYTES));
record.mode = text("mode").map(|mode| bounded(&mode, 200));
if let Some(conversation) = text("session") {
record.conversation = Some(conversation);
}
if let Some(worktree) = result
.get("worktree")
.and_then(|value| serde_json::from_value::<WorktreeRef>(value.clone()).ok())
{
record.worktree = Some(worktree);
}
}
pub fn valid_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 80
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
}
fn bounded(text: &str, max: usize) -> String {
scv_client::text::utf8_prefix(text, max).to_owned()
}
pub(crate) fn now() -> u64 {
unix_now()
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |elapsed| elapsed.as_secs())
}
#[cfg(test)]
mod tests;