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`]), the activity a delegated run records for
4//! its live view ([`RunEvent`], [`RunLogPage`]), and the screen of a
5//! conversation the owner took over in its agent's own terminal
6//! ([`AttachPage`]).
7//!
8//! Every type here is additive to protocol version 3: a daemon sends them
9//! only in answer to the `workbench*`, `run_log`, `job_result`,
10//! `worktree*`, and `attach*` `daemon.control` actions, which only newer
11//! clients send.
12
13use serde::{Deserialize, Serialize};
14
15use crate::review::JobOutcome;
16
17/// What a delegated run, a background job, or a session is doing, as far as
18/// SCV can tell. A state a client does not know parses as `unknown`, which
19/// also means SCV itself cannot tell.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum AgentState {
23 /// Launching: starting the process, its handshake, or its session.
24 Starting,
25 /// A turn runs and the agent reports activity.
26 Working,
27 /// The agent waits for a person: an approval request it relayed, or the
28 /// session's own approval request, is unanswered.
29 AwaitingInput,
30 /// A live agent (an ACP server or nested SCV) that waits between turns
31 /// for its next prompt; or a session with no turn running.
32 Idle,
33 /// A prompt that waits for its conversation's running turn.
34 Queued,
35 /// The turn finished and the agent completed its task.
36 Completed,
37 /// The run or job failed.
38 Failed,
39 /// The agent's model refused the request.
40 Declined,
41 /// The run reached its time limit.
42 Timeout,
43 /// Stopped on request.
44 Cancelled,
45 /// Its SCV process or session stopped before it finished: a crash, a
46 /// restart, or the session's client going away.
47 Interrupted,
48 /// SCV cannot tell: a turn has reported nothing for a long while, or the
49 /// process that owns it cannot be observed. Also any state this client
50 /// does not know.
51 #[serde(other)]
52 Unknown,
53}
54
55impl AgentState {
56 /// The state as SCV writes it, such as `awaiting_input`.
57 pub fn as_str(self) -> &'static str {
58 match self {
59 Self::Starting => "starting",
60 Self::Working => "working",
61 Self::AwaitingInput => "awaiting_input",
62 Self::Idle => "idle",
63 Self::Queued => "queued",
64 Self::Completed => "completed",
65 Self::Failed => "failed",
66 Self::Declined => "declined",
67 Self::Timeout => "timeout",
68 Self::Cancelled => "cancelled",
69 Self::Interrupted => "interrupted",
70 Self::Unknown => "unknown",
71 }
72 }
73
74 /// Whether nothing more happens in this state by itself: the run or job
75 /// has ended one way or another.
76 pub fn is_final(self) -> bool {
77 matches!(
78 self,
79 Self::Completed
80 | Self::Failed
81 | Self::Declined
82 | Self::Timeout
83 | Self::Cancelled
84 | Self::Interrupted
85 )
86 }
87
88 /// Whether a person is needed to go on.
89 pub fn needs_attention(self) -> bool {
90 matches!(self, Self::AwaitingInput)
91 }
92}
93
94impl std::fmt::Display for AgentState {
95 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 formatter.write_str(self.as_str())
97 }
98}
99
100/// Who started a background job.
101#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum JobOrigin {
104 /// The session's model, through an `agent` call.
105 #[default]
106 Model,
107 /// The owner, from the workbench (a dashboard or `scv jobs`).
108 Owner,
109 /// An origin this client does not know.
110 #[serde(other)]
111 Unknown,
112}
113
114/// A git worktree SCV created for one job, so its agent works apart from
115/// the repository's own checkout.
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct WorktreeRef {
118 /// The worktree's directory, under `$SCV_HOME/worktrees`.
119 pub path: String,
120 /// The branch SCV created for it, such as `scv/codex-3f9a2c`.
121 pub branch: String,
122 /// The repository it was created from.
123 pub repo: String,
124}
125
126/// A worktree SCV created, as `scv agents worktrees` lists it.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct WorktreeInfo {
129 /// Where it is, its branch, and the repository it belongs to.
130 #[serde(flatten)]
131 pub worktree: WorktreeRef,
132 /// Whether it has uncommitted changes; absent when git could not tell.
133 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub dirty: Option<bool>,
135 /// The jobs recorded as working in it, as `<session>/<job>`.
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
137 pub jobs: Vec<String>,
138}
139
140/// One SCV session the workbench knows: a live daemon session, the
141/// workbench's own host for jobs the owner starts, or one that ended.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143pub struct WorkbenchSession {
144 /// The session ID.
145 pub id: String,
146 /// Who it serves, such as `TUI`, `WeChat`, `scv exec`, or `workbench`.
147 pub label: String,
148 /// Its workspace directory.
149 pub workspace: String,
150 /// Whether it still runs in a process of this instance.
151 pub live: bool,
152 /// What it is doing, when it is live.
153 pub state: AgentState,
154 /// SCV's words for that state, such as which approval it waits for.
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub detail: Option<String>,
157}
158
159/// One background job, from the instance's job ledger and, while it runs,
160/// its live state.
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
162pub struct WorkbenchJob {
163 /// The session that owns it.
164 pub session: String,
165 /// Its handle in that session, such as `job-3`.
166 pub job: String,
167 /// The agent that runs it, such as `codex`.
168 pub agent: String,
169 /// The first line of its prompt, shortened.
170 pub task: String,
171 /// The session's workspace.
172 pub workspace: String,
173 /// Where its agent runs: a directory in the workspace, or its worktree.
174 pub cwd: String,
175 /// What it is doing.
176 pub state: AgentState,
177 /// SCV's words for that state.
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub detail: Option<String>,
180 /// Who started it.
181 pub started_by: JobOrigin,
182 /// The conversation it runs a turn of, such as `codex-2`, once known.
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub conversation: Option<String>,
185 /// The delegated run serving it now, or that served it last.
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub run: Option<String>,
188 /// The worktree SCV created for it.
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub worktree: Option<WorktreeRef>,
191 /// A reviewed job (`review`), whose journal holds its rounds.
192 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
193 pub reviewed: bool,
194 /// The owner can resume it from the workbench.
195 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
196 pub resumable: bool,
197 /// The job it was resumed as, `<session>/<job>`, once it was.
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub resumed_as: Option<String>,
200 /// When it was started.
201 pub created_unix_seconds: u64,
202 /// When its record last changed.
203 pub updated_unix_seconds: u64,
204}
205
206/// One delegated run (an agent process) and what it is doing.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct WorkbenchRun {
209 /// Its delegation handle, such as `codex-3f9a2c`; its run log's name.
210 pub handle: String,
211 /// The agent, such as `codex`.
212 pub agent: String,
213 /// The session that started it.
214 pub session: String,
215 /// How SCV reaches it: `cli` (one process per turn), `acp`, or `scv`.
216 pub transport: String,
217 /// The conversation it serves, such as `codex-2`.
218 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub conversation: Option<String>,
220 /// The turn of that conversation it serves, or served last.
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub turn: Option<u32>,
223 /// The background job it serves, when it serves one.
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub job: Option<String>,
226 /// Every background job its turns served, oldest first: a live
227 /// conversation's run serves several.
228 #[serde(default, skip_serializing_if = "Vec::is_empty")]
229 pub jobs: Vec<String>,
230 /// Its working directory.
231 pub cwd: String,
232 /// What it is doing.
233 pub state: AgentState,
234 /// SCV's words for that state, such as the approval it waits for or
235 /// how long it has been quiet.
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub detail: Option<String>,
238 /// When it started.
239 pub started_unix_seconds: u64,
240 /// When it last reported anything.
241 pub last_activity_unix_seconds: u64,
242 /// Its process still runs.
243 pub live: bool,
244 /// A running turn of it can take a further prompt (ACP steering).
245 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
246 pub steerable: bool,
247}
248
249/// An approval request a background job relayed while the owner watches the
250/// workbench, waiting for the owner's answer.
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct WorkbenchApproval {
253 /// Answer with `workbench_approve` naming this ID.
254 pub id: String,
255 /// The session whose job asks.
256 pub session: String,
257 /// The job that asks.
258 pub job: String,
259 /// The tool the request is for, as the nested agent named it.
260 pub name: String,
261 /// Its risk, such as `process`.
262 pub risk: String,
263 /// The working directory it concerns.
264 pub cwd: String,
265 /// What it will do.
266 pub summary: String,
267 /// When it was asked.
268 pub since_unix_seconds: u64,
269 /// When it gets the answer the session gives unattended (a denial).
270 pub expires_unix_seconds: u64,
271}
272
273/// A session whose queued prompts SCV kept when its process stopped (a
274/// restart or crash), which a new session can restore.
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
276pub struct RestorableSession {
277 /// The session that stopped.
278 pub id: String,
279 /// Who it served.
280 pub label: String,
281 /// Its workspace.
282 pub workspace: String,
283 /// Prompts that never started.
284 pub queued: u32,
285 /// The start of the prompt whose turn ran when it stopped, which is
286 /// never sent again by itself.
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub interrupted: Option<String>,
289 /// When it was last saved.
290 pub saved_unix_seconds: u64,
291}
292
293/// Everything the workbench shows, at one moment.
294#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
295pub struct WorkbenchSnapshot {
296 /// When it was taken.
297 pub generated_unix_seconds: u64,
298 /// Sessions with jobs, runs, or live state.
299 #[serde(default, skip_serializing_if = "Vec::is_empty")]
300 pub sessions: Vec<WorkbenchSession>,
301 /// Background jobs, newest first.
302 #[serde(default, skip_serializing_if = "Vec::is_empty")]
303 pub jobs: Vec<WorkbenchJob>,
304 /// Delegated runs, newest first.
305 #[serde(default, skip_serializing_if = "Vec::is_empty")]
306 pub runs: Vec<WorkbenchRun>,
307 /// Approval requests waiting for the owner.
308 #[serde(default, skip_serializing_if = "Vec::is_empty")]
309 pub approvals: Vec<WorkbenchApproval>,
310 /// Sessions whose queued prompts can be restored.
311 #[serde(default, skip_serializing_if = "Vec::is_empty")]
312 pub restorable: Vec<RestorableSession>,
313}
314
315/// What one line of a run's activity log records.
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
317#[serde(rename_all = "snake_case")]
318pub enum RunEventKind {
319 /// The run began: its agent, transport, and working directory.
320 RunStarted,
321 /// A turn began with a prompt.
322 TurnStarted,
323 /// A line of the agent's own message.
324 Text,
325 /// A tool call or command the agent started, as a short redacted line.
326 Tool,
327 /// A tool call that failed.
328 ToolFailed,
329 /// The agent's current plan step.
330 Plan,
331 /// The agent asked for approval.
332 ApprovalRequested,
333 /// That request was answered.
334 ApprovalResolved,
335 /// A prompt was added to the running turn.
336 Steered,
337 /// A turn ended.
338 TurnEnded,
339 /// The run's process ended.
340 RunEnded,
341 /// SCV's own note, such as why the run stopped.
342 Note,
343 /// The log reached its size limit; only turns, approvals, and the end
344 /// are recorded after this.
345 Truncated,
346 /// A kind this client does not know.
347 #[serde(other)]
348 Unknown,
349}
350
351/// One entry of a run's activity log. Text is display-only: agent messages
352/// and short tool lines with credential-like values redacted, never tool
353/// output.
354#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
355pub struct RunEvent {
356 /// Its position in the log, from 1.
357 pub seq: u64,
358 /// When it happened, in Unix milliseconds.
359 pub ts_ms: u64,
360 /// What it records.
361 pub kind: RunEventKind,
362 /// Its text: the message line, tool line, prompt, summary, or note.
363 #[serde(default, skip_serializing_if = "String::is_empty")]
364 pub text: String,
365 /// The conversation turn it belongs to.
366 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub turn: Option<u32>,
368 /// How a turn or run ended, or an approval was answered (`approved`,
369 /// `denied`).
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub status: Option<String>,
372 /// The background job a turn serves.
373 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub job: Option<String>,
375 /// An approval request's risk.
376 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub risk: Option<String>,
378 /// Reserved for who sent a turn's prompt or answered an approval:
379 /// `model`, `owner`, or `policy`. Logs omit it until a writer records
380 /// that actor.
381 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub by: Option<String>,
383}
384
385/// The most bytes of a job's reply, and of its error, a [`JobResult`]
386/// carries; a longer one is cut at a character boundary and flagged
387/// `truncated`. The ledger itself keeps up to 8 KiB of each.
388pub const MAX_JOB_RESULT_BYTES: usize = 4 * 1024;
389
390/// A background job's result, for a `job_result` request: where it stands
391/// and, once it has ended, its agent's reply or error, bounded by
392/// [`MAX_JOB_RESULT_BYTES`]. The reply and error are untrusted
393/// delegated-agent text.
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct JobResult {
396 /// The session that owns it.
397 pub session: String,
398 /// Its handle in that session, such as `job-3`.
399 pub job: String,
400 /// The agent that runs it, such as `codex`.
401 pub agent: String,
402 /// What it is doing, as the workbench lists it.
403 pub state: AgentState,
404 /// SCV's words for that state.
405 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub detail: Option<String>,
407 /// The agent's reply, once the job has ended with one.
408 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub reply: Option<String>,
410 /// Why it failed, once it has failed.
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub error: Option<String>,
413 /// The reply or the error was cut to [`MAX_JOB_RESULT_BYTES`].
414 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
415 pub truncated: bool,
416 /// A reviewed job's outcome, while the session that ran it still holds
417 /// the job.
418 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub outcome: Option<JobOutcome>,
420 /// When it ended.
421 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub finished_unix_seconds: Option<u64>,
423}
424
425/// Part of a run's activity log, after a position.
426#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
427pub struct RunLogPage {
428 /// The run, by delegation handle.
429 pub handle: String,
430 /// Its events after the requested position, oldest first.
431 #[serde(default, skip_serializing_if = "Vec::is_empty")]
432 pub events: Vec<RunEvent>,
433 /// Pass as `after` to read on: the last position returned, or the
434 /// requested one when nothing was new.
435 pub next: u64,
436 /// The run has ended, so nothing more will be logged.
437 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
438 pub ended: bool,
439 /// More events wait beyond this page.
440 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
441 pub more: bool,
442}
443
444/// How `workbench_send` hands the owner's prompt to a conversation that is
445/// running a turn.
446#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
447#[serde(rename_all = "snake_case")]
448pub enum SendMode {
449 /// Steer the running turn when its agent can take the prompt, else
450 /// queue it.
451 #[default]
452 Auto,
453 /// Queue it behind the running turn.
454 Queue,
455 /// Steer the running turn, or fail when it cannot be steered.
456 Steer,
457}
458
459/// A color of a cell of a taken-over terminal: one of the terminal's 256
460/// indexed colors, or a direct RGB color.
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
462#[serde(untagged)]
463pub enum AttachColor {
464 /// An indexed color: 0-15 are the terminal's own, 16-255 the xterm cube.
465 Index(u8),
466 /// A direct color, as `[red, green, blue]`.
467 Rgb([u8; 3]),
468}
469
470/// A run of cells of one row of a taken-over terminal, all in one style.
471/// Its text is display-only: printable characters, never an escape
472/// sequence, with values that look like credentials masked.
473#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
474pub struct AttachSpan {
475 /// The characters, one per cell (two for a wide one).
476 pub text: String,
477 /// Foreground color; absent is the terminal's default.
478 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub fg: Option<AttachColor>,
480 /// Background color; absent is the terminal's default.
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub bg: Option<AttachColor>,
483 /// Bold.
484 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
485 pub bold: bool,
486 /// Italic.
487 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
488 pub italic: bool,
489 /// Underlined.
490 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
491 pub underline: bool,
492 /// Foreground and background swapped.
493 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
494 pub inverse: bool,
495}
496
497/// One row of a taken-over terminal's screen.
498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
499pub struct AttachRow {
500 /// The row, from 0 at the top.
501 pub row: u16,
502 /// Its cells, left to right; cells past the last span are blank.
503 #[serde(default, skip_serializing_if = "Vec::is_empty")]
504 pub spans: Vec<AttachSpan>,
505}
506
507/// The screen of a conversation the owner took over in its agent's own
508/// terminal, as `attach` and `attach_io` return it: the rows that changed
509/// since the client's `after`, never the terminal's raw output.
510#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
511pub struct AttachPage {
512 /// This client's hold on the terminal: `attach_io` and `attach_detach`
513 /// name it. Another `attach` of the same conversation replaces it.
514 pub id: String,
515 /// The session that holds the conversation.
516 pub session: String,
517 /// The conversation taken over, such as `codex-2`.
518 pub conversation: String,
519 /// The agent, such as `codex`.
520 pub agent: String,
521 /// The takeover's delegated run, whose activity log records it.
522 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub run: Option<String>,
524 /// Pass as `after` to read only what changes next.
525 pub generation: u64,
526 /// The screen's width, in cells.
527 pub cols: u16,
528 /// The screen's height, in rows.
529 pub rows: u16,
530 /// Every row whose content changed since `after`; all of them when
531 /// `full`.
532 #[serde(default, skip_serializing_if = "Vec::is_empty")]
533 pub lines: Vec<AttachRow>,
534 /// `lines` holds the whole screen: the client starts over from blank.
535 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
536 pub full: bool,
537 /// The cursor, as `[row, col]`; absent while the agent hides it.
538 #[serde(default, skip_serializing_if = "Option::is_none")]
539 pub cursor: Option<[u16; 2]>,
540 /// The agent asked for application cursor keys (`ESC O A` for Up).
541 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
542 pub application_cursor: bool,
543 /// The agent asked for bracketed paste.
544 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
545 pub bracketed_paste: bool,
546 /// The agent's terminal closed: its process exited or was stopped, so
547 /// the conversation goes back to SCV's turns.
548 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
549 pub ended: bool,
550 /// How it ended, such as `exit 0` or `stopped by the owner`.
551 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub exit: Option<String>,
553 /// SCV's sentence about input it could not deliver, such as an agent
554 /// that stopped reading its terminal.
555 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub note: Option<String>,
557}
558
559/// What `session.start` restored from a session that stopped.
560#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
561pub struct RestoredQueue {
562 /// The session whose queue was restored.
563 pub from: String,
564 /// Queued prompts restored; the new session's queue starts paused.
565 pub queued: u32,
566 /// The start of the prompt that was running when the old session
567 /// stopped. It is not sent again: its outcome is unknown.
568 #[serde(default, skip_serializing_if = "Option::is_none")]
569 pub interrupted: Option<String>,
570 /// Queued prompts that could not be restored, because an attachment
571 /// was gone or the prompt was too long. Older servers omit it, and it
572 /// is left off the wire when none were.
573 #[serde(default, skip_serializing_if = "zero")]
574 pub dropped: u32,
575}
576
577#[allow(
578 clippy::trivially_copy_pass_by_ref,
579 reason = "serde skip_serializing_if passes the field by reference"
580)]
581fn zero(value: &u32) -> bool {
582 *value == 0
583}
584
585#[cfg(test)]
586mod tests;