Skip to main content

scv_tools/delegate/
activity.rs

1//! Each delegated run's activity log and current state, which the
2//! workbench shows as the agent's live view.
3//!
4//! A run (one delegation record: a CLI process for one turn, or an ACP
5//! server or nested SCV for a whole conversation) writes two files under
6//! [`Layout::runs`](scv_client::Layout::runs):
7//!
8//! - `<handle>.jsonl`, append-only: one [`RunEvent`] per line (the run's
9//!   start, each turn's prompt, the agent's message lines, its tool calls as
10//!   the same short redacted lines `tool.progress` shows, approval requests
11//!   and answers, prompts steered into a turn, and each turn's end). Never
12//!   tool output. At most [`MAX_LOG_BYTES`]; past that, only turns,
13//!   approvals, notes, and the run's end are written.
14//! - `<handle>.state.json`, rewritten atomically: what the run is doing now
15//!   ([`RunState`]), on every change of state and at most every few seconds
16//!   while it reports activity.
17//!
18//! Both files are private (mode 0600, directory 0700) and written by the
19//! SCV process that owns the run, so every process of an instance (the
20//! daemon, `scv exec`, a nested SCV's parent) is visible to the workbench,
21//! including after it stopped. Bookkeeping never fails a run: a write that
22//! fails is logged and skipped.
23
24use 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
43/// The largest activity log; past it only state-changing events are kept.
44pub const MAX_LOG_BYTES: u64 = 4 * 1024 * 1024;
45/// Longest text of one event, in characters.
46const MAX_EVENT_CHARS: usize = 2000;
47/// Longest prompt kept in a `turn_started` or `steered` event, in characters.
48const MAX_PROMPT_CHARS: usize = 4000;
49/// How often activity alone rewrites the state file.
50const STATE_REFRESH: Duration = Duration::from_secs(5);
51/// Longest state file read.
52const MAX_STATE_BYTES: u64 = 64 * 1024;
53/// Events one page of a log returns at most, and their total bytes.
54pub const MAX_PAGE_EVENTS: usize = 500;
55const MAX_PAGE_BYTES: usize = 256 * 1024;
56/// A working run that has reported nothing for this long is shown as
57/// `unknown`: SCV cannot tell whether it is thinking or stuck.
58pub const QUIET_AFTER: Duration = Duration::from_secs(5 * 60);
59/// Jobs a run remembers having served, newest last.
60const MAX_RUN_JOBS: usize = 32;
61
62/// How SCV reaches a run's agent.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub(crate) enum RunTransport {
65    /// One CLI process per turn.
66    Cli,
67    /// An Agent Client Protocol server for the whole conversation.
68    Acp,
69    /// A nested `scv server --stdio` for the whole conversation.
70    Scv,
71    /// The agent's own interactive UI in a terminal SCV holds, while the
72    /// owner has the conversation taken over.
73    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
87/// What a run starts with.
88pub(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    /// The agent's output carries live events (structured events, or text
96    /// streamed as it prints); a run whose output says nothing until the
97    /// end is never shown as quiet.
98    pub(crate) streams: bool,
99}
100
101/// What a run is doing now: `<handle>.state.json`.
102#[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    /// `cli`, `acp`, `scv`, or `terminal` (the owner's takeover).
109    pub transport: String,
110    pub cwd: PathBuf,
111    /// The SCV process that owns the run.
112    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    /// The background job the current or last turn serves.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub job: Option<String>,
120    /// Every background job its turns served, oldest first (at most 32): a
121    /// live conversation's run serves the turns of several.
122    #[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    /// When `state` began.
129    pub since_unix: u64,
130    pub last_activity_unix: u64,
131    /// The newest message or tool line, for a quiet run's explanation.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub last_line: Option<String>,
134    /// The agent's own session ID once known, so an interrupted
135    /// conversation can be resumed through the agent's own resume.
136    #[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    /// The run's process ended; nothing more is logged.
143    #[serde(default)]
144    pub ended: bool,
145    /// When it ended, which is what its age counts from once it has.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub ended_unix: Option<u64>,
148    /// The last event's position and the log's size.
149    #[serde(default)]
150    pub seq: u64,
151    #[serde(default)]
152    pub bytes: u64,
153}
154
155/// One line of the log file: an event and the format version.
156#[derive(Serialize, Deserialize)]
157struct Line {
158    v: u32,
159    #[serde(flatten)]
160    event: RunEvent,
161}
162
163/// A run's activity writer. Clones share one log.
164#[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    /// Message text that has not ended its line yet.
178    pending: String,
179    truncated: bool,
180    last_state_write: Instant,
181    /// The process ended mid-turn (`exited`, `killed`, `lost`): the run's
182    /// end is logged after that turn's, so a reader that stops at the end
183    /// has seen why the turn failed.
184    ending: Option<String>,
185    /// How its last turn ended, which a run that ends between turns ends as.
186    last_turn: Option<AgentState>,
187    /// A turn has started and its end is not logged yet.
188    turn_open: bool,
189}
190
191impl RunActivity {
192    /// Start the log of a run, or `None` when its files cannot be created
193    /// (or `handle` is not a run handle).
194    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    /// A turn of the run's conversation began with `prompt`, for `job` when
260    /// it serves one.
261    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    /// A delta of the agent's message, which may end mid-line.
296    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    /// A whole message of the agent, line by line.
311    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    /// A tool call or command the agent started, as a progress line. Lines
321    /// are scrubbed here whoever made them: a nested SCV relays its own.
322    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    /// The agent's current plan step.
339    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    /// The agent asked for approval; it waits for an answer. The summary
351    /// is scrubbed here, as a nested SCV's is its own tool's.
352    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    /// The approval request was answered.
369    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    /// A later prompt went into the running turn.
379    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    /// The current turn ended with `status` (`completed`, `failed`, ...).
391    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    /// A live child finished its turn, or the call serving it let go, and
422    /// waits for the next prompt: no turn is open.
423    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    /// The call serving a turn let go of it after the run's process ended:
430    /// if the turn's own end was never logged (the call was dropped), the
431    /// run's end is logged now.
432    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    /// The run's process ended: `exited`, `killed`, or `lost`.
442    ///
443    /// Between turns, the run ends as its last turn did (or failed, when
444    /// killed, or before any turn ended). Mid-turn it fails, and its end is
445    /// logged after the turn's own end, which says why, or once the turn is
446    /// let go of.
447    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    /// The owner's takeover of the run's conversation in its agent's
471    /// terminal: `note` is logged, and the run works with `detail` until its
472    /// terminal closes. Nothing the terminal shows is logged.
473    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    /// The takeover's terminal closed (`how`, such as `exit 0`): the run
484    /// has ended, and the conversation goes back to SCV's turns.
485    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    /// The agent's own session ID, for resuming the conversation later.
510    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    /// Whether a running turn of this run can take a further prompt.
519    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    /// Log the run's end: nothing is logged after it.
530    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    /// Append one event, unless the log is full and it is mere activity.
559    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                // A log that cannot be written stops here rather than leave
605                // a half line for the next event.
606                self.log = None;
607            }
608        }
609    }
610
611    /// Activity was reported: refresh the state file now and then.
612    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        // A run whose process ended only settles on how it ended.
621        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        // Rewritten on the agent's own output path, so never synced: a
638        // machine crash at worst loses the latest state, which then settles
639        // as interrupted.
640        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        // The run's last owner let go: whatever it was doing has ended.
649        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
665/// Whether `handle` is a delegation handle: an agent name, `-`, and six hex
666/// digits, so it can name files.
667pub 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
695/// The state of run `handle`, as its owner last saved it.
696pub 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    // Only a state named after its own handle is trusted.
709    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
721/// Every run's saved state in `dir`.
722pub 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
738/// Events of run `handle` after byte position `after` (0 from the start),
739/// at most `limit` of them: one page of its log.
740pub 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        // A position that is not right after a line ending resynchronizes
758        // at the next one.
759        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            // End of the log, or a line its writer has not finished.
779            break;
780        }
781        if read > MAX_PAGE_BYTES {
782            // No page could hold it: skipped, so readers move on.
783            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
810/// Close the logs of runs whose owning SCV process stopped without ending
811/// them (a crash or a kill): note it, end the log, and mark the run
812/// interrupted. Returns the handles settled.
813pub 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    // A line its writer left torn is ended first, or what follows would
861    // join it and be lost with it.
862    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
892/// Remove the logs of runs that ended more than `keep` ago (a run settled
893/// as interrupted counts from then), then the oldest ended ones beyond
894/// `max_runs`. Runs still going are never removed.
895pub 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
915/// What a run's saved state means now: the state, and SCV's words for it.
916/// A run whose owner stopped without ending it is interrupted; one that has
917/// reported nothing for [`QUIET_AFTER`] while working is `unknown`.
918pub 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    // A takeover's terminal is the owner's: its quiet is the owner's too.
926    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
952/// `text` without control characters other than tabs, which become spaces.
953fn 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;