1use std::{
14 collections::HashMap,
15 io::Read as _,
16 path::{Path, PathBuf},
17 sync::{Arc, Mutex, MutexGuard},
18 time::{Duration, SystemTime, UNIX_EPOCH},
19};
20
21use scv_protocol::{AgentState, JobOrigin, WorktreeRef};
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24
25use crate::delegate::records::{ProcessIdentity, write_private_json};
26
27const MAX_PROMPT_BYTES: usize = 16 * 1024;
29const MAX_REPLY_BYTES: usize = 8 * 1024;
31const MAX_RECORD_BYTES: u64 = 512 * 1024;
37
38static WRITES: Mutex<()> = Mutex::new(());
44
45fn writing() -> MutexGuard<'static, ()> {
46 crate::sync::lock(&WRITES)
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct JobRecord {
52 pub v: u32,
53 pub session: String,
54 pub job: String,
55 pub agent: String,
56 pub task: String,
57 pub prompt: String,
59 #[serde(default)]
62 pub arguments: Value,
63 pub workspace: PathBuf,
64 pub label: String,
66 #[serde(default)]
67 pub started_by: JobOrigin,
68 #[serde(default)]
69 pub reviewed: bool,
70 pub owner: ProcessIdentity,
72 pub state: AgentState,
73 pub created_unix: u64,
74 pub updated_unix: u64,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub finished_unix: Option<u64>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub conversation: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub worktree: Option<WorktreeRef>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub reply: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub error: Option<String>,
86 #[serde(default)]
88 pub reported: bool,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub interrupted: Option<String>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub resumed_as: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub resumed_from: Option<String>,
98 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
101 pub dismissed: bool,
102}
103
104impl JobRecord {
105 pub fn resumable(&self) -> bool {
109 self.state == AgentState::Interrupted && !self.reviewed && self.resumed_as.is_none()
110 }
111}
112
113pub(crate) struct NewJob<'a> {
115 pub(crate) job: &'a str,
116 pub(crate) agent: &'a str,
117 pub(crate) task: &'a str,
118 pub(crate) arguments: &'a Value,
120 pub(crate) started_by: JobOrigin,
121 pub(crate) reviewed: bool,
122 pub(crate) queued: bool,
124}
125
126#[derive(Debug, Clone)]
128pub struct JobLedger {
129 root: PathBuf,
130 session: String,
131 label: String,
132 workspace: PathBuf,
133 owner: Option<ProcessIdentity>,
134 written: Arc<Mutex<HashMap<String, u64>>>,
137}
138
139impl JobLedger {
140 pub fn new(root: &Path, session: &str, label: &str, workspace: &Path) -> Option<Self> {
144 valid_name(session).then(|| Self {
145 root: root.to_owned(),
146 session: session.to_owned(),
147 label: label.to_owned(),
148 workspace: workspace.to_owned(),
149 owner: ProcessIdentity::current(),
150 written: Arc::default(),
151 })
152 }
153
154 pub(crate) fn record(&self, new: &NewJob<'_>) -> Option<JobRecord> {
156 let owner = self.owner?;
157 let now = unix_now();
158 let mut rest = new.arguments.clone();
159 let prompt = rest
160 .as_object_mut()
161 .and_then(|object| object.remove("prompt"))
162 .and_then(|prompt| prompt.as_str().map(str::to_owned))
163 .unwrap_or_default();
164 Some(JobRecord {
165 v: 1,
166 session: self.session.clone(),
167 job: new.job.to_owned(),
168 agent: new.agent.to_owned(),
169 task: new.task.to_owned(),
170 prompt: bounded(&prompt, MAX_PROMPT_BYTES),
171 arguments: rest,
172 workspace: self.workspace.clone(),
173 label: self.label.clone(),
174 started_by: new.started_by,
175 reviewed: new.reviewed,
176 owner,
177 state: if new.queued {
178 AgentState::Queued
179 } else {
180 AgentState::Working
181 },
182 created_unix: now,
183 updated_unix: now,
184 finished_unix: None,
185 conversation: None,
186 worktree: None,
187 reply: None,
188 error: None,
189 reported: false,
190 interrupted: None,
191 resumed_as: None,
192 resumed_from: None,
193 dismissed: false,
194 })
195 }
196
197 pub(crate) fn last_number(&self) -> u64 {
201 let Ok(entries) = std::fs::read_dir(self.root.join(&self.session)) else {
202 return 0;
203 };
204 entries
205 .filter_map(Result::ok)
206 .filter_map(|entry| {
207 entry
208 .file_name()
209 .to_str()?
210 .strip_suffix(".json")?
211 .strip_prefix("job-")?
212 .parse::<u64>()
213 .ok()
214 })
215 .max()
216 .unwrap_or(0)
217 }
218
219 pub(crate) fn conversations(&self) -> Option<Vec<String>> {
223 let entries = std::fs::read_dir(self.root.join(&self.session)).ok()?;
224 let handles = entries
225 .filter_map(Result::ok)
226 .filter_map(|entry| read_record(&entry.path()))
227 .flat_map(|record| {
228 let continued = record
229 .arguments
230 .get("session")
231 .and_then(Value::as_str)
232 .map(str::to_owned);
233 record.conversation.into_iter().chain(continued)
234 })
235 .collect();
236 Some(handles)
237 }
238
239 pub(crate) fn save(&self, record: &JobRecord) {
241 if let Err(error) = save(&self.root, record) {
242 tracing::debug!(job = %record.job, %error, "could not record a background job");
243 }
244 }
245
246 pub(crate) fn save_revision(&self, record: &JobRecord, revision: u64) {
250 let mut written = crate::sync::lock(&self.written);
251 if written
252 .get(&record.job)
253 .is_some_and(|newest| *newest >= revision)
254 {
255 return;
256 }
257 self.save(record);
258 written.insert(record.job.clone(), revision);
259 }
260}
261
262pub fn save(root: &Path, record: &JobRecord) -> std::io::Result<()> {
264 let _writing = writing();
265 write(root, record)
266}
267
268fn write(root: &Path, record: &JobRecord) -> std::io::Result<()> {
275 if !valid_name(&record.session) || !valid_name(&record.job) {
276 return Err(std::io::Error::new(
277 std::io::ErrorKind::InvalidInput,
278 "not a session or job name",
279 ));
280 }
281 let dir = root.join(&record.session);
282 let name = format!("{}.json", record.job);
283 let mut record = record.clone();
284 if let Some(existing) = read_record(&dir.join(&name)) {
285 if existing.dismissed && !record.dismissed {
289 return Ok(());
290 }
291 if record.resumed_as.is_none() {
294 record.resumed_as = existing.resumed_as;
295 }
296 if record.resumed_from.is_none() {
297 record.resumed_from = existing.resumed_from;
298 }
299 }
300 fit_record(&mut record);
301 write_private_json(&dir, &name, &record)
302}
303
304fn fit_record(record: &mut JobRecord) {
307 let Some(limit) = usize::try_from(MAX_RECORD_BYTES).ok() else {
308 return;
309 };
310 for _ in 0..64 {
313 let Ok(bytes) = serde_json::to_vec(record) else {
314 return;
315 };
316 if bytes.len() <= limit {
317 return;
318 }
319 let prompt = record.prompt.len();
320 let reply = record.reply.as_ref().map_or(0, String::len);
321 let error = record.error.as_ref().map_or(0, String::len);
322 let longest = prompt.max(reply).max(error);
323 if longest == 0 {
324 record.arguments = Value::Null;
325 record.task.clear();
326 continue;
327 }
328 let keep = longest / 2;
329 if prompt == longest {
330 let next = scv_client::text::utf8_prefix(&record.prompt, keep).to_owned();
331 record.prompt = next;
332 } else if reply == longest {
333 let next = record
334 .reply
335 .as_deref()
336 .map(|text| scv_client::text::utf8_prefix(text, keep).to_owned());
337 record.reply = next;
338 } else {
339 let next = record
340 .error
341 .as_deref()
342 .map(|text| scv_client::text::utf8_prefix(text, keep).to_owned());
343 record.error = next;
344 }
345 }
346}
347
348pub fn load(root: &Path, session: &str, job: &str) -> Option<JobRecord> {
350 load_including_dismissed(root, session, job).filter(|record| !record.dismissed)
351}
352
353pub fn load_including_dismissed(root: &Path, session: &str, job: &str) -> Option<JobRecord> {
357 if !valid_name(session) || !valid_name(job) {
358 return None;
359 }
360 read_record(&root.join(session).join(format!("{job}.json")))
361}
362
363pub fn dismiss(root: &Path, session: &str, job: &str) -> std::io::Result<()> {
367 let _writing = writing();
368 let mut record = load(root, session, job)
369 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no such job record"))?;
370 record.dismissed = true;
371 if !record.state.is_final() {
374 interrupt(&mut record, "dismissed by the owner");
375 }
376 record.prompt.clear();
377 record.task.clear();
378 record.arguments = Value::Null;
379 record.reply = None;
380 record.error = None;
381 record.interrupted = None;
382 record.updated_unix = unix_now();
383 write(root, &record)
384}
385
386pub fn remove(root: &Path, session: &str, job: &str) -> std::io::Result<()> {
388 if !valid_name(session) || !valid_name(job) {
389 return Err(std::io::Error::new(
390 std::io::ErrorKind::InvalidInput,
391 "not a session or job name",
392 ));
393 }
394 std::fs::remove_file(root.join(session).join(format!("{job}.json")))?;
395 let _ = std::fs::remove_dir(root.join(session));
397 Ok(())
398}
399
400pub fn update(
402 root: &Path,
403 session: &str,
404 job: &str,
405 change: impl FnOnce(&mut JobRecord),
406) -> Option<JobRecord> {
407 update_if(root, session, job, |record| {
408 change(record);
409 true
410 })
411}
412
413fn update_if(
416 root: &Path,
417 session: &str,
418 job: &str,
419 change: impl FnOnce(&mut JobRecord) -> bool,
420) -> Option<JobRecord> {
421 let _writing = writing();
422 let mut record = load(root, session, job)?;
423 if !change(&mut record) {
424 return None;
425 }
426 record.updated_unix = unix_now();
427 write(root, &record).ok()?;
428 Some(record)
429}
430
431pub fn list(root: &Path) -> Vec<JobRecord> {
433 let mut records = list_all(root);
434 records.retain(|record| !record.dismissed);
435 records
436}
437
438pub fn list_all(root: &Path) -> Vec<JobRecord> {
440 let Ok(sessions) = std::fs::read_dir(root) else {
441 return Vec::new();
442 };
443 let mut records = Vec::new();
444 for session in sessions.filter_map(Result::ok) {
445 let Some(name) = session.file_name().to_str().map(str::to_owned) else {
446 continue;
447 };
448 if !valid_name(&name)
449 || !std::fs::symlink_metadata(session.path()).is_ok_and(|meta| meta.is_dir())
450 {
451 continue;
452 }
453 let Ok(jobs) = std::fs::read_dir(session.path()) else {
454 continue;
455 };
456 for job in jobs.filter_map(Result::ok) {
457 if let Some(record) = read_record(&job.path())
458 && record.session == name
459 {
460 records.push(record);
461 }
462 }
463 }
464 records
465}
466
467fn read_record(path: &Path) -> Option<JobRecord> {
468 use std::os::unix::fs::OpenOptionsExt as _;
469 let file = std::fs::OpenOptions::new()
470 .read(true)
471 .custom_flags(libc::O_NOFOLLOW)
472 .open(path)
473 .ok()?;
474 let mut bytes = Vec::new();
475 file.take(MAX_RECORD_BYTES).read_to_end(&mut bytes).ok()?;
476 let record: JobRecord = serde_json::from_slice(&bytes).ok()?;
477 let stem = path.file_stem()?.to_str()?;
479 (stem == record.job).then_some(record)
480}
481
482pub fn settle_orphans(root: &Path) -> Vec<JobRecord> {
485 let mut settled = Vec::new();
486 for record in list(root) {
487 if record.state.is_final() || record.owner.is_alive() {
488 continue;
489 }
490 if let Some(record) = update_if(root, &record.session, &record.job, |record| {
493 let orphaned = !record.state.is_final() && !record.owner.is_alive();
494 if orphaned {
495 interrupt(record, "the SCV process that ran it stopped");
496 }
497 orphaned
498 }) {
499 settled.push(record);
500 }
501 }
502 settled
503}
504
505pub(crate) fn interrupt(record: &mut JobRecord, reason: &str) {
507 let now = unix_now();
508 record.state = AgentState::Interrupted;
509 record.interrupted = Some(reason.to_owned());
510 record.finished_unix = Some(now);
511 record.updated_unix = now;
512}
513
514pub fn prune(root: &Path, keep: Duration) -> usize {
517 let now = unix_now();
518 let mut removed = 0;
519 let old = |record: &JobRecord| {
520 record.state.is_final() && now.saturating_sub(record.updated_unix) > keep.as_secs()
521 };
522 for record in list_all(root) {
523 if !old(&record) {
524 continue;
525 }
526 let _writing = writing();
529 let path = root
530 .join(&record.session)
531 .join(format!("{}.json", record.job));
532 if read_record(&path).is_some_and(|record| old(&record))
533 && remove(root, &record.session, &record.job).is_ok()
534 {
535 removed += 1;
536 }
537 }
538 removed
539}
540
541pub(crate) fn finish(record: &mut JobRecord, state: AgentState, result: &Value, reported: bool) {
543 let now = unix_now();
544 record.state = state;
545 record.finished_unix = Some(now);
546 record.updated_unix = now;
547 record.reported |= reported;
548 let text = |key: &str| result.get(key).and_then(Value::as_str).map(str::to_owned);
549 record.reply = text("reply").map(|reply| bounded(&reply, MAX_REPLY_BYTES));
550 record.error = text("error").map(|error| bounded(&error, MAX_REPLY_BYTES));
551 if let Some(conversation) = text("session") {
552 record.conversation = Some(conversation);
553 }
554 if let Some(worktree) = result
555 .get("worktree")
556 .and_then(|value| serde_json::from_value::<WorktreeRef>(value.clone()).ok())
557 {
558 record.worktree = Some(worktree);
559 }
560}
561
562pub fn valid_name(name: &str) -> bool {
565 !name.is_empty()
566 && name.len() <= 80
567 && name
568 .bytes()
569 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
570}
571
572fn bounded(text: &str, max: usize) -> String {
573 scv_client::text::utf8_prefix(text, max).to_owned()
574}
575
576pub(crate) fn now() -> u64 {
578 unix_now()
579}
580
581fn unix_now() -> u64 {
582 SystemTime::now()
583 .duration_since(UNIX_EPOCH)
584 .map_or(0, |elapsed| elapsed.as_secs())
585}
586
587#[cfg(test)]
588mod tests;