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