Skip to main content

scv_protocol/
workbench.rs

1//! The agent workbench: what SCV knows about delegated agents, background
2//! jobs, and their sessions across the whole instance, as a dashboard reads
3//! it ([`WorkbenchSnapshot`]), and the activity a delegated run records for
4//! its live view ([`RunEvent`], [`RunLogPage`]).
5//!
6//! Every type here is additive to protocol version 3: a daemon sends them
7//! only in answer to the `workbench*`, `run_log`, and `worktree*`
8//! `daemon.control` actions, which only newer clients send.
9
10use serde::{Deserialize, Serialize};
11
12/// What a delegated run, a background job, or a session is doing, as far as
13/// SCV can tell. A state a client does not know parses as `unknown`, which
14/// also means SCV itself cannot tell.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum AgentState {
18    /// Launching: starting the process, its handshake, or its session.
19    Starting,
20    /// A turn runs and the agent reports activity.
21    Working,
22    /// The agent waits for a person: an approval request it relayed, or the
23    /// session's own approval request, is unanswered.
24    AwaitingInput,
25    /// A live agent (an ACP server or nested SCV) that waits between turns
26    /// for its next prompt; or a session with no turn running.
27    Idle,
28    /// A prompt that waits for its conversation's running turn.
29    Queued,
30    /// The turn finished and the agent completed its task.
31    Completed,
32    /// The run or job failed.
33    Failed,
34    /// The agent's model refused the request.
35    Declined,
36    /// The run reached its time limit.
37    Timeout,
38    /// Stopped on request.
39    Cancelled,
40    /// Its SCV process or session stopped before it finished: a crash, a
41    /// restart, or the session's client going away.
42    Interrupted,
43    /// SCV cannot tell: a turn has reported nothing for a long while, or the
44    /// process that owns it cannot be observed. Also any state this client
45    /// does not know.
46    #[serde(other)]
47    Unknown,
48}
49
50impl AgentState {
51    /// The state as SCV writes it, such as `awaiting_input`.
52    pub fn as_str(self) -> &'static str {
53        match self {
54            Self::Starting => "starting",
55            Self::Working => "working",
56            Self::AwaitingInput => "awaiting_input",
57            Self::Idle => "idle",
58            Self::Queued => "queued",
59            Self::Completed => "completed",
60            Self::Failed => "failed",
61            Self::Declined => "declined",
62            Self::Timeout => "timeout",
63            Self::Cancelled => "cancelled",
64            Self::Interrupted => "interrupted",
65            Self::Unknown => "unknown",
66        }
67    }
68
69    /// Whether nothing more happens in this state by itself: the run or job
70    /// has ended one way or another.
71    pub fn is_final(self) -> bool {
72        matches!(
73            self,
74            Self::Completed
75                | Self::Failed
76                | Self::Declined
77                | Self::Timeout
78                | Self::Cancelled
79                | Self::Interrupted
80        )
81    }
82
83    /// Whether a person is needed to go on.
84    pub fn needs_attention(self) -> bool {
85        matches!(self, Self::AwaitingInput)
86    }
87}
88
89impl std::fmt::Display for AgentState {
90    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        formatter.write_str(self.as_str())
92    }
93}
94
95/// Who started a background job.
96#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum JobOrigin {
99    /// The session's model, through an `agent` call.
100    #[default]
101    Model,
102    /// The owner, from the workbench (a dashboard or `scv jobs`).
103    Owner,
104    /// An origin this client does not know.
105    #[serde(other)]
106    Unknown,
107}
108
109/// A git worktree SCV created for one job, so its agent works apart from
110/// the repository's own checkout.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112pub struct WorktreeRef {
113    /// The worktree's directory, under `$SCV_HOME/worktrees`.
114    pub path: String,
115    /// The branch SCV created for it, such as `scv/codex-3f9a2c`.
116    pub branch: String,
117    /// The repository it was created from.
118    pub repo: String,
119}
120
121/// A worktree SCV created, as `scv agents worktrees` lists it.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct WorktreeInfo {
124    /// Where it is, its branch, and the repository it belongs to.
125    #[serde(flatten)]
126    pub worktree: WorktreeRef,
127    /// Whether it has uncommitted changes; absent when git could not tell.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub dirty: Option<bool>,
130    /// The jobs recorded as working in it, as `<session>/<job>`.
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub jobs: Vec<String>,
133}
134
135/// One SCV session the workbench knows: a live daemon session, the
136/// workbench's own host for jobs the owner starts, or one that ended.
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct WorkbenchSession {
139    /// The session ID.
140    pub id: String,
141    /// Who it serves, such as `TUI`, `WeChat`, `scv exec`, or `workbench`.
142    pub label: String,
143    /// Its workspace directory.
144    pub workspace: String,
145    /// Whether it still runs in a process of this instance.
146    pub live: bool,
147    /// What it is doing, when it is live.
148    pub state: AgentState,
149    /// SCV's words for that state, such as which approval it waits for.
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub detail: Option<String>,
152}
153
154/// One background job, from the instance's job ledger and, while it runs,
155/// its live state.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct WorkbenchJob {
158    /// The session that owns it.
159    pub session: String,
160    /// Its handle in that session, such as `job-3`.
161    pub job: String,
162    /// The agent that runs it, such as `codex`.
163    pub agent: String,
164    /// The first line of its prompt, shortened.
165    pub task: String,
166    /// The session's workspace.
167    pub workspace: String,
168    /// Where its agent runs: a directory in the workspace, or its worktree.
169    pub cwd: String,
170    /// What it is doing.
171    pub state: AgentState,
172    /// SCV's words for that state.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub detail: Option<String>,
175    /// Who started it.
176    pub started_by: JobOrigin,
177    /// The conversation it runs a turn of, such as `codex-2`, once known.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub conversation: Option<String>,
180    /// The delegated run serving it now, or that served it last.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub run: Option<String>,
183    /// The worktree SCV created for it.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub worktree: Option<WorktreeRef>,
186    /// A reviewed job (`review`), whose journal holds its rounds.
187    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
188    pub reviewed: bool,
189    /// The owner can resume it from the workbench.
190    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
191    pub resumable: bool,
192    /// The job it was resumed as, `<session>/<job>`, once it was.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub resumed_as: Option<String>,
195    /// When it was started.
196    pub created_unix_seconds: u64,
197    /// When its record last changed.
198    pub updated_unix_seconds: u64,
199}
200
201/// One delegated run (an agent process) and what it is doing.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct WorkbenchRun {
204    /// Its delegation handle, such as `codex-3f9a2c`; its run log's name.
205    pub handle: String,
206    /// The agent, such as `codex`.
207    pub agent: String,
208    /// The session that started it.
209    pub session: String,
210    /// How SCV reaches it: `cli` (one process per turn), `acp`, or `scv`.
211    pub transport: String,
212    /// The conversation it serves, such as `codex-2`.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub conversation: Option<String>,
215    /// The turn of that conversation it serves, or served last.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub turn: Option<u32>,
218    /// The background job it serves, when it serves one.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub job: Option<String>,
221    /// Every background job its turns served, oldest first: a live
222    /// conversation's run serves several.
223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
224    pub jobs: Vec<String>,
225    /// Its working directory.
226    pub cwd: String,
227    /// What it is doing.
228    pub state: AgentState,
229    /// SCV's words for that state, such as the approval it waits for or
230    /// how long it has been quiet.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub detail: Option<String>,
233    /// When it started.
234    pub started_unix_seconds: u64,
235    /// When it last reported anything.
236    pub last_activity_unix_seconds: u64,
237    /// Its process still runs.
238    pub live: bool,
239    /// A running turn of it can take a further prompt (ACP steering).
240    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
241    pub steerable: bool,
242}
243
244/// An approval request a background job relayed while the owner watches the
245/// workbench, waiting for the owner's answer.
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247pub struct WorkbenchApproval {
248    /// Answer with `workbench_approve` naming this ID.
249    pub id: String,
250    /// The session whose job asks.
251    pub session: String,
252    /// The job that asks.
253    pub job: String,
254    /// The tool the request is for, as the nested agent named it.
255    pub name: String,
256    /// Its risk, such as `process`.
257    pub risk: String,
258    /// The working directory it concerns.
259    pub cwd: String,
260    /// What it will do.
261    pub summary: String,
262    /// When it was asked.
263    pub since_unix_seconds: u64,
264    /// When it gets the answer the session gives unattended (a denial).
265    pub expires_unix_seconds: u64,
266}
267
268/// A session whose queued prompts SCV kept when its process stopped (a
269/// restart or crash), which a new session can restore.
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271pub struct RestorableSession {
272    /// The session that stopped.
273    pub id: String,
274    /// Who it served.
275    pub label: String,
276    /// Its workspace.
277    pub workspace: String,
278    /// Prompts that never started.
279    pub queued: u32,
280    /// The start of the prompt whose turn ran when it stopped, which is
281    /// never sent again by itself.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub interrupted: Option<String>,
284    /// When it was last saved.
285    pub saved_unix_seconds: u64,
286}
287
288/// Everything the workbench shows, at one moment.
289#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
290pub struct WorkbenchSnapshot {
291    /// When it was taken.
292    pub generated_unix_seconds: u64,
293    /// Sessions with jobs, runs, or live state.
294    #[serde(default, skip_serializing_if = "Vec::is_empty")]
295    pub sessions: Vec<WorkbenchSession>,
296    /// Background jobs, newest first.
297    #[serde(default, skip_serializing_if = "Vec::is_empty")]
298    pub jobs: Vec<WorkbenchJob>,
299    /// Delegated runs, newest first.
300    #[serde(default, skip_serializing_if = "Vec::is_empty")]
301    pub runs: Vec<WorkbenchRun>,
302    /// Approval requests waiting for the owner.
303    #[serde(default, skip_serializing_if = "Vec::is_empty")]
304    pub approvals: Vec<WorkbenchApproval>,
305    /// Sessions whose queued prompts can be restored.
306    #[serde(default, skip_serializing_if = "Vec::is_empty")]
307    pub restorable: Vec<RestorableSession>,
308}
309
310/// What one line of a run's activity log records.
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
312#[serde(rename_all = "snake_case")]
313pub enum RunEventKind {
314    /// The run began: its agent, transport, and working directory.
315    RunStarted,
316    /// A turn began with a prompt.
317    TurnStarted,
318    /// A line of the agent's own message.
319    Text,
320    /// A tool call or command the agent started, as a short redacted line.
321    Tool,
322    /// A tool call that failed.
323    ToolFailed,
324    /// The agent's current plan step.
325    Plan,
326    /// The agent asked for approval.
327    ApprovalRequested,
328    /// That request was answered.
329    ApprovalResolved,
330    /// A prompt was added to the running turn.
331    Steered,
332    /// A turn ended.
333    TurnEnded,
334    /// The run's process ended.
335    RunEnded,
336    /// SCV's own note, such as why the run stopped.
337    Note,
338    /// The log reached its size limit; only turns, approvals, and the end
339    /// are recorded after this.
340    Truncated,
341    /// A kind this client does not know.
342    #[serde(other)]
343    Unknown,
344}
345
346/// One entry of a run's activity log. Text is display-only: agent messages
347/// and short tool lines with credential-like values redacted, never tool
348/// output.
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350pub struct RunEvent {
351    /// Its position in the log, from 1.
352    pub seq: u64,
353    /// When it happened, in Unix milliseconds.
354    pub ts_ms: u64,
355    /// What it records.
356    pub kind: RunEventKind,
357    /// Its text: the message line, tool line, prompt, summary, or note.
358    #[serde(default, skip_serializing_if = "String::is_empty")]
359    pub text: String,
360    /// The conversation turn it belongs to.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub turn: Option<u32>,
363    /// How a turn or run ended, or an approval was answered (`approved`,
364    /// `denied`).
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub status: Option<String>,
367    /// The background job a turn serves.
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub job: Option<String>,
370    /// An approval request's risk.
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub risk: Option<String>,
373    /// Reserved for who sent a turn's prompt or answered an approval:
374    /// `model`, `owner`, or `policy`. Logs omit it until a writer records
375    /// that actor.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub by: Option<String>,
378}
379
380/// Part of a run's activity log, after a position.
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
382pub struct RunLogPage {
383    /// The run, by delegation handle.
384    pub handle: String,
385    /// Its events after the requested position, oldest first.
386    #[serde(default, skip_serializing_if = "Vec::is_empty")]
387    pub events: Vec<RunEvent>,
388    /// Pass as `after` to read on: the last position returned, or the
389    /// requested one when nothing was new.
390    pub next: u64,
391    /// The run has ended, so nothing more will be logged.
392    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
393    pub ended: bool,
394    /// More events wait beyond this page.
395    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
396    pub more: bool,
397}
398
399/// How `workbench_send` hands the owner's prompt to a conversation that is
400/// running a turn.
401#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(rename_all = "snake_case")]
403pub enum SendMode {
404    /// Steer the running turn when its agent can take the prompt, else
405    /// queue it.
406    #[default]
407    Auto,
408    /// Queue it behind the running turn.
409    Queue,
410    /// Steer the running turn, or fail when it cannot be steered.
411    Steer,
412}
413
414/// What `session.start` restored from a session that stopped.
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
416pub struct RestoredQueue {
417    /// The session whose queue was restored.
418    pub from: String,
419    /// Queued prompts restored; the new session's queue starts paused.
420    pub queued: u32,
421    /// The start of the prompt that was running when the old session
422    /// stopped. It is not sent again: its outcome is unknown.
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub interrupted: Option<String>,
425    /// Queued prompts that could not be restored, because an attachment
426    /// was gone or the prompt was too long. Older servers omit it, and it
427    /// is left off the wire when none were.
428    #[serde(default, skip_serializing_if = "zero")]
429    pub dropped: u32,
430}
431
432#[allow(
433    clippy::trivially_copy_pass_by_ref,
434    reason = "serde skip_serializing_if passes the field by reference"
435)]
436fn zero(value: &u32) -> bool {
437    *value == 0
438}
439
440#[cfg(test)]
441mod tests;