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;