1use std::{
25 fs::File,
26 io::{BufRead as _, Read as _, Seek as _, SeekFrom, Write as _},
27 path::{Path, PathBuf},
28 sync::{Arc, Mutex},
29 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
30};
31
32use scv_protocol::{AgentState, RunEvent, RunEventKind, RunLogPage};
33use serde::{Deserialize, Serialize};
34
35use crate::{
36 delegate::{
37 progress::{redact, scrub},
38 records::{ProcessIdentity, write_private_json, write_private_json_unsynced},
39 },
40 sync::lock,
41};
42
43pub const MAX_LOG_BYTES: u64 = 4 * 1024 * 1024;
45const MAX_EVENT_CHARS: usize = 2000;
47const MAX_PROMPT_CHARS: usize = 4000;
49const STATE_REFRESH: Duration = Duration::from_secs(5);
51const MAX_STATE_BYTES: u64 = 64 * 1024;
53pub const MAX_PAGE_EVENTS: usize = 500;
55const MAX_PAGE_BYTES: usize = 256 * 1024;
56pub const QUIET_AFTER: Duration = Duration::from_secs(5 * 60);
59const MAX_RUN_JOBS: usize = 32;
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub(crate) enum RunTransport {
65 Cli,
67 Acp,
69 Scv,
71 Terminal,
74}
75
76impl RunTransport {
77 fn as_str(self) -> &'static str {
78 match self {
79 Self::Cli => "cli",
80 Self::Acp => "acp",
81 Self::Scv => "scv",
82 Self::Terminal => "terminal",
83 }
84 }
85}
86
87pub(crate) struct RunHeader {
89 pub(crate) handle: String,
90 pub(crate) agent: String,
91 pub(crate) session: String,
92 pub(crate) transport: RunTransport,
93 pub(crate) cwd: PathBuf,
94 pub(crate) conversation: Option<(String, u32)>,
95 pub(crate) streams: bool,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct RunState {
104 pub v: u32,
105 pub handle: String,
106 pub agent: String,
107 pub session: String,
108 pub transport: String,
110 pub cwd: PathBuf,
111 pub owner: ProcessIdentity,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub conversation: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub turn: Option<u32>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub job: Option<String>,
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
123 pub jobs: Vec<String>,
124 pub state: AgentState,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub detail: Option<String>,
127 pub started_unix: u64,
128 pub since_unix: u64,
130 pub last_activity_unix: u64,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub last_line: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub vendor: Option<String>,
138 #[serde(default)]
139 pub steerable: bool,
140 #[serde(default)]
141 pub streams: bool,
142 #[serde(default)]
144 pub ended: bool,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub ended_unix: Option<u64>,
148 #[serde(default)]
150 pub seq: u64,
151 #[serde(default)]
152 pub bytes: u64,
153}
154
155#[derive(Serialize, Deserialize)]
157struct Line {
158 v: u32,
159 #[serde(flatten)]
160 event: RunEvent,
161}
162
163#[derive(Clone)]
165pub(crate) struct RunActivity(Arc<Mutex<Writer>>);
166
167impl std::fmt::Debug for RunActivity {
168 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 formatter.write_str("RunActivity")
170 }
171}
172
173struct Writer {
174 dir: PathBuf,
175 log: Option<File>,
176 state: RunState,
177 pending: String,
179 truncated: bool,
180 last_state_write: Instant,
181 ending: Option<String>,
185 last_turn: Option<AgentState>,
187 turn_open: bool,
189}
190
191impl RunActivity {
192 pub(crate) fn open(dir: &Path, owner: ProcessIdentity, header: RunHeader) -> Option<Self> {
195 if !valid_handle(&header.handle) {
196 return None;
197 }
198 let file = match create_log(dir, &header.handle) {
199 Ok(file) => file,
200 Err(error) => {
201 tracing::debug!(handle = %header.handle, %error, "could not start a run's activity log");
202 return None;
203 }
204 };
205 let now = unix_now();
206 let (conversation, turn) = header
207 .conversation
208 .map_or((None, None), |(handle, turn)| (Some(handle), Some(turn)));
209 let activity = Self(Arc::new(Mutex::new(Writer {
210 dir: dir.to_owned(),
211 log: Some(file),
212 state: RunState {
213 v: 1,
214 handle: header.handle,
215 agent: header.agent,
216 session: header.session,
217 transport: header.transport.as_str().to_owned(),
218 cwd: header.cwd,
219 owner,
220 conversation,
221 turn,
222 job: None,
223 jobs: Vec::new(),
224 state: AgentState::Starting,
225 detail: None,
226 started_unix: now,
227 since_unix: now,
228 last_activity_unix: now,
229 last_line: None,
230 vendor: None,
231 steerable: false,
232 streams: header.streams,
233 ended: false,
234 ended_unix: None,
235 seq: 0,
236 bytes: 0,
237 },
238 pending: String::new(),
239 truncated: false,
240 last_state_write: Instant::now(),
241 ending: None,
242 last_turn: None,
243 turn_open: false,
244 })));
245 {
246 let mut writer = lock(&activity.0);
247 let text = format!(
248 "{} over {} in {}",
249 writer.state.agent,
250 writer.state.transport,
251 writer.state.cwd.display()
252 );
253 writer.event(RunEventKind::RunStarted, text, |_| {});
254 writer.save();
255 }
256 Some(activity)
257 }
258
259 pub(crate) fn turn_started(
262 &self,
263 conversation: Option<&str>,
264 turn: u32,
265 prompt: &str,
266 job: Option<&str>,
267 ) {
268 let mut writer = lock(&self.0);
269 writer.flush_text();
270 if let Some(conversation) = conversation {
271 writer.state.conversation = Some(conversation.to_owned());
272 }
273 writer.state.turn = Some(turn);
274 writer.state.job = job.map(str::to_owned);
275 writer.turn_open = true;
276 if let Some(job) = job
277 && !writer.state.jobs.iter().any(|served| served == job)
278 {
279 if writer.state.jobs.len() >= MAX_RUN_JOBS {
280 writer.state.jobs.remove(0);
281 }
282 writer.state.jobs.push(job.to_owned());
283 }
284 writer.event(
285 RunEventKind::TurnStarted,
286 bounded_chars(&redact(prompt), MAX_PROMPT_CHARS),
287 |event| {
288 event.turn = Some(turn);
289 event.job = job.map(str::to_owned);
290 },
291 );
292 writer.transition(AgentState::Working, None);
293 }
294
295 pub(crate) fn text(&self, chunk: &str) {
297 let mut writer = lock(&self.0);
298 writer.pending.push_str(chunk);
299 while let Some(end) = writer.pending.find('\n') {
300 let line: String = writer.pending.drain(..=end).collect();
301 writer.text_line(line.trim_end_matches(['\n', '\r']));
302 }
303 if writer.pending.chars().count() > MAX_EVENT_CHARS {
304 let line = std::mem::take(&mut writer.pending);
305 writer.text_line(&line);
306 }
307 writer.active();
308 }
309
310 pub(crate) fn message(&self, text: &str) {
312 let mut writer = lock(&self.0);
313 writer.flush_text();
314 for line in text.lines() {
315 writer.text_line(line);
316 }
317 writer.active();
318 }
319
320 pub(crate) fn tool(&self, line: &str) {
323 let line = scrub(line);
324 let line = line.as_str();
325 let mut writer = lock(&self.0);
326 writer.flush_text();
327 let failed = line.ends_with(" failed") || line.starts_with("exit ");
328 let kind = if failed {
329 RunEventKind::ToolFailed
330 } else {
331 RunEventKind::Tool
332 };
333 writer.state.last_line = Some(bounded_chars(line, 200));
334 writer.event(kind, bounded_chars(line, MAX_EVENT_CHARS), |_| {});
335 writer.active();
336 }
337
338 pub(crate) fn plan(&self, step: &str) {
340 let mut writer = lock(&self.0);
341 writer.flush_text();
342 writer.event(
343 RunEventKind::Plan,
344 bounded_chars(&scrub(step), MAX_EVENT_CHARS),
345 |_| {},
346 );
347 writer.active();
348 }
349
350 pub(crate) fn approval_requested(&self, risk: &str, summary: &str) {
353 let mut writer = lock(&self.0);
354 writer.flush_text();
355 let summary = bounded_chars(&scrub(summary), MAX_EVENT_CHARS);
356 writer.event(RunEventKind::ApprovalRequested, summary.clone(), |event| {
357 event.risk = Some(risk.to_owned());
358 });
359 writer.transition(
360 AgentState::AwaitingInput,
361 Some(format!(
362 "waiting for approval: {}",
363 bounded_chars(&summary, 200)
364 )),
365 );
366 }
367
368 pub(crate) fn approval_resolved(&self, approved: bool) {
370 let mut writer = lock(&self.0);
371 let status = if approved { "approved" } else { "denied" };
372 writer.event(RunEventKind::ApprovalResolved, String::new(), |event| {
373 event.status = Some(status.to_owned());
374 });
375 writer.transition(AgentState::Working, None);
376 }
377
378 pub(crate) fn steered(&self, prompt: &str) {
380 let mut writer = lock(&self.0);
381 writer.flush_text();
382 writer.event(
383 RunEventKind::Steered,
384 bounded_chars(&redact(prompt), MAX_PROMPT_CHARS),
385 |_| {},
386 );
387 writer.active();
388 }
389
390 pub(crate) fn turn_ended(&self, status: &str, error: Option<&str>) {
392 let mut writer = lock(&self.0);
393 writer.flush_text();
394 let turn = writer.state.turn;
395 writer.event(
396 RunEventKind::TurnEnded,
397 error
398 .map(|error| bounded_chars(&redact(error), MAX_EVENT_CHARS))
399 .unwrap_or_default(),
400 |event| {
401 event.turn = turn;
402 event.status = Some(status.to_owned());
403 },
404 );
405 let state = match status {
406 "completed" => AgentState::Completed,
407 "declined" => AgentState::Declined,
408 "timeout" => AgentState::Timeout,
409 "cancelled" => AgentState::Cancelled,
410 _ => AgentState::Failed,
411 };
412 writer.last_turn = Some(state);
413 writer.turn_open = false;
414 writer.transition(state, error.map(|error| bounded_chars(&redact(error), 300)));
415 if let Some(how) = writer.ending.take() {
416 writer.end(&how);
417 writer.save();
418 }
419 }
420
421 pub(crate) fn idle(&self) {
424 let mut writer = lock(&self.0);
425 writer.turn_open = false;
426 writer.transition(AgentState::Idle, None);
427 }
428
429 pub(crate) fn turn_released(&self) {
433 let mut writer = lock(&self.0);
434 writer.turn_open = false;
435 if let Some(how) = writer.ending.take() {
436 writer.end(&how);
437 writer.save();
438 }
439 }
440
441 pub(crate) fn run_ended(&self, how: &str) {
448 let mut writer = lock(&self.0);
449 if writer.state.ended || writer.ending.is_some() {
450 return;
451 }
452 writer.flush_text();
453 let state = writer.state.state;
454 if writer.turn_open {
455 writer.transition(AgentState::Failed, Some(format!("its process {how}")));
456 writer.ending = Some(how.to_owned());
457 } else if state.is_final() {
458 writer.end(how);
459 writer.save();
460 } else {
461 let ended = match writer.last_turn {
462 Some(last) if how == "exited" && state == AgentState::Idle => last,
463 _ => AgentState::Failed,
464 };
465 writer.end(how);
466 writer.transition(ended, Some(format!("its process {how}")));
467 }
468 }
469
470 pub(crate) fn terminal(&self, note: &str, detail: &str) {
474 let mut writer = lock(&self.0);
475 writer.event(
476 RunEventKind::Note,
477 bounded_chars(&redact(note), MAX_EVENT_CHARS),
478 |_| {},
479 );
480 writer.transition(AgentState::Working, Some(detail.to_owned()));
481 }
482
483 pub(crate) fn terminal_closed(&self, how: &str) {
486 let mut writer = lock(&self.0);
487 if writer.state.ended {
488 return;
489 }
490 writer.event(
491 RunEventKind::Note,
492 bounded_chars(
493 &format!("the terminal closed: {}", redact(how)),
494 MAX_EVENT_CHARS,
495 ),
496 |_| {},
497 );
498 writer.transition(
499 AgentState::Completed,
500 Some(format!(
501 "the terminal closed: {}",
502 bounded_chars(&redact(how), 200)
503 )),
504 );
505 writer.end("exited");
506 writer.save();
507 }
508
509 pub(crate) fn set_vendor(&self, vendor: &str) {
511 let mut writer = lock(&self.0);
512 if writer.state.vendor.as_deref() != Some(vendor) {
513 writer.state.vendor = Some(vendor.to_owned());
514 writer.save();
515 }
516 }
517
518 pub(crate) fn set_steerable(&self, steerable: bool) {
520 let mut writer = lock(&self.0);
521 if writer.state.steerable != steerable {
522 writer.state.steerable = steerable;
523 writer.save();
524 }
525 }
526}
527
528impl Writer {
529 fn end(&mut self, how: &str) {
531 self.flush_text();
532 self.append(RunEventKind::RunEnded, String::new(), |event| {
533 event.status = Some(how.to_owned());
534 });
535 self.state.ended = true;
536 self.state.ended_unix = Some(unix_now());
537 }
538
539 fn text_line(&mut self, line: &str) {
540 let line = redact(line);
541 if !line.trim().is_empty() {
542 self.state.last_line = Some(bounded_chars(line.trim(), 200));
543 }
544 self.event(
545 RunEventKind::Text,
546 bounded_chars(&line, MAX_EVENT_CHARS),
547 |_| {},
548 );
549 }
550
551 fn flush_text(&mut self) {
552 if !self.pending.is_empty() {
553 let line = std::mem::take(&mut self.pending);
554 self.text_line(line.trim_end_matches(['\n', '\r']));
555 }
556 }
557
558 fn event(&mut self, kind: RunEventKind, text: String, fill: impl FnOnce(&mut RunEvent)) {
560 let keeps_going = matches!(
561 kind,
562 RunEventKind::Text | RunEventKind::Tool | RunEventKind::ToolFailed | RunEventKind::Plan
563 );
564 if self.truncated && keeps_going {
565 return;
566 }
567 if !self.truncated && self.state.bytes >= MAX_LOG_BYTES {
568 self.truncated = true;
569 self.append(RunEventKind::Truncated, String::new(), |_| {});
570 if keeps_going {
571 return;
572 }
573 }
574 self.append(kind, text, fill);
575 }
576
577 fn append(&mut self, kind: RunEventKind, text: String, fill: impl FnOnce(&mut RunEvent)) {
578 let Some(file) = self.log.as_mut() else {
579 return;
580 };
581 let mut event = RunEvent {
582 seq: self.state.seq + 1,
583 ts_ms: unix_ms(),
584 kind,
585 text: sanitize(&text),
586 turn: None,
587 status: None,
588 job: None,
589 risk: None,
590 by: None,
591 };
592 fill(&mut event);
593 let Ok(mut bytes) = serde_json::to_vec(&Line { v: 1, event }) else {
594 return;
595 };
596 bytes.push(b'\n');
597 match file.write_all(&bytes) {
598 Ok(()) => {
599 self.state.seq += 1;
600 self.state.bytes += bytes.len() as u64;
601 }
602 Err(error) => {
603 tracing::debug!(handle = %self.state.handle, %error, "could not write a run's activity");
604 self.log = None;
607 }
608 }
609 }
610
611 fn active(&mut self) {
613 self.state.last_activity_unix = unix_now();
614 if self.last_state_write.elapsed() >= STATE_REFRESH {
615 self.save();
616 }
617 }
618
619 fn transition(&mut self, state: AgentState, detail: Option<String>) {
620 if (self.state.ended || self.ending.is_some()) && !state.is_final() {
622 return;
623 }
624 let now = unix_now();
625 if self.state.state != state {
626 self.state.since_unix = now;
627 }
628 self.state.state = state;
629 self.state.detail = detail;
630 self.state.last_activity_unix = now;
631 self.save();
632 }
633
634 fn save(&mut self) {
635 self.last_state_write = Instant::now();
636 let name = format!("{}.state.json", self.state.handle);
637 if let Err(error) = write_private_json_unsynced(&self.dir, &name, &self.state) {
641 tracing::debug!(handle = %self.state.handle, %error, "could not save a run's state");
642 }
643 }
644}
645
646impl Drop for Writer {
647 fn drop(&mut self) {
648 if !self.state.ended {
650 let how = self.ending.take().unwrap_or_else(|| "exited".to_owned());
651 self.end(&how);
652 if !self.state.state.is_final() {
653 self.state.state = if self.state.state == AgentState::Idle {
654 AgentState::Completed
655 } else {
656 AgentState::Failed
657 };
658 self.state.since_unix = unix_now();
659 }
660 self.save();
661 }
662 }
663}
664
665pub fn valid_handle(handle: &str) -> bool {
668 let Some((agent, suffix)) = handle.rsplit_once('-') else {
669 return false;
670 };
671 handle.len() <= 64
672 && !agent.is_empty()
673 && agent
674 .bytes()
675 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
676 && agent.as_bytes()[0].is_ascii_lowercase()
677 && suffix.len() == 6
678 && suffix
679 .bytes()
680 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
681}
682
683fn create_log(dir: &Path, handle: &str) -> std::io::Result<File> {
684 use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
685 std::fs::create_dir_all(dir)?;
686 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
687 std::fs::OpenOptions::new()
688 .append(true)
689 .create_new(true)
690 .mode(0o600)
691 .custom_flags(libc::O_NOFOLLOW)
692 .open(dir.join(format!("{handle}.jsonl")))
693}
694
695pub fn load_state(dir: &Path, handle: &str) -> Option<RunState> {
697 if !valid_handle(handle) {
698 return None;
699 }
700 read_state(&dir.join(format!("{handle}.state.json")))
701}
702
703fn read_state(path: &Path) -> Option<RunState> {
704 let file = open_nofollow(path).ok()?;
705 let mut bytes = Vec::new();
706 file.take(MAX_STATE_BYTES).read_to_end(&mut bytes).ok()?;
707 let state: RunState = serde_json::from_slice(&bytes).ok()?;
708 let stem = path.file_name()?.to_str()?.strip_suffix(".state.json")?;
710 (stem == state.handle).then_some(state)
711}
712
713fn open_nofollow(path: &Path) -> std::io::Result<File> {
714 use std::os::unix::fs::OpenOptionsExt as _;
715 std::fs::OpenOptions::new()
716 .read(true)
717 .custom_flags(libc::O_NOFOLLOW)
718 .open(path)
719}
720
721pub fn list_states(dir: &Path) -> Vec<RunState> {
723 let Ok(entries) = std::fs::read_dir(dir) else {
724 return Vec::new();
725 };
726 entries
727 .filter_map(Result::ok)
728 .filter(|entry| {
729 entry
730 .file_name()
731 .to_str()
732 .is_some_and(|name| name.ends_with(".state.json"))
733 })
734 .filter_map(|entry| read_state(&entry.path()))
735 .collect()
736}
737
738pub fn read_page(
741 dir: &Path,
742 handle: &str,
743 after: u64,
744 limit: usize,
745) -> std::io::Result<RunLogPage> {
746 if !valid_handle(handle) {
747 return Err(std::io::Error::new(
748 std::io::ErrorKind::InvalidInput,
749 "not a run handle",
750 ));
751 }
752 let limit = limit.clamp(1, MAX_PAGE_EVENTS);
753 let mut file = open_nofollow(&dir.join(format!("{handle}.jsonl")))?;
754 let length = file.metadata()?.len();
755 let mut position = after.min(length);
756 if position > 0 {
757 file.seek(SeekFrom::Start(position - 1))?;
760 let mut previous = [0u8; 1];
761 file.read_exact(&mut previous)?;
762 if previous[0] != b'\n' {
763 let mut skipped = Vec::new();
764 let mut reader = std::io::BufReader::new(&mut file);
765 position += reader.read_until(b'\n', &mut skipped)? as u64;
766 }
767 }
768 file.seek(SeekFrom::Start(position))?;
769 let mut reader = std::io::BufReader::new(file);
770 let mut events = Vec::new();
771 let mut bytes = 0usize;
772 let mut more = false;
773 let mut ended = false;
774 loop {
775 let mut line = Vec::new();
776 let read = reader.read_until(b'\n', &mut line)?;
777 if read == 0 || line.last() != Some(&b'\n') {
778 break;
780 }
781 if read > MAX_PAGE_BYTES {
782 position += read as u64;
784 continue;
785 }
786 if events.len() >= limit || bytes + read > MAX_PAGE_BYTES {
787 more = true;
788 break;
789 }
790 position += read as u64;
791 bytes += read;
792 if let Ok(Line { event, .. }) = serde_json::from_slice::<Line>(&line) {
793 ended |= event.kind == RunEventKind::RunEnded;
794 events.push(event);
795 }
796 }
797 if !more && !ended {
798 ended = load_state(dir, handle).is_some_and(|state| state.ended)
799 && position >= reader.get_ref().metadata().map_or(0, |meta| meta.len());
800 }
801 Ok(RunLogPage {
802 handle: handle.to_owned(),
803 events,
804 next: position,
805 ended,
806 more,
807 })
808}
809
810pub fn settle_orphans(dir: &Path) -> Vec<String> {
814 let mut settled = Vec::new();
815 for mut state in list_states(dir) {
816 if state.ended || state.owner.is_alive() {
817 continue;
818 }
819 let path = dir.join(format!("{}.jsonl", state.handle));
820 let appended = append_existing(
821 &path,
822 &mut state,
823 &[
824 (
825 RunEventKind::Note,
826 "interrupted: the SCV process that ran it stopped".into(),
827 None,
828 ),
829 (RunEventKind::RunEnded, String::new(), Some("lost".into())),
830 ],
831 );
832 if appended.is_err() {
833 tracing::debug!(handle = %state.handle, "could not end an interrupted run's log");
834 }
835 state.ended = true;
836 state.ended_unix = Some(unix_now());
837 if !state.state.is_final() {
838 state.state = AgentState::Interrupted;
839 state.detail = Some("the SCV process that ran it stopped".into());
840 state.since_unix = unix_now();
841 }
842 if write_private_json(dir, &format!("{}.state.json", state.handle), &state).is_ok() {
843 settled.push(state.handle);
844 }
845 }
846 settled
847}
848
849fn append_existing(
850 path: &Path,
851 state: &mut RunState,
852 events: &[(RunEventKind, String, Option<String>)],
853) -> std::io::Result<()> {
854 use std::os::unix::fs::OpenOptionsExt as _;
855 let mut file = std::fs::OpenOptions::new()
856 .read(true)
857 .append(true)
858 .custom_flags(libc::O_NOFOLLOW)
859 .open(path)?;
860 if file.metadata()?.len() > 0 {
863 let mut last = [0u8];
864 file.seek(SeekFrom::End(-1))?;
865 file.read_exact(&mut last)?;
866 if last != *b"\n" {
867 file.write_all(b"\n")?;
868 state.bytes += 1;
869 }
870 }
871 for (kind, text, status) in events {
872 let event = RunEvent {
873 seq: state.seq + 1,
874 ts_ms: unix_ms(),
875 kind: *kind,
876 text: text.clone(),
877 turn: None,
878 status: status.clone(),
879 job: None,
880 risk: None,
881 by: None,
882 };
883 let mut bytes = serde_json::to_vec(&Line { v: 1, event }).map_err(std::io::Error::other)?;
884 bytes.push(b'\n');
885 file.write_all(&bytes)?;
886 state.seq += 1;
887 state.bytes += bytes.len() as u64;
888 }
889 Ok(())
890}
891
892pub fn prune(dir: &Path, keep: Duration, max_runs: usize) -> usize {
896 let now = unix_now();
897 let mut ended: Vec<RunState> = list_states(dir)
898 .into_iter()
899 .filter(|state| state.ended)
900 .collect();
901 let ended_at = |state: &RunState| state.ended_unix.unwrap_or(state.last_activity_unix);
902 ended.sort_by_key(|state| std::cmp::Reverse(ended_at(state)));
903 let mut removed = 0;
904 for (index, state) in ended.iter().enumerate() {
905 let old = now.saturating_sub(ended_at(state)) > keep.as_secs();
906 if old || index >= max_runs {
907 let _ = std::fs::remove_file(dir.join(format!("{}.jsonl", state.handle)));
908 let _ = std::fs::remove_file(dir.join(format!("{}.state.json", state.handle)));
909 removed += 1;
910 }
911 }
912 removed
913}
914
915pub fn current(state: &RunState, now_unix: u64) -> (AgentState, Option<String>) {
919 if !state.ended && !state.owner.is_alive() {
920 return (
921 AgentState::Interrupted,
922 Some("the SCV process that ran it stopped".into()),
923 );
924 }
925 if state.transport == RunTransport::Terminal.as_str() {
927 return (state.state, state.detail.clone());
928 }
929 let quiet = now_unix.saturating_sub(state.last_activity_unix);
930 match state.state {
931 AgentState::Working if quiet >= QUIET_AFTER.as_secs() && state.streams => (
932 AgentState::Unknown,
933 Some(format!(
934 "no activity for {} min{}",
935 quiet / 60,
936 state
937 .last_line
938 .as_deref()
939 .map(|line| format!("; last: {line}"))
940 .unwrap_or_default()
941 )),
942 ),
943 AgentState::Working if !state.streams => (
944 AgentState::Working,
945 Some("this agent prints its answer only when it finishes".into()),
946 ),
947 AgentState::Idle => (AgentState::Idle, Some("waiting for the next prompt".into())),
948 other => (other, state.detail.clone()),
949 }
950}
951
952fn sanitize(text: &str) -> String {
954 text.chars()
955 .map(|character| {
956 if character == '\t' {
957 ' '
958 } else if character.is_control() {
959 '\u{fffd}'
960 } else {
961 character
962 }
963 })
964 .collect()
965}
966
967fn bounded_chars(text: &str, max: usize) -> String {
968 if text.chars().count() <= max {
969 return text.to_owned();
970 }
971 let mut cut: String = text.chars().take(max.saturating_sub(1)).collect();
972 cut.push('…');
973 cut
974}
975
976fn unix_now() -> u64 {
977 SystemTime::now()
978 .duration_since(UNIX_EPOCH)
979 .map_or(0, |elapsed| elapsed.as_secs())
980}
981
982fn unix_ms() -> u64 {
983 SystemTime::now()
984 .duration_since(UNIX_EPOCH)
985 .map_or(0, |elapsed| {
986 u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX)
987 })
988}
989
990#[cfg(test)]
991mod tests;