forge_workspace/protocol.rs
1//! Command + SessionUpdate channel envelopes between a view and
2//! forge-workspace. A view dispatches Commands; workspace per-session
3//! tasks emit SessionUpdates back through the workspace's fan-out.
4//!
5//! ## Dual Command shape (deliberate)
6//!
7//! Two `Command` enums exist in the workspace: this one, keyed by
8//! [`SessionSlot`], and [`forge_primitives::AgentCommand`], keyed by
9//! `session_id: String`. They overlap on variant names (Prompt,
10//! Cancel, SetMode, …) but serve different boundary layers:
11//!
12//! - **`forge_workspace::protocol::Command`** is the view ↔ workspace
13//! envelope. SessionSlot routing, App-level variants (SpawnProject),
14//! the workspace-internal Respond* cluster.
15//! - **`forge_primitives::AgentCommand`** is the workspace ↔ agent
16//! envelope. session_id-keyed, raw shapes the AgentHandle
17//! dispatcher recognises.
18//!
19//! Collapsing them would force the AgentHandle dispatcher to handle
20//! App-level variants it has no business in (SpawnProject is a
21//! workspace concern; SessionSlot is a routing concern; neither
22//! belongs in the agent layer). The current split keeps each
23//! envelope minimal at its respective boundary. The translation
24//! happens in `session_task::execute_command_via_handle`.
25
26use std::path::PathBuf;
27
28use forge_agent::client::SessionLaunchSettings;
29use forge_primitives::cloud::service_status::ServiceSeverity;
30use forge_primitives::error::AppError;
31use forge_primitives::permission::PermissionMode;
32use forge_primitives::permission_interaction::{PermissionOutcome, PermissionRequest};
33use forge_primitives::plugins::{
34 PluginUpdateRun, PluginUpdateTrigger, PluginsCliActionSuccess, PluginsInventorySnapshot,
35};
36use forge_primitives::question::{QuestionOutcome, QuestionRequest};
37use forge_primitives::review::{ReviewStatus, ReviewThread};
38use forge_primitives::runtime::{AvailableModel, CurrentModel, ModeState, TerminalReason};
39use forge_primitives::{ImageAttachment, McpOperationError, McpServerStatus, Message, SessionId};
40use serde::{Deserialize, Serialize};
41use tokio::sync::oneshot;
42use uuid::Uuid;
43
44use crate::SessionSlot;
45use crate::mcp::peers::types::WrappedPrompt;
46
47// `TurnErrorClass` lives in forge-primitives so the classifier (in
48// forge-agent) and consumers (views, via this protocol module)
49// share one enum. Re-exported here so existing call sites keep
50// resolving via `forge_workspace::protocol::TurnErrorClass`.
51pub use forge_primitives::TurnErrorClass;
52
53/// One pending interaction response slot. Workspace stores these
54/// keyed by `tool_id` in `DomainSession.pending_interactions`.
55/// `Command::RespondPermission` / `RespondQuestion` look up the
56/// matching slot and send the outcome down the oneshot.
57///
58/// The request rides beside the sender so a view that attached after the
59/// prompt landed still draws what it offers. The stream is a mirror with no
60/// backlog, so the update that carried it is gone by then, and the dock's
61/// whole point is answering from a view that was not there when it arrived.
62pub enum PendingInteractionSlot {
63 Permission { tx: oneshot::Sender<PermissionOutcome>, request: Box<PermissionRequest> },
64 Question { tx: oneshot::Sender<QuestionOutcome>, request: Box<QuestionRequest> },
65}
66
67/// What a seat is held on, as the core kept it: the prompt a view that
68/// attached late has no other way to read.
69///
70/// The kinds are every member of the category, not the two the dock
71/// happened to draw first: a Slack draft parks on a reply the same way a
72/// permission and a question do - and a browser hand-off parks on a person -
73/// so a record carrying two of them told a builder the category was covered
74/// when it was not.
75#[derive(Clone)]
76pub enum PendingAsk {
77 Permission(Box<PermissionRequest>),
78 Question(Box<QuestionRequest>),
79 SlackDraft(Box<forge_primitives::slack::SlackDraft>),
80 BrowserHandOff(Box<forge_primitives::browser::HandOff>),
81 TerminalHandOff(Box<forge_primitives::terminal::TerminalHandOff>),
82 DocumentHandOff(Box<forge_primitives::document::DocumentHandOffAsk>),
83 SettingsWrite(Box<forge_primitives::settings::SettingsWriteAsk>),
84 Vault(Box<forge_primitives::vault::VaultDraft>),
85}
86
87impl PendingAsk {
88 /// The tool call this prompt is about, which is the id an answer names.
89 ///
90 /// `None` for a Slack draft and a hand-off: each is answered by its own
91 /// command with its own id, and names no tool call at all.
92 pub fn tool_id(&self) -> Option<&str> {
93 match self {
94 Self::Permission(request) => Some(&request.tool_call.tool_call_id),
95 Self::Question(request) => Some(&request.tool_call.tool_call_id),
96 Self::SlackDraft(_)
97 | Self::BrowserHandOff(_)
98 | Self::TerminalHandOff(_)
99 | Self::DocumentHandOff(_)
100 | Self::SettingsWrite(_)
101 | Self::Vault(_) => None,
102 }
103 }
104}
105
106impl PendingInteractionSlot {
107 /// The request this slot is holding, which is what it offers.
108 pub fn ask(&self) -> PendingAsk {
109 match self {
110 Self::Permission { request, .. } => PendingAsk::Permission(request.clone()),
111 Self::Question { request, .. } => PendingAsk::Question(request.clone()),
112 }
113 }
114}
115
116impl std::fmt::Debug for PendingInteractionSlot {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 match self {
119 Self::Permission { .. } => f.write_str("PendingInteractionSlot::Permission"),
120 Self::Question { .. } => f.write_str("PendingInteractionSlot::Question"),
121 }
122 }
123}
124
125/// Synchronous return from `Command::SpawnWorker` - the session_id
126/// that the new worker was issued and the tag value applied. Threaded
127/// back to the calling `agents__spawn` Tool impl via the oneshot
128/// receiver so the LLM sees `{session_id, tag}` in the tool result.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub struct WorkerSpawnReply {
132 pub session_id: String,
133 pub tag: String,
134 /// The worker's stored MCP-family selection, canonicalised; `None`
135 /// means every family. The spawn tool echoes it so the lead sees
136 /// exactly which families this worker got.
137 pub mcp_families: Option<Vec<String>>,
138 /// Set to the account name when the walk had to take an account
139 /// that is saturated or bailed, because no other account in the pin
140 /// declares the project's model. The spawn tool surfaces it as a
141 /// `notice`, so the lead sees at spawn that the worker may hit a
142 /// 429 right away instead of only finding out when it stalls.
143 pub rate_limited_account: Option<String>,
144 /// Set when persisting the worker's durable row failed (the store
145 /// couldn't open, or the write errored). The worker still spawns, but
146 /// it won't survive a forge restart. The spawn tool surfaces it as a
147 /// warning so the lead knows the "durable" promise didn't hold for
148 /// this one. Mirrors `worktree_cleanup_warning` on despawn.
149 pub durability_warning: Option<String>,
150 /// Where the worker landed: its worktree, or `None` when the project
151 /// is not a git repo and the worker runs in the project root. Set by
152 /// the spawn handler, which is the only place that knows both.
153 pub worktree: Option<String>,
154 /// Which session the spawn landed on. The handler states what its own
155 /// arguments say; the MCP facade restates it with the resolution it
156 /// made, which is the only place a `resume_session` fallback is known.
157 pub session_choice: SessionChoice,
158}
159
160/// Which session a worker spawn landed on, as the spawn tool reports it.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum SessionChoice {
164 /// The label's prior session was found and resumed.
165 Resumed,
166 /// A new session, because the caller did not ask to resume.
167 Fresh,
168 /// A new session, because the caller asked to resume and the label
169 /// had no prior session to resume.
170 FreshWithoutPrior,
171}
172
173/// Outcome of a [`Command::DespawnWorker`], sent back to the calling
174/// `agents__despawn` Tool via the command's `respond` oneshot.
175#[derive(Debug, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177pub enum DespawnResult {
178 /// The worker was torn down (subprocess killed, dropped from
179 /// `live_workers`). `worktree_cleanup_warning`
180 /// is `Some` when the post-teardown `git worktree remove` failed -
181 /// the worker is still gone; only the worktree directory lingers.
182 /// Teardown and worktree cleanup are independent: a cleanup failure
183 /// never rolls back the kill.
184 ///
185 /// `branch_cleanup_warning` is `Some` when the worker's
186 /// `worktree-<label>` branch was left in place - it holds commits
187 /// reachable from no other ref, or the check itself failed.
188 Despawned { worktree_cleanup_warning: Option<String>, branch_cleanup_warning: Option<String> },
189 /// Despawn refused: the worktree has uncommitted/untracked changes
190 /// or unpushed commits and `force` was not set. Nothing was torn
191 /// down; the worker stays live. `reason` names what is dirty.
192 Blocked { reason: String },
193 /// No live worker matched `label` (already gone or never existed).
194 NotFound,
195 /// The despawn could not be carried out: the worker's durable row
196 /// could not be read or removed, so whether one is there is unknown.
197 /// Distinct from [`Self::NotFound`], which claims there is none.
198 Failed { reason: String },
199}
200
201/// Mutation kind for a `SessionUpdate::WorkerStatusChanged` event.
202/// `Added` and `StatusChanged` carry a fresh `WorkerStatus` snapshot;
203/// `Removed` carries the last-known snapshot for symmetry but a view's
204/// reducer treats it as a delete.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "snake_case")]
207pub enum WorkerStatusAction {
208 Added,
209 Removed,
210 StatusChanged,
211}
212
213/// What has happened to a worker's git worktree as of the
214/// `SessionUpdate::WorkerStatusChanged` event carrying it. Only the
215/// `agents__despawn` path ever removes one. Among the spawn
216/// rollbacks, the dividing line is `Connected`: one that fires before
217/// the subprocess connected reports [`Self::Absent`], because claude
218/// never ran to create a worktree, while a rollback after `Connected`
219/// (a failed tag-write) reports [`Self::untouched`], because by then
220/// the worktree is on disk. Every other emitter reports
221/// [`Self::untouched`].
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum WorktreeDisposition {
225 /// Nothing to report on: either the worker was spawned outside a
226 /// git repo, or its spawn failed before it had a worktree.
227 Absent,
228 /// On disk and untouched.
229 Intact,
230 /// Gone from disk by the time the despawn finished, whether or not
231 /// the despawn is what removed it.
232 Removed,
233 /// The despawn's removal failed and the worktree is still on disk.
234 RemovalFailed,
235}
236
237/// A reply channel for a command that carries none.
238///
239/// A command off the socket has no channel to answer down - its reply is a
240/// socket message instead - so the handler is given this rather than being
241/// made to ask: sending into it reports the closed channel it is, which
242/// every call site already discards.
243pub fn unanswerable<T>() -> oneshot::Sender<T> {
244 oneshot::channel().0
245}
246
247impl WorktreeDisposition {
248 /// The disposition when nothing has touched the worktree.
249 pub fn untouched(is_git_repo_at_spawn: bool) -> Self {
250 if is_git_repo_at_spawn { Self::Intact } else { Self::Absent }
251 }
252}
253
254/// Command envelope: a view -> forge-workspace.
255///
256/// Every variant carries a `SessionSlot` identifying the target
257/// session task. `Workspace::dispatch` fans the variant into the
258/// matching task's command receiver.
259///
260/// A reply channel cannot cross a socket, so the fields carrying one are
261/// `Option` and skipped on the wire: `#[serde(skip)]` reconstructs a field
262/// on deserialize, which needs a `Default`, and a bare `oneshot::Sender` has
263/// none while an `Option` does.
264#[derive(Serialize, Deserialize)]
265#[serde(rename_all = "snake_case")]
266pub enum Command {
267 Prompt {
268 key: SessionSlot,
269 text: String,
270 attachments: Vec<ImageAttachment>,
271 },
272 /// [`Self::Prompt`], under an id the caller already minted.
273 ///
274 /// A view mints its own so it can draw the prompt's row and key it to the
275 /// CLI's `command_lifecycle` frames by id, without matching on order or
276 /// text - two sends with a cron fire between them cannot be told apart any
277 /// other way. Every other dispatcher uses [`Self::Prompt`] and lets the
278 /// core mint.
279 PromptUnder {
280 key: SessionSlot,
281 text: String,
282 attachments: Vec<ImageAttachment>,
283 uuid: String,
284 /// Who dispatched it, for the queued row's label.
285 source: PromptSource,
286 /// The pressing view's own token for this press, like
287 /// [`Self::MarketplaceAdd`]'s: a refusal of this prompt echoes it, so
288 /// a composer with sends out on two seats marks the one that failed
289 /// (#1583). `None` from a view that minted none.
290 #[serde(default)]
291 dispatch_id: Option<u64>,
292 },
293 Cancel {
294 key: SessionSlot,
295 },
296 /// Drop one prompt still in the CLI's queue, by the uuid it was sent under.
297 CancelQueuedPrompt {
298 key: SessionSlot,
299 uuid: String,
300 },
301 /// A view opened the row of a finished call and asks for the command's
302 /// own output: the tail of the file the CLI streamed it to.
303 ///
304 /// Answered by the transport from its held conversation, which carries
305 /// the frame that named the call's `output_file`. The answer is
306 /// [`SessionUpdate::CallOutput`] - the tail, or a named reason there is
307 /// none. Asking again re-reads, which is the whole of what a running
308 /// tail would need.
309 ReadCallOutput {
310 key: SessionSlot,
311 call_id: String,
312 },
313 SetMode {
314 key: SessionSlot,
315 mode: PermissionMode,
316 },
317 SetModel {
318 key: SessionSlot,
319 model: String,
320 },
321 NewSession {
322 key: SessionSlot,
323 cwd: String,
324 launch_settings: SessionLaunchSettings,
325 /// The id the new occupant must run under. `None` mints one, which
326 /// is what `/new` wants; a handoff supplies the id it already
327 /// recorded, because its row names the pair before the spawn.
328 #[serde(default)]
329 session_id: Option<String>,
330 },
331 ResumeSession {
332 key: SessionSlot,
333 session_id: String,
334 cwd: String,
335 launch_settings: SessionLaunchSettings,
336 },
337 /// Hand this seat over to a new session that continues its work.
338 ///
339 /// **Two halves of one handoff.** `brief: None` is the entry: the seat is
340 /// asked for the judgment half in the procedure turn. `brief: Some(note)`
341 /// is the exit: the turn's note, accepted only while a handoff is
342 /// running. Either way the successor is started the way `/new` starts one.
343 Handoff {
344 key: SessionSlot,
345 brief: Option<String>,
346 },
347 RespondPermission {
348 key: SessionSlot,
349 tool_id: String,
350 outcome: PermissionOutcome,
351 },
352 /// Answer a held Slack draft. The blocked Slack handler is awaiting
353 /// this answer: approving sends it, declining sends nothing, and a
354 /// send-back carries the reader's words to the agent instead.
355 /// App-level command (`key()` returns `None`): the registry is
356 /// workspace state, and `key` names the owner the answer is
357 /// checked against, not a SessionTask route.
358 RespondSlackPost {
359 key: SessionSlot,
360 id: uuid::Uuid,
361 answer: forge_primitives::slack::SlackDraftAnswer,
362 },
363 /// Answer a parked browser hand-off. The blocked `browser_hand_off`
364 /// handler is awaiting this answer: what it means, the door in the client
365 /// that sent it, and - stamped by the server - the connection that carried
366 /// it. App-level like `RespondSlackPost`: the registry is workspace state,
367 /// and `key` names the owner the answer is checked against.
368 RespondBrowserHandOff {
369 key: SessionSlot,
370 id: uuid::Uuid,
371 answer: forge_primitives::browser::HandOffAnswer,
372 },
373 /// Answer a parked terminal hand-off. The blocked `terminal__hand_off`
374 /// handler is awaiting this answer: what it means, the door in the client
375 /// that sent it, and - stamped by the server - the connection that carried
376 /// it. App-level like `RespondBrowserHandOff`: the registry is workspace
377 /// state, and `key` names the owner the answer is checked against.
378 RespondTerminalHandOff {
379 key: SessionSlot,
380 id: uuid::Uuid,
381 answer: forge_primitives::terminal::TerminalHandOffAnswer,
382 },
383 /// Answer a parked document hand-off: `NotNow` declines it, `Approve`
384 /// and `Feedback` release it with the reader's decision, note and the
385 /// review the finish filed. The blocked `hand_off_document` call is
386 /// awaiting this; `key` names the owner the answer is checked against.
387 AnswerDocumentHandOff {
388 key: SessionSlot,
389 id: uuid::Uuid,
390 answer: forge_primitives::document::DocumentHandOffEnding,
391 },
392 /// Answer a held configuration write. The blocked `settings__set` /
393 /// `settings__unset` handler is awaiting this decision; `approved:
394 /// false` means nothing is written. App-level like `RespondSlackPost`:
395 /// the registry is workspace state, and `key` names the owner the
396 /// answer is checked against.
397 RespondSettingsWrite {
398 key: SessionSlot,
399 id: uuid::Uuid,
400 approved: bool,
401 },
402 /// Answer a held vault call. The blocked MCP handler is awaiting this
403 /// answer; a decline runs nothing, a send-back carries the reader's note
404 /// to the agent, and an approval runs the call after unlocking the shared
405 /// session when the card asked for the master password. App-level like
406 /// `RespondSettingsWrite`: the registry is workspace state, and `key`
407 /// names the owner the answer is checked against.
408 RespondVaultAsk {
409 key: SessionSlot,
410 id: uuid::Uuid,
411 answer: forge_primitives::vault::VaultAskAnswer,
412 },
413 /// A client enrolled one purpose's wrapped secret under its device's
414 /// enrolment. The blob is opaque: the client wrapped it under the platform
415 /// authenticator's key, and the core stores the bytes without decoding
416 /// them.
417 ///
418 /// `enrolment` is the id the client minted for the device; an id the core
419 /// has not seen starts the enrolment, and a known one adds or replaces the
420 /// purpose's blob - a rotation's rewrite is the same command again.
421 /// App-level (`key()` returns `None`); the outcome rides
422 /// [`SessionUpdate::BiometricEnrolmentsChanged`].
423 BiometricEnrol {
424 enrolment: uuid::Uuid,
425 /// The device's own name for itself, as the settings page draws it.
426 device: String,
427 /// The consumer the blob belongs to (`"vault"` in v1).
428 purpose: String,
429 /// The wrapped secret, base64.
430 blob: String,
431 /// Answers `true` when the blob was stored. **A store failure is a
432 /// dispatch refusal, never `false`**, so the two cannot be mistaken
433 /// for each other. The rotation rewrite reads the answer to decide
434 /// whether the vault must lock (decision 15).
435 #[serde(skip)]
436 respond: Option<oneshot::Sender<bool>>,
437 },
438 /// A rotation's rewrite of an **existing** enrolment: the same purpose
439 /// and a fresh blob on an id the core already holds.
440 ///
441 /// **The person's press is what creates a row**; this may only update
442 /// one. A device the person revoked has no row, so its rewrite answers
443 /// `false` - the client then locks rather than re-registering itself.
444 /// Answers `true` when the blob was replaced, `false` when the id or the
445 /// purpose is gone, and a store failure is a dispatch refusal. App-level;
446 /// the outcome rides [`SessionUpdate::BiometricEnrolmentsChanged`].
447 BiometricRewrite {
448 enrolment: uuid::Uuid,
449 purpose: String,
450 blob: String,
451 #[serde(skip)]
452 respond: Option<oneshot::Sender<bool>>,
453 },
454 /// A client asks for the blob it enrolled for one purpose, so its gate can
455 /// unwrap it. Answers through `reply_to` with the blob, or `null` when that
456 /// purpose is not enrolled on this device - the state a client falls back
457 /// to the password on. A store failure is a dispatch refusal instead, so
458 /// the two cannot read alike.
459 BiometricRelease {
460 enrolment: uuid::Uuid,
461 purpose: String,
462 #[serde(skip)]
463 respond: Option<oneshot::Sender<Option<forge_primitives::biometric::BiometricBlob>>>,
464 },
465 /// The reader dropped one purpose's enrolment, or (`purpose: None`) the
466 /// device's whole enrolment. App-level; the outcome rides
467 /// [`SessionUpdate::BiometricEnrolmentsChanged`].
468 BiometricRevoke {
469 enrolment: uuid::Uuid,
470 purpose: Option<String>,
471 },
472 /// The reader locked the shared vault now, rather than waiting for the
473 /// idle clock. **App-level**: the process holds one vault session, so the
474 /// settings page's vault card is where the press comes from and no seat
475 /// has a better claim to it than another. Workspace state rather than
476 /// agent traffic, and the parked cards are re-stamped so a dock that
477 /// already drew one asks for the password again.
478 VaultLock,
479 /// The material a client wraps to enrol this device: the shared session's
480 /// current refresh token and the account's user key, base64. Answers
481 /// through `reply_to` with [`forge_primitives::vault::VaultReleased`],
482 /// and `null` while the session is locked - the state a device cannot
483 /// enrol from.
484 ///
485 /// **It is the vault's material, not the biometric gate's.** The gate
486 /// stores and releases whatever blob a consumer wraps; only the vault
487 /// knows what its own material is, so the command lives on the vault's
488 /// surface.
489 VaultEnrolmentMaterial {
490 #[serde(skip)]
491 respond: Option<oneshot::Sender<Option<forge_primitives::vault::VaultReleased>>>,
492 },
493 RespondQuestion {
494 key: SessionSlot,
495 tool_id: String,
496 outcome: QuestionOutcome,
497 },
498 /// Set one `/dictate` overlay axis for this session, or clear
499 /// every axis at once. Workspace state on the `DomainSession`,
500 /// never routed to the agent; the echo lands as
501 /// `SessionUpdate::DictateOverrides`.
502 SetDictateOverride {
503 key: SessionSlot,
504 update: crate::dictate::DictateOverrideUpdate,
505 },
506 /// Clear every `/dictate` override this session holds.
507 ResetDictateOverrides {
508 key: SessionSlot,
509 },
510 /// Check the model catalogue now. App-level command (`key()` returns
511 /// `None`); routed inline. Fire-and-forget: the outcome rides
512 /// [`SessionUpdate::DictateModelsChanged`], so a client watches its
513 /// own subscription rather than a reply.
514 DictateCatalogueCheck,
515 /// Download a feed variant into the models directory. App-level
516 /// (`key()` returns `None`), and its outcome rides
517 /// [`SessionUpdate::DictateModelsChanged`] the way a check's does.
518 ///
519 /// The variant is the feed's own verb for the model; its doc supplies
520 /// the download link, and nothing here constructs one.
521 DictateInstall {
522 variant: String,
523 },
524 /// Load one installed model and make it the role's active model on the
525 /// running forge. App-level, and its outcome rides
526 /// [`SessionUpdate::DictateModelsChanged`].
527 DictateActivate {
528 role: crate::dictate::DictateRole,
529 file: String,
530 },
531 /// Return one role to its `[dictate]` key or its compiled default.
532 /// App-level, same outcome channel as activation.
533 DictateDeactivate {
534 role: crate::dictate::DictateRole,
535 },
536 /// Run one model over one tier of this machine's own material.
537 /// App-level, and its outcome rides
538 /// [`SessionUpdate::DictateModelsChanged`] like the others: ticks as
539 /// it runs, then the result or the reason.
540 DictateBench {
541 target: crate::bench::BenchTarget,
542 tier: forge_dictate::bench::Tier,
543 },
544 /// Stop the bench in flight. What ran so far is discarded: a partial
545 /// corpus is not a result.
546 DictateBenchStop,
547 /// Score the speaking loop on the public corpus: synthesize each
548 /// transcript, hear it back through the transcribing and cleanup pair,
549 /// and report the round-trip words, the first-audio latency and the
550 /// render's pace. App-level, same outcome channel as the words bench.
551 VoiceBenchStart,
552 /// Stop the speaking bench in flight: a partial corpus is not a result.
553 VoiceBenchStop,
554 /// Begin recording the read-aloud set: the connection that sent this
555 /// feeds the audio as binary frames, like a take a client captures, and
556 /// no session owns it. App-level; the recording's own state rides the
557 /// models read.
558 DictateReadAloudStart {
559 /// The connection that sent this, stamped by the transport that
560 /// received it, as [`Command::DictateStream`]'s is: only that
561 /// connection can feed or stop the recording.
562 #[serde(skip)]
563 initiator: Option<u64>,
564 },
565 /// Stop the read-aloud recording. `keep` writes it into the set; a stop
566 /// that does not keeps nothing, which is the page's cancel.
567 DictateReadAloudStop {
568 keep: bool,
569 /// Stamped like [`Command::DictateReadAloudStart`]'s.
570 #[serde(skip)]
571 initiator: Option<u64>,
572 },
573 /// Drop one recording from the read-aloud set, by the id the page read.
574 DictateReadAloudDelete {
575 id: String,
576 },
577 /// Drop one saved bench result, named by its own key: the target, the
578 /// tier, and the corpus it ran over.
579 DictateBenchDelete {
580 target: crate::bench::BenchTarget,
581 tier: forge_dictate::bench::Tier,
582 corpus: String,
583 },
584 /// Remove one downloaded model - the file and its record - which is what
585 /// a sweep that scored a candidate it did not adopt leaves behind.
586 DictateUninstall {
587 file: String,
588 },
589 // --- Plugins / marketplaces (app-level; the Extensions page) ---
590 //
591 // These variants carry the work through the command bus so a view
592 // can act. Every one is app-level (`key()` returns `None`), serialized
593 // one at a time, and reports through the `Plugins*` updates a view
594 // already listens to.
595 /// Install one plugin at a scope. `project` names a declared project
596 /// for the `project`/`local` scopes and is ignored for `user`, which
597 /// runs in the home dir; it is a NAME rather than a path so the
598 /// working directory always comes from `forge.toml`.
599 PluginInstall {
600 plugin_id: String,
601 scope: String,
602 project: Option<String>,
603 },
604 /// Update one installed plugin in its own scope and project, which the
605 /// registry lookup resolves - the caller states no cwd.
606 PluginUpdate {
607 plugin_id: String,
608 scope: String,
609 },
610 /// Update exactly the stale installed plugins, one CLI call per entry,
611 /// continuing past failures. The set is computed from a fresh scan at
612 /// dispatch, so it is what the marketplace reports NOW.
613 PluginUpdateAll,
614 /// Refresh the inventory through the CLI and report which installed
615 /// plugins have a newer marketplace version, applying nothing.
616 PluginCheckUpdates,
617 /// Add a marketplace by `owner/repo` or URL, at user scope.
618 ///
619 /// `dispatch_id` is the pressing view's own token for this press - the
620 /// add is the one plugin action whose answer disposes a draft a page is
621 /// holding - and it rides back on the refusal of this dispatch and on
622 /// the outcome frames that answer it, so the press can attribute its own
623 /// answer to itself.
624 MarketplaceAdd {
625 source: String,
626 dispatch_id: Option<u64>,
627 },
628 /// Repair a drifted marketplace with the CLI's remove-and-re-add pair.
629 MarketplaceRepair {
630 name: String,
631 },
632 /// User clicked an inactive project to wake it. No `key` - the
633 /// session doesn't exist yet; workspace resolves the id it will run
634 /// under and emits `SessionUpdate::Spawning` then `::Connected` on
635 /// it. This is an App-level Command, not per-session.
636 SpawnProject {
637 project_name: String,
638 launch_settings: SessionLaunchSettings,
639 },
640 /// The user's verdict on a row waiting on their look. App-level (no
641 /// session route): the board is the user's, and the row names its
642 /// own project. `approve` completes it (past the verify gate - this
643 /// IS the verification); a send-back returns it to its owner with
644 /// `words` appended to the detail and one message into that session.
645 TaskVerdict {
646 project: String,
647 id: String,
648 approve: bool,
649 words: Option<String>,
650 },
651 /// The user answers a row waiting on a question: the words go into
652 /// the detail, the row resumes in progress, and its owner hears once.
653 TaskAnswer {
654 project: String,
655 id: String,
656 words: String,
657 },
658 /// The user moves a row in its queue.
659 TaskRank {
660 project: String,
661 id: String,
662 to: RankMove,
663 },
664 /// The user gives a row an owner (a label in the project) or takes
665 /// it back.
666 TaskAssign {
667 project: String,
668 id: String,
669 owner: Option<String>,
670 },
671 /// The user cuts a row from the board.
672 TaskCreate {
673 project: String,
674 subject: String,
675 parent: Option<String>,
676 },
677 /// The user moves a row's state directly - the drag across the board.
678 /// `place` carries where a drop landed, so the position the user chose
679 /// is the priority the row keeps; a keyboard lane step carries none.
680 TaskMove {
681 project: String,
682 id: String,
683 to: forge_primitives::tasks::TaskStatus,
684 #[serde(default)]
685 place: Option<RankMove>,
686 },
687 /// The user strikes a lead's attestation: the look gate is re-minted
688 /// and the row parks on the user again. App-level like `TaskVerdict`.
689 TaskReopen {
690 project: String,
691 id: String,
692 },
693 /// The user reserves a row's pick for themselves (or releases it): the
694 /// sweep leaves a reserved row alone rather than dispatching it.
695 TaskReserve {
696 project: String,
697 id: String,
698 reserved: bool,
699 },
700 /// Cross-project delivery (#114 v1). Dispatched by the
701 /// `mcp__forge__agents__send_message` tool impl via
702 /// `WorkspaceFacade::deliver_peer_prompt`. Routed to
703 /// `spawn::handle_deliver_peer_prompt` which: (a) resolves
704 /// `target_project` to a `SessionSlot`; (b) if target is running,
705 /// dispatches a plain `Command::Prompt` carrying the wrapper
706 /// prose; (c) if sleeping, parks `wrapped` for target's owner and
707 /// dispatches `Command::SpawnProject`.
708 ///
709 /// App-level command (`key()` returns `None`); workspace routes
710 /// to the App-level handler in `spawn.rs`. The `caller` field is
711 /// the source session's key, carried so a parking that never lands
712 /// can route its delivery notice back.
713 DeliverPeerPrompt {
714 caller: SessionSlot,
715 target_project: String,
716 wrapped: WrappedPrompt,
717 /// What happened to the prompt: `Ok(())` once it was accepted -
718 /// handed to the target's running session, or parked for a spawn
719 /// this handler kicked off - and `Err(reason)` when nothing could
720 /// take it. The sender's ack is built from this answer alone, the
721 /// way the worker engine's is (#2342).
722 #[serde(skip)]
723 respond: Option<oneshot::Sender<Result<(), String>>>,
724 },
725 /// Spawn a new worker session in `project_key`. Dispatched by
726 /// the `agents__spawn` MCP Tool impl after caller-tag validation,
727 /// or by the lead Connected hook when reviving
728 /// across-restart workers.
729 ///
730 /// `resume_existing`: when `Some(session_id)`, the handler resumes
731 /// the named session via `SessionTarget::Session` instead of
732 /// spawning fresh. The session_id MUST already carry the
733 /// `forge:worker:<label>` tag (the lead Connected hook verifies
734 /// this before dispatching). `WorkerEntry::needs_tag` is set false
735 /// on the resume path since the tag is already on disk. `None`
736 /// preserves the original fresh-spawn path used by `agents__spawn`.
737 ///
738 /// `return_to` carries the spawn result back to the calling tool
739 /// invocation; `Ok((session_id, tag))` on success, `Err(message)`
740 /// on failure (e.g. tag-write failed; see spawn handler). On the
741 /// resume path the lead Connected hook ignores the reply (the
742 /// worker is for the lead's benefit, not in response to a tool
743 /// call).
744 SpawnWorker {
745 project_key: crate::ProjectKey,
746 label: String,
747 charter: String,
748 /// The slot of the session issuing the spawn - normally the
749 /// project's lead.
750 spawned_by: SessionSlot,
751 resume_existing: Option<String>,
752 /// First message delivered as the worker's user turn on Connected,
753 /// via the rate-limited kick dispatcher. Either `agents__spawn`'s
754 /// `kick`, or - on a re-spawn - the row's `resume_kick` or the
755 /// generic restart note, which is the only thing that wakes a
756 /// resuming worker. `None` -> no kick (the worker idles until
757 /// told).
758 kick: Option<String>,
759 /// Re-orient message delivered instead of the generic restart
760 /// note when this worker is later resumed. `None` on a re-spawn
761 /// keeps whatever the row already holds.
762 resume_kick: Option<String>,
763 /// Whether this worker keeps the built-in `AskUserQuestion`
764 /// tool. Read from the persisted row on a re-spawn, so it
765 /// survives a forge restart.
766 interactive: bool,
767 /// The MCP families this spawn selects, exactly as
768 /// `agents__spawn` validated them; `None`/empty means every
769 /// family. Written to the row on a first spawn and read back
770 /// from it on a re-spawn, so the selection survives restarts.
771 mcp_families: Option<Vec<String>>,
772 /// The tree this spawn runs the worker in, already resolved to
773 /// its repo root by the facade: `None` means the project's own
774 /// path. Written to the row, which is what every read-back
775 /// composes from - so a defaulted field reads exactly as a
776 /// spawn written before the key existed.
777 #[serde(default)]
778 cwd: Option<String>,
779 /// True only for boot/reconnect re-spawns of persisted
780 /// workers, which bypass the project's worker cap:
781 /// they restore state the user already had, and their reply is
782 /// dropped, so a refusal there would strand rows with no
783 /// caller to hear it.
784 from_boot_respawn: bool,
785 #[serde(skip)]
786 return_to: Option<oneshot::Sender<Result<WorkerSpawnReply, String>>>,
787 },
788 /// Close (terminate agent + remove from `live_workers`) the
789 /// worker identified by `label` in `project_key`. Dispatched by
790 /// a view's per-row close click. If duplicates exist, the latest-
791 /// spawned matching entry is removed.
792 CloseWorker {
793 project_key: crate::ProjectKey,
794 label: String,
795 },
796 /// Despawn the worker identified by `label` in `project_key`:
797 /// terminate its agent, drop it from `live_workers`, AND clean up
798 /// its git worktree. Dispatched by the
799 /// `agents__despawn` MCP tool (lead-only). Unlike `CloseWorker`
800 /// (a view's X-button), this also removes the worker's git worktree:
801 /// a clean worktree is removed; a dirty one (uncommitted/untracked
802 /// or unpushed commits) blocks the despawn unless `force`. The
803 /// outcome flows back via `respond`.
804 DespawnWorker {
805 project_key: crate::ProjectKey,
806 label: String,
807 force: bool,
808 #[serde(skip)]
809 respond: Option<oneshot::Sender<DespawnResult>>,
810 },
811 /// Deliver a wrapped peer-style prompt to a worker. Same envelope
812 /// as `DeliverPeerPrompt` but addressed by worker label within
813 /// the caller's project rather than by cross-project name.
814 ///
815 /// `respond` carries what actually happened to the prompt: `Ok(())`
816 /// once it was accepted - handed to the target's session queue, held
817 /// for its turn end, or parked for its connect - and `Err(reason)`
818 /// when nothing could take it. The sender's ack is built from this
819 /// answer alone, so it can only claim what the delivery really did.
820 DeliverWorkerPrompt {
821 caller: SessionSlot,
822 project_key: crate::ProjectKey,
823 target_label: String,
824 wrapped: WrappedPrompt,
825 #[serde(skip)]
826 respond: Option<oneshot::Sender<Result<(), String>>>,
827 },
828 /// Deliver a wrapped prompt from a worker back to its lead.
829 /// Dispatched by the `agents__send_message` Tool impl
830 /// when the caller addresses `label="lead"`. The target
831 /// `SessionSlot` is resolved at Tool dispatch time from the
832 /// worker's `spawned_by_session_id` so the handler can deliver
833 /// directly without re-doing the lookup against a possibly-mutated
834 /// `live_workers` map. Wire shape is identical to
835 /// `DeliverWorkerPrompt` (same PeerEnvelopeAppended echo + same
836 /// `Command::Prompt` dispatch into the target session), and its
837 /// `respond` carries the same acceptance answer.
838 DeliverWorkerPromptToLead {
839 caller: SessionSlot,
840 target_lead_key: SessionSlot,
841 wrapped: WrappedPrompt,
842 #[serde(skip)]
843 respond: Option<oneshot::Sender<Result<(), String>>>,
844 },
845 /// Deliver a matched Gotify notification into `project` as a plain
846 /// user turn (spawning the project if asleep, exactly like a cron
847 /// prompt). `team_role` targets a durable team worker when `Some`;
848 /// `None` targets the project lead. Dispatched by the
849 /// `GotifyHost::deliver` port impl, one per matching subscription;
850 /// handled by `spawn::deliver_gotify_message`. `notification` carries
851 /// the resolved app name, title, message, and priority - its
852 /// `to_prose()` is the user-turn text, and the same struct drives the
853 /// chat-echo. App-level command (`key()` returns `None`).
854 DeliverGotifyMessage {
855 project: String,
856 team_role: Option<String>,
857 notification: crate::mcp::gotify::types::GotifyNotification,
858 },
859 /// Begin a take the CLIENT captures: the connection that sent this
860 /// feeds the audio as binary frames, so no device is opened here.
861 /// Registered synchronously by the dispatch that receives it, so the
862 /// frame a client sends next on the same ordered socket finds it.
863 ///
864 /// `options` are the axes that client's panel was showing; they
865 /// normalize this take, where an in-process take reads the
866 /// session's stored overrides at its stop.
867 DictateStream {
868 key: SessionSlot,
869 options: crate::dictate::DictateAxes,
870 /// The surface whose own capture began this take - `composer`,
871 /// `review`, `files`, `doc` - carried so the landed words reach the
872 /// surface that began them and no other. Absent from a client older
873 /// than the field, which is the one case every surface reads as "the
874 /// old rules decide", as it did before.
875 ///
876 /// **Read as an opaque name, never validated.** The vocabulary is the
877 /// client's own, and under forge's trust model a client that misnames
878 /// its take only mislabels its own words.
879 #[serde(default)]
880 surface: Option<String>,
881 /// The connection that sent this, stamped by the transport that
882 /// received it: the take belongs to that connection, so every
883 /// update about it goes back there alone. `None` is this
884 /// process's own take.
885 #[serde(skip)]
886 initiator: Option<u64>,
887 },
888 /// Submit (`submit = true`) or abandon the take started by `key`.
889 /// During recording this is release-to-submit vs discard; during a
890 /// transcription in flight it abandons the ticket.
891 DictateStop {
892 key: SessionSlot,
893 submit: bool,
894 /// The connection whose take this stop addresses, stamped like
895 /// [`Command::DictateStream`]'s: only the connection a take
896 /// belongs to can stop it. `None` is this process's own.
897 #[serde(skip)]
898 initiator: Option<u64>,
899 },
900 /// Set the status of one review thread by id, bumping its
901 /// `updated_at`. App-level command (`key()` returns `None`);
902 /// routed inline.
903 SetReviewThreadStatus {
904 project: String,
905 branch: String,
906 thread_id: String,
907 status: ReviewStatus,
908 },
909 /// Release the session `session_key` (cascade-aware: a project
910 /// lead's workers terminate first). Dispatched by a view's
911 /// per-row close click; a view removes its own bucket around the
912 /// dispatch. App-level command (`key()` returns `None`); routed
913 /// inline.
914 CloseSession {
915 session_key: SessionSlot,
916 },
917 /// Insert or replace one review thread by id in `(project, branch)`.
918 /// `respond` carries whether the write was confirmed, so the
919 /// overlay's at-risk durability flag stays honest (the same
920 /// value-returning shape as `SpawnWorker.return_to`). The handler
921 /// runs inline in dispatch, so the response is present the moment
922 /// dispatch returns. App-level command (`key()` returns `None`).
923 UpsertReviewThread {
924 project: String,
925 branch: String,
926 thread: ReviewThread,
927 /// The seat whose words this write sends: a landed take's words are
928 /// the seat's until a write carries them, and this is what tells the
929 /// composer which take the comment spends (#2283). `None` from a
930 /// client older than the field, and from a write no seat made.
931 #[serde(default)]
932 origin: Option<SessionSlot>,
933 /// **The words this press sent**, as the ask's `answered_with` states
934 /// its own: the thread carries every turn it ever had, so the composer
935 /// cannot tell the new comment from an old one that happens to match a
936 /// landed take (#2283). `None` from a client older than the field.
937 #[serde(default)]
938 sent_words: Option<String>,
939 #[serde(skip)]
940 respond: Option<oneshot::Sender<bool>>,
941 },
942 /// Delete one review thread from `(project, branch)` - the reviewer's
943 /// own retraction, wherever its card is drawn. `respond` carries
944 /// whether the store confirmed; `None` when the write failed.
945 DeleteReviewThread {
946 project: String,
947 branch: String,
948 thread_id: String,
949 #[serde(skip)]
950 respond: Option<oneshot::Sender<bool>>,
951 },
952 /// Submit (seal) the listed threads as one review round.
953 /// `respond` carries the minted review, `None` when the store
954 /// write failed. App-level command (`key()` returns `None`).
955 SubmitReview {
956 project: String,
957 branch: String,
958 summary: Option<String>,
959 thread_ids: Vec<String>,
960 origin: SessionSlot,
961 #[serde(skip)]
962 respond: Option<oneshot::Sender<Option<forge_primitives::ReviewSet>>>,
963 },
964 /// A client turned voice on or off for the seat. The listener set is per
965 /// connection, so the transport stamps which one.
966 VoiceMode {
967 key: SessionSlot,
968 on: bool,
969 /// Whether this off is the reader's own, which is what tells the
970 /// session it has stopped listening. A move to another seat pauses
971 /// without the note, so a seat switch does not toggle the agent.
972 #[serde(default)]
973 explicit: bool,
974 #[serde(skip)]
975 initiator: Option<u64>,
976 },
977 /// Play one voice on the seat's own stream, so it can be heard before it
978 /// is chosen. Refused when that seat's speaker switch is off.
979 VoicePreview {
980 key: SessionSlot,
981 voice: String,
982 },
983}
984
985impl Command {
986 /// The `SessionSlot` this command routes to, or `None` for
987 /// App-level commands (`SpawnProject`). `Workspace::dispatch`
988 /// routes `None` commands to its app-level handler (which resolves
989 /// the id the new session will run under and spawns the agent);
990 /// `Some(key)` commands route to the matching SessionTask.
991 pub fn key(&self) -> Option<&SessionSlot> {
992 match self {
993 Self::Prompt { key, .. }
994 | Self::PromptUnder { key, .. }
995 | Self::Cancel { key }
996 | Self::CancelQueuedPrompt { key, .. }
997 | Self::ReadCallOutput { key, .. }
998 | Self::SetMode { key, .. }
999 | Self::SetModel { key, .. }
1000 | Self::NewSession { key, .. }
1001 | Self::ResumeSession { key, .. }
1002 | Self::Handoff { key, .. }
1003 | Self::RespondPermission { key, .. }
1004 | Self::RespondQuestion { key, .. }
1005 | Self::SetDictateOverride { key, .. }
1006 | Self::ResetDictateOverrides { key }
1007 | Self::VoiceMode { key, .. }
1008 | Self::VoicePreview { key, .. } => Some(key),
1009 Self::SpawnProject { .. }
1010 | Self::DictateStream { .. }
1011 | Self::DictateStop { .. }
1012 | Self::DeliverPeerPrompt { .. }
1013 | Self::SpawnWorker { .. }
1014 | Self::CloseWorker { .. }
1015 | Self::DespawnWorker { .. }
1016 | Self::DeliverWorkerPrompt { .. }
1017 | Self::DeliverWorkerPromptToLead { .. }
1018 | Self::DeliverGotifyMessage { .. }
1019 | Self::DictateCatalogueCheck
1020 | Self::DictateInstall { .. }
1021 | Self::DictateActivate { .. }
1022 | Self::DictateDeactivate { .. }
1023 | Self::DictateBench { .. }
1024 | Self::DictateBenchStop
1025 | Self::VoiceBenchStart
1026 | Self::VoiceBenchStop
1027 | Self::DictateReadAloudStart { .. }
1028 | Self::DictateReadAloudStop { .. }
1029 | Self::DictateReadAloudDelete { .. }
1030 | Self::DictateBenchDelete { .. }
1031 | Self::DictateUninstall { .. }
1032 | Self::SetReviewThreadStatus { .. }
1033 | Self::CloseSession { .. }
1034 | Self::UpsertReviewThread { .. }
1035 | Self::DeleteReviewThread { .. }
1036 | Self::RespondSlackPost { .. }
1037 | Self::RespondBrowserHandOff { .. }
1038 | Self::RespondTerminalHandOff { .. }
1039 | Self::AnswerDocumentHandOff { .. }
1040 | Self::RespondSettingsWrite { .. }
1041 | Self::RespondVaultAsk { .. }
1042 | Self::SubmitReview { .. }
1043 | Self::TaskVerdict { .. }
1044 | Self::TaskAnswer { .. }
1045 | Self::TaskRank { .. }
1046 | Self::TaskAssign { .. }
1047 | Self::TaskCreate { .. }
1048 | Self::TaskMove { .. }
1049 | Self::TaskReopen { .. }
1050 | Self::TaskReserve { .. }
1051 | Self::PluginInstall { .. }
1052 | Self::PluginUpdate { .. }
1053 | Self::PluginUpdateAll
1054 | Self::PluginCheckUpdates
1055 | Self::MarketplaceAdd { .. }
1056 | Self::MarketplaceRepair { .. }
1057 | Self::BiometricEnrol { .. }
1058 | Self::BiometricRelease { .. }
1059 | Self::BiometricRevoke { .. }
1060 | Self::BiometricRewrite { .. }
1061 | Self::VaultLock
1062 | Self::VaultEnrolmentMaterial { .. } => None,
1063 }
1064 }
1065
1066 /// The dispatch id the press that made this command minted, when it
1067 /// named one: what a refusal echoes and the outcome frames carry back.
1068 pub fn dispatch_id(&self) -> Option<u64> {
1069 match self {
1070 Self::MarketplaceAdd { dispatch_id, .. } | Self::PromptUnder { dispatch_id, .. } => {
1071 *dispatch_id
1072 }
1073 _ => None,
1074 }
1075 }
1076}
1077
1078impl std::fmt::Debug for Command {
1079 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1080 // Custom Debug: discriminant + routing key (and a few cheap
1081 // identifying fields) only. `oneshot::Sender` on `SpawnWorker`
1082 // isn't Debug, so deriving isn't an option; payloads can be
1083 // bulky and aren't useful in trace output.
1084 match self {
1085 Self::Prompt { key, .. } => {
1086 f.debug_struct("Prompt").field("key", key).finish_non_exhaustive()
1087 }
1088 Self::PromptUnder { key, uuid, .. } => f
1089 .debug_struct("PromptUnder")
1090 .field("key", key)
1091 .field("uuid", uuid)
1092 .finish_non_exhaustive(),
1093 Self::Cancel { key } => f.debug_struct("Cancel").field("key", key).finish(),
1094 Self::CancelQueuedPrompt { key, uuid } => {
1095 f.debug_struct("CancelQueuedPrompt").field("key", key).field("uuid", uuid).finish()
1096 }
1097 Self::ReadCallOutput { key, call_id } => f
1098 .debug_struct("ReadCallOutput")
1099 .field("key", key)
1100 .field("call_id", call_id)
1101 .finish(),
1102 Self::SetMode { key, mode } => {
1103 f.debug_struct("SetMode").field("key", key).field("mode", mode).finish()
1104 }
1105 Self::SetModel { key, model } => {
1106 f.debug_struct("SetModel").field("key", key).field("model", model).finish()
1107 }
1108 Self::NewSession { key, cwd, .. } => f
1109 .debug_struct("NewSession")
1110 .field("key", key)
1111 .field("cwd", cwd)
1112 .finish_non_exhaustive(),
1113 Self::ResumeSession { key, session_id, cwd, .. } => f
1114 .debug_struct("ResumeSession")
1115 .field("key", key)
1116 .field("session_id", session_id)
1117 .field("cwd", cwd)
1118 .finish_non_exhaustive(),
1119 Self::Handoff { key, brief } => f
1120 .debug_struct("Handoff")
1121 .field("key", key)
1122 .field("brief", &brief.as_ref().map(|_| "exit"))
1123 .finish(),
1124 Self::RespondPermission { key, tool_id, .. } => f
1125 .debug_struct("RespondPermission")
1126 .field("key", key)
1127 .field("tool_id", tool_id)
1128 .finish_non_exhaustive(),
1129 Self::RespondQuestion { key, tool_id, .. } => f
1130 .debug_struct("RespondQuestion")
1131 .field("key", key)
1132 .field("tool_id", tool_id)
1133 .finish_non_exhaustive(),
1134 Self::RespondSlackPost { key, id, answer } => f
1135 .debug_struct("RespondSlackPost")
1136 .field("key", key)
1137 .field("id", id)
1138 .field("answer", answer)
1139 .finish_non_exhaustive(),
1140 Self::RespondBrowserHandOff { key, id, answer } => f
1141 .debug_struct("RespondBrowserHandOff")
1142 .field("key", key)
1143 .field("id", id)
1144 .field("answer", answer)
1145 .finish_non_exhaustive(),
1146 Self::RespondTerminalHandOff { key, id, answer } => f
1147 .debug_struct("RespondTerminalHandOff")
1148 .field("key", key)
1149 .field("id", id)
1150 .field("answer", answer)
1151 .finish_non_exhaustive(),
1152 Self::RespondSettingsWrite { key, id, approved } => f
1153 .debug_struct("RespondSettingsWrite")
1154 .field("key", key)
1155 .field("id", id)
1156 .field("approved", approved)
1157 .finish(),
1158 Self::RespondVaultAsk { key, id, answer } => f
1159 .debug_struct("RespondVaultAsk")
1160 .field("key", key)
1161 .field("id", id)
1162 .field("answer", answer)
1163 .finish_non_exhaustive(),
1164 Self::BiometricEnrol { enrolment, device, purpose, .. } => f
1165 .debug_struct("BiometricEnrol")
1166 .field("enrolment", enrolment)
1167 .field("device", device)
1168 .field("purpose", purpose)
1169 .finish_non_exhaustive(),
1170 Self::BiometricRelease { enrolment, purpose, .. } => f
1171 .debug_struct("BiometricRelease")
1172 .field("enrolment", enrolment)
1173 .field("purpose", purpose)
1174 .finish_non_exhaustive(),
1175 Self::BiometricRevoke { enrolment, purpose } => f
1176 .debug_struct("BiometricRevoke")
1177 .field("enrolment", enrolment)
1178 .field("purpose", purpose)
1179 .finish(),
1180 Self::BiometricRewrite { enrolment, purpose, .. } => f
1181 .debug_struct("BiometricRewrite")
1182 .field("enrolment", enrolment)
1183 .field("purpose", purpose)
1184 .finish_non_exhaustive(),
1185 Self::VaultLock => f.write_str("VaultLock"),
1186 Self::VaultEnrolmentMaterial { .. } => f.write_str("VaultEnrolmentMaterial"),
1187 Self::AnswerDocumentHandOff { key, id, answer } => f
1188 .debug_struct("AnswerDocumentHandOff")
1189 .field("key", key)
1190 .field("id", id)
1191 .field("answer", answer)
1192 .finish_non_exhaustive(),
1193 Self::SetDictateOverride { key, .. } => {
1194 f.debug_struct("SetDictateOverride").field("key", key).finish_non_exhaustive()
1195 }
1196 Self::ResetDictateOverrides { key } => {
1197 f.debug_struct("ResetDictateOverrides").field("key", key).finish()
1198 }
1199 Self::SpawnProject { project_name, .. } => f
1200 .debug_struct("SpawnProject")
1201 .field("project_name", project_name)
1202 .finish_non_exhaustive(),
1203 Self::DeliverPeerPrompt { caller, target_project, .. } => f
1204 .debug_struct("DeliverPeerPrompt")
1205 .field("caller", caller)
1206 .field("target_project", target_project)
1207 .finish_non_exhaustive(),
1208 Self::SpawnWorker { project_key, label, spawned_by, .. } => f
1209 .debug_struct("SpawnWorker")
1210 .field("project_key", project_key)
1211 .field("label", label)
1212 .field("spawned_by", spawned_by)
1213 .field("return_to", &"<oneshot::Sender>")
1214 .finish_non_exhaustive(),
1215 Self::CloseWorker { project_key, label } => f
1216 .debug_struct("CloseWorker")
1217 .field("project_key", project_key)
1218 .field("label", label)
1219 .finish(),
1220 Self::DespawnWorker { project_key, label, force, .. } => f
1221 .debug_struct("DespawnWorker")
1222 .field("project_key", project_key)
1223 .field("label", label)
1224 .field("force", force)
1225 .finish_non_exhaustive(),
1226 Self::DeliverWorkerPrompt { caller, project_key, target_label, .. } => f
1227 .debug_struct("DeliverWorkerPrompt")
1228 .field("caller", caller)
1229 .field("project_key", project_key)
1230 .field("target_label", target_label)
1231 .finish_non_exhaustive(),
1232 Self::DeliverWorkerPromptToLead { caller, target_lead_key, .. } => f
1233 .debug_struct("DeliverWorkerPromptToLead")
1234 .field("caller", caller)
1235 .field("target_lead_key", target_lead_key)
1236 .finish_non_exhaustive(),
1237 Self::DeliverGotifyMessage { project, team_role, notification } => f
1238 .debug_struct("DeliverGotifyMessage")
1239 .field("project", project)
1240 .field("team_role", team_role)
1241 .field("app", ¬ification.app)
1242 .field("priority", ¬ification.priority)
1243 .finish_non_exhaustive(),
1244 Self::DictateCatalogueCheck => f.write_str("DictateCatalogueCheck"),
1245 Self::DictateInstall { variant } => {
1246 f.debug_struct("DictateInstall").field("variant", variant).finish()
1247 }
1248 Self::DictateActivate { role, file } => {
1249 f.debug_struct("DictateActivate").field("role", role).field("file", file).finish()
1250 }
1251 Self::DictateDeactivate { role } => {
1252 f.debug_struct("DictateDeactivate").field("role", role).finish()
1253 }
1254 Self::DictateBench { target, tier } => {
1255 f.debug_struct("DictateBench").field("target", target).field("tier", tier).finish()
1256 }
1257 Self::DictateBenchStop => f.write_str("DictateBenchStop"),
1258 Self::VoiceBenchStart => f.write_str("VoiceBenchStart"),
1259 Self::VoiceBenchStop => f.write_str("VoiceBenchStop"),
1260 Self::DictateReadAloudStart { .. } => f.write_str("DictateReadAloudStart"),
1261 Self::DictateReadAloudStop { keep, .. } => {
1262 f.debug_struct("DictateReadAloudStop").field("keep", keep).finish()
1263 }
1264 Self::DictateReadAloudDelete { id } => {
1265 f.debug_struct("DictateReadAloudDelete").field("id", id).finish()
1266 }
1267 Self::DictateUninstall { file } => {
1268 f.debug_struct("DictateUninstall").field("file", file).finish()
1269 }
1270 Self::DictateBenchDelete { target, tier, corpus } => f
1271 .debug_struct("DictateBenchDelete")
1272 .field("target", target)
1273 .field("tier", tier)
1274 .field("corpus", corpus)
1275 .finish(),
1276 Self::DictateStream { key, .. } => {
1277 f.debug_struct("DictateStream").field("key", key).finish_non_exhaustive()
1278 }
1279 Self::DictateStop { key, submit, .. } => {
1280 f.debug_struct("DictateStop").field("key", key).field("submit", submit).finish()
1281 }
1282 Self::SetReviewThreadStatus { project, branch, thread_id, status } => f
1283 .debug_struct("SetReviewThreadStatus")
1284 .field("project", project)
1285 .field("branch", branch)
1286 .field("thread_id", thread_id)
1287 .field("status", status)
1288 .finish(),
1289 Self::CloseSession { session_key } => {
1290 f.debug_struct("CloseSession").field("session_key", session_key).finish()
1291 }
1292 Self::UpsertReviewThread { project, branch, thread, .. } => f
1293 .debug_struct("UpsertReviewThread")
1294 .field("project", project)
1295 .field("branch", branch)
1296 .field("thread_id", &thread.id)
1297 .finish_non_exhaustive(),
1298 Self::DeleteReviewThread { project, branch, thread_id, .. } => f
1299 .debug_struct("DeleteReviewThread")
1300 .field("project", project)
1301 .field("branch", branch)
1302 .field("thread_id", thread_id)
1303 .finish_non_exhaustive(),
1304 Self::SubmitReview { project, branch, thread_ids, .. } => f
1305 .debug_struct("SubmitReview")
1306 .field("project", project)
1307 .field("branch", branch)
1308 .field("thread_ids", thread_ids)
1309 .finish_non_exhaustive(),
1310 Self::TaskVerdict { project, id, approve, .. } => f
1311 .debug_struct("TaskVerdict")
1312 .field("project", project)
1313 .field("id", id)
1314 .field("approve", approve)
1315 .finish_non_exhaustive(),
1316 Self::TaskAnswer { project, id, .. } => f
1317 .debug_struct("TaskAnswer")
1318 .field("project", project)
1319 .field("id", id)
1320 .finish_non_exhaustive(),
1321 Self::TaskRank { project, id, to } => f
1322 .debug_struct("TaskRank")
1323 .field("project", project)
1324 .field("id", id)
1325 .field("to", to)
1326 .finish_non_exhaustive(),
1327 Self::TaskAssign { project, id, owner } => f
1328 .debug_struct("TaskAssign")
1329 .field("project", project)
1330 .field("id", id)
1331 .field("owner", owner)
1332 .finish_non_exhaustive(),
1333 Self::TaskCreate { project, subject, .. } => f
1334 .debug_struct("TaskCreate")
1335 .field("project", project)
1336 .field("subject", subject)
1337 .finish_non_exhaustive(),
1338 Self::TaskMove { project, id, to, place } => f
1339 .debug_struct("TaskMove")
1340 .field("project", project)
1341 .field("id", id)
1342 .field("to", to)
1343 .field("place", place)
1344 .finish_non_exhaustive(),
1345 Self::TaskReopen { project, id } => f
1346 .debug_struct("TaskReopen")
1347 .field("project", project)
1348 .field("id", id)
1349 .finish_non_exhaustive(),
1350 Self::TaskReserve { project, id, reserved } => f
1351 .debug_struct("TaskReserve")
1352 .field("project", project)
1353 .field("id", id)
1354 .field("reserved", reserved)
1355 .finish_non_exhaustive(),
1356 Self::PluginInstall { plugin_id, scope, project } => f
1357 .debug_struct("PluginInstall")
1358 .field("plugin_id", plugin_id)
1359 .field("scope", scope)
1360 .field("project", project)
1361 .finish(),
1362 Self::PluginUpdate { plugin_id, scope } => f
1363 .debug_struct("PluginUpdate")
1364 .field("plugin_id", plugin_id)
1365 .field("scope", scope)
1366 .finish(),
1367 Self::PluginUpdateAll => f.write_str("PluginUpdateAll"),
1368 Self::PluginCheckUpdates => f.write_str("PluginCheckUpdates"),
1369 Self::MarketplaceAdd { source, dispatch_id } => f
1370 .debug_struct("MarketplaceAdd")
1371 .field("source", source)
1372 .field("dispatch_id", dispatch_id)
1373 .finish(),
1374 Self::MarketplaceRepair { name } => {
1375 f.debug_struct("MarketplaceRepair").field("name", name).finish()
1376 }
1377 Self::VoiceMode { key, on, .. } => {
1378 f.debug_struct("VoiceMode").field("key", key).field("on", on).finish()
1379 }
1380 Self::VoicePreview { key, voice } => {
1381 f.debug_struct("VoicePreview").field("key", key).field("voice", voice).finish()
1382 }
1383 }
1384 }
1385}
1386
1387/// Read a no-signal peak - a level's, or the one a `no_audio` outcome
1388/// carries - taking `null` as no signal rather than as a decode failure.
1389///
1390/// The counterpart of serde writing a non-finite float as `null`: a
1391/// window that heard nothing peaks at exactly zero, whose dBFS is
1392/// negative infinity. Without this, a Rust reader cannot decode the
1393/// message at all, and the failure names the type rather than the case.
1394fn no_signal_reads_as_negative_infinity<'de, D>(deserializer: D) -> Result<f32, D::Error>
1395where
1396 D: serde::Deserializer<'de>,
1397{
1398 Ok(Option::<f32>::deserialize(deserializer)?.unwrap_or(f32::NEG_INFINITY))
1399}
1400
1401/// What one finished dictation take produced. Plain data rather than
1402/// [`forge_dictate::Outcome`]: a view words the notices, so it gets
1403/// the observations and keeps the crate's error shapes.
1404#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1405#[serde(rename_all = "snake_case")]
1406pub enum DictateOutcome {
1407 /// Words to insert at the composer's caret. `truncated` means the
1408 /// take hit the capture cap or the decode budget and is partial.
1409 Landed { text: String, truncated: bool },
1410 /// The audio normalised to nothing - a valid answer, not a failure.
1411 Empty,
1412 /// Nothing rose above the silence floor. A finite `peak_db` is a
1413 /// quiet room and a retry is reasonable; negative infinity means
1414 /// every sample was exactly zero, which is structural and sticky.
1415 NoAudio {
1416 #[serde(default, deserialize_with = "no_signal_reads_as_negative_infinity")]
1417 peak_db: f32,
1418 seconds: u64,
1419 },
1420 /// The take never happened. Covers a busy microphone, a device that
1421 /// would not open, and dictation not being ready.
1422 Refused { message: String },
1423 /// Recognition failed. The response is the same whichever way.
1424 Failed,
1425 /// The user abandoned the take. Resets silently.
1426 Cancelled,
1427}
1428
1429/// The role a caller states for a spawn: a project's lead, or a worker
1430/// named by its label.
1431///
1432/// Both the session's tool surface and its slot's label come from this
1433/// one value, so they cannot disagree - a caller that says `Worker`
1434/// gives a worker's tool surface AND a worker's slot. Every spawn
1435/// states one; forge has no keyless form, because a role it cannot
1436/// state is one it would have to guess.
1437#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1438#[serde(rename_all = "snake_case")]
1439pub enum SpawnRole {
1440 Lead,
1441 Worker {
1442 label: String,
1443 /// Whether this spawn wrote the worker's durable row rather than
1444 /// adopting one that was already there. A rollback may take the
1445 /// row away only when it did: a resume and a boot re-spawn are
1446 /// handed a row that holds the worker's charter, kick and the id
1447 /// being resumed, so the row is the worker, not this spawn's
1448 /// leftover.
1449 wrote_row: bool,
1450 /// The stored MCP-family selection this spawn composes its tool
1451 /// surface from; `None`/empty means every family. Carried here
1452 /// because the server is built inside `get_agent_handle_at_key`,
1453 /// which reads no store of its own.
1454 mcp_families: Option<Vec<String>>,
1455 },
1456}
1457
1458/// Who dispatched a prompt, for a queued row's label.
1459///
1460/// This one is about the sender, and every frame forge writes as a
1461/// user message gets one. Serialised as the label itself.
1462#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)]
1463#[serde(rename_all = "snake_case")]
1464pub enum PromptSource {
1465 /// A person's own send - this view's or another's.
1466 You,
1467 Cron,
1468 Gotify,
1469 Slack,
1470 Peer,
1471 /// forge's own machinery: a worker kick, an auto-continue, a replayed
1472 /// parked delivery whose kind is no longer known.
1473 Forge,
1474}
1475
1476/// Where the user moved a row in its queue.
1477#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1478#[serde(rename_all = "snake_case")]
1479pub enum RankMove {
1480 /// Above everything ranked.
1481 Top,
1482 /// One place toward the front.
1483 Up,
1484 /// One place toward the back.
1485 Down,
1486 /// Where a drag landed: the row this one should sit above in the queue,
1487 /// or nothing for the end of it.
1488 Before(Option<String>),
1489}
1490
1491/// One prompt waiting in the CLI's queue, as a view reads it.
1492///
1493/// The pile holds only prompts that are still waiting: an entry is appended
1494/// where the prompt is sent (the only place that knows its source and words)
1495/// and leaves on a lifecycle state the core NAMES as settled.
1496#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Debug)]
1497pub struct QueuedPrompt {
1498 /// The uuid the prompt was sent under, which its lifecycle frames carry.
1499 pub uuid: String,
1500 /// Who dispatched it.
1501 pub source: PromptSource,
1502 /// The prompt's words, as they were written.
1503 pub text: String,
1504 /// What the prompt carried besides its words.
1505 ///
1506 /// **Core-side only, never on the wire.** The row is drawn at the CLI's
1507 /// start, long after the command that carried these is gone, so the task
1508 /// keeps them here to build that frame; the queue a view reads is the
1509 /// three fields above, and a base64 payload has no business riding every
1510 /// seat record read.
1511 #[serde(skip)]
1512 pub attachments: Vec<forge_primitives::ImageAttachment>,
1513}
1514
1515/// How loudly a [`SessionUpdate::Notice`] reads.
1516///
1517/// Two levels rather than a scale: the core's own answer about a seat, and
1518/// the failures it owes the reader - a command that did not run, a session
1519/// that ended.
1520#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)]
1521#[serde(rename_all = "snake_case")]
1522pub enum NoticeSeverity {
1523 /// A command's own answer.
1524 Info,
1525 /// One that did not run.
1526 Error,
1527}
1528
1529/// Update envelope: forge-workspace -> a view.
1530///
1531/// Permission/Question variants do NOT carry response oneshots -
1532/// responses flow back via `Command::Respond*`. The workspace stores
1533/// the oneshot in `DomainSession.pending_interactions` when emitting
1534/// these variants.
1535///
1536/// `Clone` is what lets the fan-out hand one emit to more than one
1537/// subscriber, so every variant's payload must stay cloneable.
1538#[derive(Clone, Serialize, Deserialize)]
1539#[serde(rename_all = "snake_case")]
1540pub enum SessionUpdate {
1541 /// Workspace is spawning a session (in response to
1542 /// `Command::SpawnProject`). A view creates a placeholder
1543 /// under `key` and shows the "Waking {display_name}…" message. The
1544 /// matching `Connected` lands soon after, under the same key.
1545 Spawning {
1546 key: SessionSlot,
1547 project_name: String,
1548 cwd: String,
1549 display_name: String,
1550 },
1551 Connected {
1552 key: SessionSlot,
1553 session_id: SessionId,
1554 cwd: String,
1555 current_model: CurrentModel,
1556 available_models: Vec<AvailableModel>,
1557 mode: Option<ModeState>,
1558 history: Vec<Message>,
1559 /// Messages the read carried that `history`'s window left behind its
1560 /// front, which is the floor a page cursor below the window is
1561 /// measured against.
1562 ///
1563 /// **Carried because the window cannot say it.** The read is windowed
1564 /// before it crosses - a resume's whole transcript is gigabytes - so a
1565 /// consumer holding the window alone reads it as the conversation's
1566 /// front, and a walk below it would stop there instead of crossing
1567 /// into the transcript. Additive on the wire, and zero for a read the
1568 /// window holds whole.
1569 dropped: usize,
1570 /// Compactions the resumed transcript records. Seeds the
1571 /// per-session count, which has no other durable source.
1572 compaction_count: u32,
1573 },
1574 /// A call's own output, asked for by `Command::ReadCallOutput`: the tail
1575 /// of the file the CLI streamed the call's output to, or a named reason
1576 /// there is none.
1577 ///
1578 /// Emitted by the transport from its held copy of the seat's
1579 /// conversation, which carries the frame naming the call's `output_file`.
1580 CallOutput {
1581 key: SessionSlot,
1582 call_id: String,
1583 output: forge_primitives::CallOutput,
1584 },
1585 /// The slot's occupant changed under it - a `/new`, a `/resume`, a
1586 /// login or a logout. The slot keeps its bucket, in its place on
1587 /// screen; its contents reset, meaning the rendered conversation,
1588 /// the history, the per-session overrides and any waiter holding a
1589 /// tool call that is never coming back. `session_id` is the new
1590 /// occupant.
1591 SessionReplaced {
1592 key: SessionSlot,
1593 session_id: SessionId,
1594 cwd: String,
1595 current_model: CurrentModel,
1596 available_models: Vec<AvailableModel>,
1597 mode: Option<ModeState>,
1598 history: Vec<Message>,
1599 /// Messages the read carried that `history`'s window left behind its
1600 /// front: the same floor a `Connected` carries, for the same reason.
1601 dropped: usize,
1602 /// Compactions the resumed transcript records. Seeds the
1603 /// per-session count, which has no other durable source.
1604 compaction_count: u32,
1605 },
1606 /// The seat's occupant was replaced by a handoff rather than by a person
1607 /// asking for one, and this is the record of it - emitted before any
1608 /// frame from the new occupant, so the seat learns the swap before the
1609 /// successor's transcript starts. No view draws a row from it: the
1610 /// successor's own procedure row carries the handoff.
1611 Handoff {
1612 key: SessionSlot,
1613 handoff: forge_primitives::HandoffRecord,
1614 },
1615 /// The machine turn running on this seat changed: a handoff beginning or
1616 /// ending, and `None` for a seat taking input.
1617 ///
1618 /// **A record read is not pushed.** The record carries the same fact for a
1619 /// page that attaches mid-turn, but a page already open learns it here -
1620 /// and its composer stands down for exactly that span.
1621 MachineTurn {
1622 key: SessionSlot,
1623 machine: Option<forge_primitives::runtime::MachineTurn>,
1624 },
1625 ConnectionFailed {
1626 key: SessionSlot,
1627 message: String,
1628 fatal: bool,
1629 },
1630 /// The seat's release has begun: its session is being taken down, and a
1631 /// view draws the seat as going to sleep until the roster lands it asleep
1632 /// or gone.
1633 ///
1634 /// **Announced at the START of the release, not when the handles drop.**
1635 /// The teardown is what takes the seconds, so a viewer that hears only the
1636 /// end keeps the row reading as working through the whole gesture - and a
1637 /// close made from another view is heard by no one at all without this.
1638 Releasing {
1639 key: SessionSlot,
1640 },
1641 /// One line the core has for a view about `key`, which nothing else
1642 /// carries.
1643 ///
1644 /// A command answered where both views dispatch has no other way to say
1645 /// anything: the CLI emits no frame for a command it never saw, and the
1646 /// line is the core's own answer rather than a view's opinion - what a
1647 /// command found, or why it refused.
1648 ///
1649 /// **Live only, and that is intended.** The CLI writes no transcript row
1650 /// for a line it never produced, so a view draws this on arrival and a
1651 /// page that attaches afterwards has nothing to read it from. It is a
1652 /// statement about the moment a command ran, not a record of the
1653 /// conversation.
1654 Notice {
1655 key: SessionSlot,
1656 severity: NoticeSeverity,
1657 text: String,
1658 },
1659 /// The CLI refused (or failed) a `set_permission_mode` control
1660 /// request for `key`. `message` carries the underlying error text;
1661 /// the view rolls the optimistic mode chip back and surfaces it.
1662 SetModeFailed {
1663 key: SessionSlot,
1664 mode: PermissionMode,
1665 message: String,
1666 },
1667 /// The CLI refused (or failed) a `set_model` control request for
1668 /// `key`. `message` carries the underlying error text; the view
1669 /// rolls the optimistic model change back and surfaces it.
1670 SetModelFailed {
1671 key: SessionSlot,
1672 model: String,
1673 message: String,
1674 },
1675 /// Permission prompt. No response_tx - a view replies via
1676 /// `Command::RespondPermission { tool_id, outcome }`.
1677 PermissionRequest {
1678 key: SessionSlot,
1679 tool_id: String,
1680 request: PermissionRequest,
1681 },
1682 /// AskUserQuestion prompt. Same shape; reply via
1683 /// `Command::RespondQuestion { tool_id, outcome }`.
1684 QuestionRequest {
1685 key: SessionSlot,
1686 tool_id: String,
1687 request: QuestionRequest,
1688 },
1689 /// The interaction `tool_id` was answered, so every view holding the
1690 /// prompt can drop it. Answering leaves the pending set either way,
1691 /// and this is the only thing that says so on the stream: without it a
1692 /// second view keeps drawing a prompt that is already settled. A lone
1693 /// view never meets that, because it is the only one.
1694 ///
1695 /// **A question names its round as well as its call.** A batch reuses
1696 /// one tool call id and advances `question_index`, and the next round's
1697 /// request can land before this round's resolution - so a clear keyed
1698 /// on the id alone dropped the ask that had just parked, and every
1699 /// round after the first lost its opening question (Ved's live find;
1700 /// #1717). `None` on every interaction that is not a question, and on
1701 /// the frames of a core older than this field.
1702 PendingInteractionResolved {
1703 key: SessionSlot,
1704 tool_id: String,
1705 /// Absent from an older core's frames, so the default carries that.
1706 #[serde(default)]
1707 question_index: Option<u64>,
1708 /// The words the answer carried, where it carried any: the box's own
1709 /// notes, typed or dictated (#1360). `None` for a pick with no words,
1710 /// which is most of them.
1711 #[serde(default)]
1712 answered_with: Option<String>,
1713 },
1714 McpOperationError {
1715 key: SessionSlot,
1716 error: McpOperationError,
1717 },
1718 TurnComplete {
1719 key: SessionSlot,
1720 terminal_reason: Option<TerminalReason>,
1721 },
1722 TurnCancelled {
1723 key: SessionSlot,
1724 },
1725 TurnError {
1726 key: SessionSlot,
1727 message: String,
1728 /// The failure's class, where the producer that built the event could
1729 /// name one. **`None` is "not classified here" rather than "not
1730 /// auth"**: a producer that never classified and an auth failure are
1731 /// different facts, and a reader that folds them together holds a
1732 /// session on `/login` for an error that was never about login.
1733 class: Option<TurnErrorClass>,
1734 terminal_reason: Option<TerminalReason>,
1735 },
1736 ChatAppended {
1737 key: SessionSlot,
1738 msg: Message,
1739 /// Who dispatched the prompt this frame writes as a user turn: the
1740 /// same [`PromptSource`] the queue's own row carries.
1741 ///
1742 /// **This is the arm a reader's follow reads**, because the words'
1743 /// shape cannot say it: forge's own machinery - an auto-continue, a
1744 /// worker kick, a board notice - forges prose as the reader's own row
1745 /// on this frame, and arming for one carries a parked reader to the
1746 /// foot for words that are nobody's (#2198). Only `you` is a person's
1747 /// send.
1748 ///
1749 /// `None` on every frame the CLI sent; the turns a delivery forges
1750 /// carry the delivery's own kind, since their envelope row is not the
1751 /// reader's either.
1752 #[serde(default)]
1753 source: Option<PromptSource>,
1754 },
1755 HookObservation {
1756 key: SessionSlot,
1757 tool_use_id: Option<String>,
1758 permission_mode: Option<String>,
1759 effort: Option<String>,
1760 agent_id: Option<String>,
1761 agent_type: Option<String>,
1762 },
1763 ForgeAccountIdentity {
1764 key: SessionSlot,
1765 display_name: String,
1766 },
1767 /// The full override set a session holds after a `/dictate` edit
1768 /// landed. Sent after every `SetDictateOverride` and
1769 /// `ResetDictateOverrides` so the dialog's markers and its reset
1770 /// row read from this, not from a view-side copy.
1771 DictateOverrides {
1772 key: SessionSlot,
1773 overrides: crate::dictate::DictateOverrides,
1774 },
1775 ContextUsageSnapshot {
1776 key: SessionSlot,
1777 percentage: Option<u8>,
1778 /// Raw model context-window size in tokens (e.g. `1_000_000`
1779 /// for Opus 1M). `None` until the upstream probe reports it.
1780 max_tokens: Option<u64>,
1781 },
1782 McpSnapshot {
1783 key: SessionSlot,
1784 servers: Vec<McpServerStatus>,
1785 error: Option<String>,
1786 },
1787 /// The seat's process tree moved, as the whole walk the core holds.
1788 ///
1789 /// **Pushed rather than read.** The walk is a `sysinfo` refresh; the
1790 /// core runs it once a second for every seat a view is showing, and this
1791 /// carries the answer to whoever is drawing it. Emitted only when the
1792 /// tree's own entries moved - a walk that found the same processes says
1793 /// nothing.
1794 ProcessesChanged {
1795 key: SessionSlot,
1796 snapshot: forge_agent::env::processes::ProcessSnapshot,
1797 },
1798 /// The seat's monitors moved, as the whole set the core holds.
1799 ///
1800 /// **Pushed rather than read.** The set changes on discrete task frames -
1801 /// a `Monitor` call entering, a task stamping its id, a frame settling
1802 /// one, the drain that empties it - and never per output chunk, so the
1803 /// frame that moved it announces it and a frame that moved nothing says
1804 /// nothing.
1805 MonitorsChanged {
1806 key: SessionSlot,
1807 monitors: Vec<forge_primitives::MonitorRecord>,
1808 },
1809 /// The seat's live task set moved, as the whole set.
1810 ///
1811 /// Both halves move it: the CLI announces its background registry whole on
1812 /// each change, and a long foreground call enters on its `task_started`
1813 /// and leaves on its `task_notification`, a terminal `task_updated`, or
1814 /// the end of the turn that ran it. So this mirrors a task entering,
1815 /// leaving or being cleared by a dead connection.
1816 BackgroundTasksChanged {
1817 key: SessionSlot,
1818 tasks: Vec<crate::BackgroundTask>,
1819 },
1820 /// The seat's working tree moved, as the row a view draws it.
1821 ///
1822 /// **Pushed rather than read.** The three fields are the record's own -
1823 /// the branch and count, the open PR on the pushed sha, and the issues it
1824 /// closes - so a client applies them to the fields it already narrows. A
1825 /// seat nobody is showing is not scanned, so this is only sent for a seat
1826 /// somebody holds.
1827 WorkChanged {
1828 key: SessionSlot,
1829 work: crate::work::WorkState,
1830 /// The tree behind the row's depth: the uncommitted files with
1831 /// their marks and counts, the branch's chain ahead of its
1832 /// default, and which branch that is.
1833 git: forge_primitives::git_diff::GitWorkView,
1834 pr: Option<forge_primitives::git::GitPrInfo>,
1835 closes: Vec<forge_primitives::git::GitIssueRef>,
1836 },
1837 /// The project's task list moved, as the whole set the core holds.
1838 ///
1839 /// **Pushed rather than read.** The `tasks__*` writes are the only movers
1840 /// and the section a view draws sits on the board's project row - keyed by
1841 /// the project's lead seat, which exists for every declared project - so
1842 /// the write announces the set rather than leaving the row to the next
1843 /// unrelated read.
1844 TasksChanged {
1845 key: SessionSlot,
1846 tasks: Vec<forge_primitives::tasks::Task>,
1847 },
1848 /// The project's schedules moved, as the whole set the core holds.
1849 ///
1850 /// **Pushed rather than read.** Three doors move it - the `cron__*`
1851 /// writes, a fire that advances or removes an entry, and a worker's
1852 /// teardown - and the section draws the countdown, so each announces the
1853 /// set it left. `key` is the project's lead seat.
1854 CronSchedulesChanged {
1855 key: SessionSlot,
1856 crons: Vec<forge_primitives::CronEntry>,
1857 },
1858 /// The project's inbound connector subscriptions moved, as the two sets
1859 /// the core holds.
1860 ///
1861 /// **Pushed rather than read.** The `gotify__*` / `slack__*` writes move
1862 /// them, and so do the doors no tool call reaches: a mention's
1863 /// auto-subscribe, a watched conversation's name healing on its first
1864 /// message, and a worker's teardown taking its own with it. One update
1865 /// carries both connectors because they are one section of the board's
1866 /// project row, and a reader that patches it needs the pair. `key` is the
1867 /// project's lead seat.
1868 ConnectorSubscriptionsChanged {
1869 key: SessionSlot,
1870 gotify: Vec<forge_primitives::GotifySubscription>,
1871 slack: Vec<forge_primitives::slack::SlackSubscription>,
1872 },
1873 /// The `/` menu's catalogue moved, as the whole list the core holds.
1874 ///
1875 /// **Pushed rather than read.** The CLI advertises it on a turn's init and
1876 /// re-sends it when a plugin changes it (`commands_changed`), so the frame
1877 /// that moved it announces it and a turn's init repeating the same list
1878 /// says nothing.
1879 SlashCommandsChanged {
1880 key: SessionSlot,
1881 commands: Vec<forge_primitives::runtime::AvailableCommand>,
1882 },
1883 /// The agent-type catalogue moved, as the whole list the core holds.
1884 ///
1885 /// **Pushed rather than read.** The CLI advertises it on a turn's init,
1886 /// once per turn, so this is that one frame - and an init that repeats the
1887 /// list the last turn advertised says nothing.
1888 SubagentsChanged {
1889 key: SessionSlot,
1890 subagents: Vec<forge_primitives::runtime::AvailableAgent>,
1891 },
1892 /// The session's sub-agent instances moved, as the whole list the core
1893 /// folds from the frames it has seen.
1894 ///
1895 /// **Pushed rather than read.** The join lives in
1896 /// `forge_workspace::subagent_cards` and folds a frame at a time, so a
1897 /// live session announces the list it now holds rather than re-walking
1898 /// its conversation per frame. A client draws the instances from this -
1899 /// the raw frames stay on the conversation for whoever folds something
1900 /// else - and a page attaching later reads the same list off the record.
1901 SubagentCardsChanged {
1902 key: SessionSlot,
1903 cards: Vec<forge_primitives::runtime::SubagentCard>,
1904 },
1905 /// The tree's file index moved, as the whole index the core holds.
1906 ///
1907 /// **Pushed rather than read, and throttled here rather than by the
1908 /// socket.** The socket's batch folds only consecutive token appends, so
1909 /// a burst of index frames would go out one per change: the walk runs at
1910 /// most once per poke while the watch says the tree is moving - so a
1911 /// build's writes inside one poke become one walk - and once per
1912 /// `INDEX_STALENESS` when it is still. The frame goes out only when the
1913 /// index actually differs from the one last announced.
1914 FileIndexChanged {
1915 key: SessionSlot,
1916 index: std::sync::Arc<crate::file_index::FileIndex>,
1917 },
1918 ServiceStatus {
1919 severity: ServiceSeverity,
1920 message: String,
1921 },
1922 /// The background catalog scan finished and `Workspace`'s project
1923 /// catalog is populated. A view reads the roster itself, so nothing
1924 /// needs carrying: the event exists to wake the render loop so
1925 /// session counts appear when the scan lands rather than on the next
1926 /// unrelated frame.
1927 CatalogLoaded,
1928 /// A claude version probe landed and moved what the views draw. The
1929 /// snapshot itself is not carried, so this is only the wake-up: a view
1930 /// re-reads through the surface's `cli_version` verb.
1931 CliVersionChanged,
1932 /// The account pool moved: an account's loading state, its last probe
1933 /// failure, its cached usage snapshot, or the gateway listener's
1934 /// readiness.
1935 ///
1936 /// **The frame carries the pool's drawn state**, flattened so its fields
1937 /// are the ones `HomeWire::accounts` states - the same values, built by
1938 /// [`crate::Workspace::account_pool_view`], so a client that folds the frame
1939 /// and a client that reads the snapshot cannot disagree (rule 25). That
1940 /// is what lets a usage move reach the badge without a home read.
1941 ///
1942 /// It carries no seat because the pool belongs to none, which is also
1943 /// what routes it to a home subscriber and to nobody else.
1944 AccountsChanged {
1945 #[serde(flatten)]
1946 accounts: crate::views::AccountPoolView,
1947 },
1948 PluginsInventoryUpdated {
1949 cwd_raw: String,
1950 snapshot: PluginsInventorySnapshot,
1951 claude_path: PathBuf,
1952 },
1953 PluginsInventoryRefreshFailed {
1954 cwd_raw: String,
1955 message: String,
1956 /// Whether the failed refresh served a boot auto-update run -
1957 /// app-scoped, so its failure bypasses the cwd gate.
1958 trigger: PluginUpdateTrigger,
1959 },
1960 PluginsCliActionSucceeded {
1961 cwd_raw: String,
1962 result: PluginsCliActionSuccess,
1963 /// The dispatch this outcome answers, when the press that made it
1964 /// named one - `None` for an internal dispatch no press owns.
1965 dispatch_id: Option<u64>,
1966 },
1967 PluginsCliActionFailed {
1968 cwd_raw: String,
1969 message: String,
1970 /// As on the succeeded frame above: the dispatch this failure
1971 /// answers, when the press that made it named one.
1972 dispatch_id: Option<u64>,
1973 },
1974 /// A section-level update run or check moved: one or more rows
1975 /// changed state. Carries the whole run so the pane replaces its
1976 /// copy wholesale.
1977 PluginsUpdateRunProgress {
1978 cwd_raw: String,
1979 run: PluginUpdateRun,
1980 },
1981 /// The run (or check) is over. The record batch is persisted by
1982 /// the run task itself; this event is reporting only.
1983 PluginsUpdateRunFinished {
1984 cwd_raw: String,
1985 run: PluginUpdateRun,
1986 snapshot: Option<PluginsInventorySnapshot>,
1987 claude_path: Option<PathBuf>,
1988 },
1989 PluginsRollbackSucceeded {
1990 cwd_raw: String,
1991 plugin_id: String,
1992 scope: String,
1993 message: String,
1994 snapshot: PluginsInventorySnapshot,
1995 claude_path: PathBuf,
1996 },
1997 PluginsRollbackFailed {
1998 cwd_raw: String,
1999 plugin_id: String,
2000 message: String,
2001 /// The refreshed inventory when the rollback ran but did not
2002 /// verify, so the pane still reflects the real state.
2003 snapshot: Option<PluginsInventorySnapshot>,
2004 },
2005 /// Workspace pushed a change to `live_workers[project_key]`. A
2006 /// view's reducer updates the projects pane's tree-children based
2007 /// on `action`. `status` is the snapshot at the moment of the
2008 /// change (relevant for Added and StatusChanged; ignored for
2009 /// Removed but carried for symmetry). `worktree` is what has
2010 /// become of the worker's worktree, read on `Removed` events
2011 /// (after which the `WorkerEntry` is gone from `live_workers`, so a
2012 /// lookup-by-label would fail).
2013 WorkerStatusChanged {
2014 project_key: crate::ProjectKey,
2015 action: WorkerStatusAction,
2016 status: forge_primitives::WorkerStatus,
2017 worktree: WorktreeDisposition,
2018 },
2019 /// A peer-coordination envelope arrived at `key`.
2020 /// Carries the typed `WrappedPrompt` so a view's reducer can build
2021 /// the chat-side echo from real fields instead of having the
2022 /// workspace forge a `Message::User` carrying prose for a view to
2023 /// re-parse (audit I11). The recipient's LLM still receives the
2024 /// prose via a separate `Command::Prompt` dispatch - that's the
2025 /// CLI's input channel and stays text-shaped.
2026 PeerEnvelopeAppended {
2027 key: SessionSlot,
2028 wrapped: crate::mcp::peers::types::WrappedPrompt,
2029 /// The id the delivered prompt was dispatched under, which the
2030 /// drawn row carries: a view holds the row while that prompt waits in
2031 /// the pile and draws it when it starts.
2032 uuid: String,
2033 },
2034 /// A matched Gotify notification arrived at `key`.
2035 /// Carries the typed `GotifyNotification` so a view's reducer builds
2036 /// the chat-side echo from real fields (mirrors PeerEnvelopeAppended).
2037 /// The session's LLM receives the same prose via a separate
2038 /// `Command::Prompt` - this update only drives the visible echo.
2039 GotifyNotificationAppended {
2040 key: SessionSlot,
2041 notification: crate::mcp::gotify::types::GotifyNotification,
2042 /// The id the delivered prompt was dispatched under, which the drawn
2043 /// row carries - see `PeerEnvelopeAppended`.
2044 uuid: String,
2045 },
2046 /// A due cron fired into `key`. Carries the fired
2047 /// prompt text so a view's reducer builds the chat-side echo (mirrors
2048 /// GotifyNotificationAppended). The session's LLM receives the same
2049 /// text via a separate `Command::Prompt` - this update only drives
2050 /// the visible echo.
2051 CronPromptAppended {
2052 key: SessionSlot,
2053 text: String,
2054 /// The id the delivered prompt was dispatched under, which the drawn
2055 /// row carries - see `PeerEnvelopeAppended`.
2056 uuid: String,
2057 /// The cron entry that fired, so a view can name the schedule the
2058 /// prompt came from - the entry is gone from the store by the time
2059 /// a run-once fire is drawn.
2060 cron_id: String,
2061 /// The entry's human summary; `None` when none was registered, and
2062 /// the row falls back to the prompt's first line.
2063 description: Option<String>,
2064 },
2065 /// A matched Slack message arrived at `key`. Carries the
2066 /// prose rather than the typed message: the prose builder is `pub(crate)`
2067 /// to forge-workspace, so a typed message would force a view to rebuild
2068 /// the very format it parses. The session's LLM receives the same prose
2069 /// via a separate `Command::Prompt` - this update only drives the visible
2070 /// echo (mirrors GotifyNotificationAppended).
2071 SlackMessageAppended {
2072 key: SessionSlot,
2073 prose: String,
2074 /// The id the delivered prompt was dispatched under, which the drawn
2075 /// row carries - see `PeerEnvelopeAppended`.
2076 uuid: String,
2077 },
2078 /// A composed Slack message is waiting for the user's decision in the
2079 /// dock prompt. The authoring tool handler is blocked on a oneshot
2080 /// until `Command::RespondSlackPost` answers it, so nothing posts
2081 /// while this is outstanding.
2082 SlackPostPending {
2083 key: SessionSlot,
2084 draft: forge_primitives::slack::SlackDraft,
2085 },
2086 /// A held Slack draft left the core's registry: answered in some view,
2087 /// or its asking session gone. Each view keeps its own copy of the
2088 /// parked draft, so this is the only update that clears it - and
2089 /// `ending` is what a view that did not answer it says happened.
2090 SlackDraftResolved {
2091 key: SessionSlot,
2092 id: Uuid,
2093 ending: forge_primitives::slack::SlackDraftEnding,
2094 },
2095 /// A browser hand-off waits for the person at a client: the session asked
2096 /// them to act in a browser tab, and its tool handler is blocked on a
2097 /// oneshot until `Command::RespondBrowserHandOff` answers it.
2098 ///
2099 /// **No timeout rides this one.** The ask dock's own timelines say a held
2100 /// prompt sits for hours, and the session is meant to wait as long as it
2101 /// takes; the handler's drop, not a clock, is what ends the wait.
2102 BrowserHandOffPending {
2103 key: SessionSlot,
2104 handoff: forge_primitives::browser::HandOff,
2105 },
2106 /// A browser hand-off left the core's registry: answered in some view, or
2107 /// its asking session gone. `answer` is the ending, the door that sent it
2108 /// and the connection that carried it - what a view that did not answer it
2109 /// reads, and what the log names.
2110 BrowserHandOffResolved {
2111 key: SessionSlot,
2112 id: Uuid,
2113 answer: forge_primitives::browser::HandOffAnswer,
2114 },
2115 /// A terminal hand-off waits for the person at a client: the session asked
2116 /// them to type into the seat's terminal, and its tool handler is blocked
2117 /// on a oneshot until `Command::RespondTerminalHandOff` answers it.
2118 ///
2119 /// **No timeout rides this one**, and the agent is blind while it is
2120 /// parked: `terminal__read` and `terminal__write` are refused.
2121 TerminalHandOffPending {
2122 key: SessionSlot,
2123 handoff: forge_primitives::terminal::TerminalHandOff,
2124 },
2125 /// A terminal hand-off left the core's registry: answered in some view, or
2126 /// its asking session gone. `answer` is the ending, the door that sent it
2127 /// and the connection that carried it.
2128 TerminalHandOffResolved {
2129 key: SessionSlot,
2130 id: Uuid,
2131 answer: forge_primitives::terminal::TerminalHandOffAnswer,
2132 },
2133 /// A seat's terminal opened, changed or closed, as the strip and the
2134 /// session record read it. `None` means the terminal is gone.
2135 TerminalChanged {
2136 key: SessionSlot,
2137 terminal: Option<forge_primitives::terminal::TerminalSummary>,
2138 },
2139 /// A prompt entered the CLI's queue: the row a view draws as waiting,
2140 /// with the words and the sender the lifecycle frames do not carry.
2141 ///
2142 /// Emitted where the prompt is dispatched, keyed by the uuid it was sent
2143 /// under - so a view keys its own optimistic row to this one by id rather
2144 /// than by order or text.
2145 PromptQueued {
2146 key: SessionSlot,
2147 uuid: String,
2148 source: PromptSource,
2149 text: String,
2150 },
2151 /// Where one prompt is in the CLI's queue, as the CLI itself reports it:
2152 /// `queued`, `started`, `completed` and the terminal states
2153 /// (`cancelled`, `discarded`, `refused`). Keyed by the uuid the prompt was
2154 /// sent under, which is what lets a view key its queued row to its own
2155 /// send by id rather than by order or text.
2156 ///
2157 /// `state` stays a free-form string so a state this build has not seen
2158 /// reaches a view rather than being dropped.
2159 PromptLifecycle {
2160 key: SessionSlot,
2161 uuid: String,
2162 state: String,
2163 },
2164 /// The CLI answered a `cancel_async_message`: `cancelled: false` means the
2165 /// prompt was already dequeued for execution, so a reader that asked to
2166 /// drop it has to say it is on its way instead.
2167 PromptCancelResolved {
2168 key: SessionSlot,
2169 uuid: String,
2170 cancelled: bool,
2171 },
2172 /// A worker's review turn addressed review comments; `key` is the
2173 /// session that authored the review (the submit origin). A view drops
2174 /// `message` as a system line into that session's chat so the reviewer
2175 /// sees the batched tally, and parks `waiting` - how many threads on
2176 /// `branch` now await a reviewer turn - as the persistent signal both
2177 /// the Inspector GIT badge and the NEEDS ATTENTION band read.
2178 ReviewActivityNotice {
2179 key: SessionSlot,
2180 branch: String,
2181 waiting: usize,
2182 message: String,
2183 },
2184 /// Dictation models are loaded and the composer may offer to
2185 /// dictate. App-global (no key): the engine is process-wide and
2186 /// every session's composer shares the availability; the event's
2187 /// existence is the signal. Never emitted when `[dictate]` is
2188 /// disabled, so sessions that cannot dictate render nothing.
2189 DictateAvailability,
2190 /// A recording started for the composer at `key`. `floor_db` is the
2191 /// silence floor the level meter maps onto its zero glyph, so the
2192 /// bar and the `NoAudio` verdict agree by construction. `generation`
2193 /// identifies this take among the key's takes: a resolver that
2194 /// arrives after a newer take started carries a stale one, and the
2195 /// composer resets on its own generation only.
2196 ///
2197 /// `initiator` names the connection the take belongs to, as its
2198 /// `dictate_stream` carried it: every `Dictate*` update about a take
2199 /// goes to that connection alone, so no other subscriber draws its
2200 /// meter, its phases or its words. `None` is this process's own
2201 /// take.
2202 DictateStarted {
2203 key: SessionSlot,
2204 floor_db: f32,
2205 generation: u64,
2206 #[serde(skip)]
2207 initiator: Option<u64>,
2208 },
2209 /// One level reading for the recording at `key`: the peak over the
2210 /// window since the previous reading, in dBFS. Emitted on the
2211 /// meter clock, not the repaint clock.
2212 ///
2213 /// **A window with no signal reads as `null` on the wire, and `null`
2214 /// reads back as negative infinity.** A peak that heard nothing is
2215 /// exactly zero, whose dBFS is negative infinity, and serde writes a
2216 /// non-finite float as `null` - so a reader that takes the field as an
2217 /// `f32` cannot decode the message at all, which is the state this
2218 /// tolerance exists to close. Nothing about the value a view draws
2219 /// changes: the client narrows `null` itself, and this only lets a
2220 /// Rust reader do the same.
2221 DictateLevel {
2222 key: SessionSlot,
2223 #[serde(default, deserialize_with = "no_signal_reads_as_negative_infinity")]
2224 peak_db: f32,
2225 /// The take's own, as [`SessionUpdate::DictateStarted`] announced it:
2226 /// a reading that arrives after a newer take started is the older
2227 /// one's and belongs in no live meter.
2228 generation: u64,
2229 /// The take's own, as [`SessionUpdate::DictateStarted`] announced
2230 /// it.
2231 #[serde(skip)]
2232 initiator: Option<u64>,
2233 },
2234 /// The take from `key` was submitted and a transcript is in flight.
2235 DictateTranscribing {
2236 key: SessionSlot,
2237 /// The take's own, as [`SessionUpdate::DictateStarted`] announced it:
2238 /// a stale one must not move a newer take to `transcribing` while its
2239 /// microphone still records.
2240 generation: u64,
2241 /// The take's own, as [`SessionUpdate::DictateStarted`] announced
2242 /// it.
2243 #[serde(skip)]
2244 initiator: Option<u64>,
2245 },
2246 /// A take from `key` has settled `done` segments of its
2247 /// transcription. `total` is `None` while the recording is still
2248 /// open, because a live take cannot know how many segments it will
2249 /// produce, and the final count once the take was stopped and its
2250 /// tail submitted. Single-segment takes report at stop only; a
2251 /// composer renders only what it wants to. `generation` is the
2252 /// take's own, as handed out by [`SessionUpdate::DictateStarted`].
2253 DictateProgress {
2254 key: SessionSlot,
2255 generation: u64,
2256 done: usize,
2257 total: Option<usize>,
2258 /// The take's own, as [`SessionUpdate::DictateStarted`] announced
2259 /// it.
2260 #[serde(skip)]
2261 initiator: Option<u64>,
2262 },
2263 /// A take from `key` is done: insert, notice or reset per
2264 /// [`DictateOutcome`]. `generation` is the take's own, as handed
2265 /// out by [`SessionUpdate::DictateStarted`].
2266 DictateEnded {
2267 key: SessionSlot,
2268 outcome: DictateOutcome,
2269 generation: u64,
2270 /// The surface whose own capture began the take, as
2271 /// [`Command::DictateStream`] carried it: the landed words are that
2272 /// surface's to place, and no other's. `None` for a take
2273 /// begun in process or by a client older than the field.
2274 #[serde(default)]
2275 surface: Option<String>,
2276 /// The take's own - and for a refusal, the connection that was
2277 /// refused, so only it learns why. `None` is this process's own.
2278 #[serde(skip)]
2279 initiator: Option<u64>,
2280 },
2281 /// The core's fatal: the app is going away. `message` is what a
2282 /// view draws on exit - `AppError::user_message()` - so a view that
2283 /// draws the line words it the same way without a second copy.
2284 FatalError {
2285 error: AppError,
2286 message: String,
2287 },
2288 /// The catalogue check landed: the models page's whole view, re-read.
2289 /// Carries no slot - the feed belongs to the machine, not a seat - and
2290 /// a session page ignores it. Sentinel rows the page draws (the
2291 /// check's freshness, every candidate row, the per-model updates) are
2292 /// all here, so a subscriber that was already attached never has to
2293 /// ask again for what this frame just changed.
2294 DictateModelsChanged {
2295 models: crate::catalogue::DictateModelsSnapshot,
2296 },
2297 /// A session handed the reader a document: the ask a client draws -
2298 /// **open** or **not now** - with the resolved path it opens at.
2299 ///
2300 /// **The call parks on this**, like the browser hand-off: `id` names the
2301 /// parked call, and the reader's answer releases it - `NotNow` here, or
2302 /// `Approve` / `Feedback` when the viewer's finish files the reading.
2303 /// `path` is the
2304 /// resolved absolute path, the viewer's stable address; `reviewable` is
2305 /// what the hand-off asked for - the ask's own fact rather than the
2306 /// document's, because a readable document offers its review either way.
2307 DocumentHandOffPending {
2308 key: SessionSlot,
2309 id: Uuid,
2310 path: String,
2311 reviewable: bool,
2312 },
2313 /// A document hand-off left the core's registry: finished in some view,
2314 /// declined, or its asking session gone. `ending` is what a view that
2315 /// did not answer it says happened.
2316 DocumentHandOffResolved {
2317 key: SessionSlot,
2318 id: Uuid,
2319 ending: forge_primitives::document::DocumentHandOffEnding,
2320 },
2321 /// A configuration write waits for the reader's approval in the dock:
2322 /// the session's `settings__set` / `settings__unset` handler is blocked
2323 /// on a oneshot until `Command::RespondSettingsWrite` answers it, so
2324 /// nothing reaches `forge.toml` while this is outstanding.
2325 SettingsWritePending {
2326 key: SessionSlot,
2327 ask: forge_primitives::settings::SettingsWriteAsk,
2328 },
2329 /// A held configuration write left the core's registry: answered in some
2330 /// view, or its asking session gone. `ending` is what a view that did
2331 /// not answer it says happened.
2332 SettingsWriteResolved {
2333 key: SessionSlot,
2334 id: Uuid,
2335 ending: forge_primitives::settings::SettingsWriteEnding,
2336 },
2337 /// A vault call waits for the reader's approval in the dock: the family's
2338 /// tool handler is blocked on a oneshot until `Command::RespondVaultAsk`
2339 /// answers it, so no operation runs in the vault while this is outstanding.
2340 /// `needs_unlock` on the draft is stamped from the live session as the
2341 /// card is built, so a sibling that unlocked first re-bases this one.
2342 VaultAskPending {
2343 key: SessionSlot,
2344 draft: forge_primitives::vault::VaultDraft,
2345 },
2346 /// A held vault call left the core's registry: answered in some view, or
2347 /// its asking session gone. `ending` is what a view that did not answer
2348 /// it says happened; the tool result carries the operation's own outcome.
2349 VaultAskResolved {
2350 key: SessionSlot,
2351 id: Uuid,
2352 ending: forge_primitives::vault::VaultAskEnding,
2353 },
2354 /// The device enrolments moved, as the whole set the core holds. Pushed
2355 /// rather than read: an enrol, a release's stamp and a revoke each leave a
2356 /// set the settings page draws, and the card reads the same set - so a
2357 /// second client redraws without asking. No wrapped blob rides here; see
2358 /// [`forge_primitives::biometric::BiometricEnrolment`].
2359 BiometricEnrolmentsChanged {
2360 enrolments: Vec<forge_primitives::biometric::BiometricEnrolment>,
2361 },
2362 /// The shared vault session rotated its refresh token, so the copy an
2363 /// enrolment blob holds is spent. A client that holds the material from
2364 /// its last release rewrites its blob with this one (decision 15); one
2365 /// that cannot falls back to the password on its next start.
2366 ///
2367 /// **Slot-less**: the session is process-wide, and only the client that
2368 /// owns the enrolment acts on it. The token is a secret on the socket -
2369 /// it never reaches a transcript or the live fold, and the manual `Debug`
2370 /// redacts it.
2371 VaultRotated {
2372 refresh_token: String,
2373 },
2374 /// The shared vault session locked or unlocked outside a held card - the
2375 /// idle bound passed, a refresh or a decryption failed, or an unlock
2376 /// landed. Slot-less like the rotation: the session is process-wide, and
2377 /// the settings page's vault card is what redraws.
2378 VaultLockChanged {
2379 locked: bool,
2380 },
2381 /// One rendered clip of the seat's speech, mono 16-bit samples at
2382 /// `sample_rate`. Chunks arrive in `seq` order and every client with
2383 /// voice on for the seat plays them; a client with voice off receives
2384 /// them and stays silent.
2385 VoiceAudio {
2386 key: SessionSlot,
2387 seq: u32,
2388 sample_rate: u32,
2389 pcm: Vec<i16>,
2390 },
2391 /// The seat's voice state: how many clients are listening, which is what
2392 /// the control draws and what a listener note is injected on.
2393 VoiceModeState {
2394 key: SessionSlot,
2395 listeners: u32,
2396 },
2397}
2398
2399impl SessionUpdate {
2400 /// The [`SessionSlot`] this update routes to, or `None` for
2401 /// updates that target App-level state (`ServiceStatus`, catalog,
2402 /// plugin, fatal-error). Every identity-carrying variant names a
2403 /// slot, so nothing is synthesized here.
2404 pub fn slot(&self) -> Option<&SessionSlot> {
2405 match self {
2406 Self::Spawning { key, .. }
2407 | Self::Connected { key, .. }
2408 | Self::CallOutput { key, .. }
2409 | Self::SessionReplaced { key, .. }
2410 | Self::Handoff { key, .. }
2411 | Self::MachineTurn { key, .. }
2412 | Self::ConnectionFailed { key, .. }
2413 | Self::Releasing { key }
2414 | Self::Notice { key, .. }
2415 | Self::SetModeFailed { key, .. }
2416 | Self::SetModelFailed { key, .. }
2417 | Self::PermissionRequest { key, .. }
2418 | Self::QuestionRequest { key, .. }
2419 | Self::PendingInteractionResolved { key, .. }
2420 | Self::McpOperationError { key, .. }
2421 | Self::TurnComplete { key, .. }
2422 | Self::TurnCancelled { key }
2423 | Self::TurnError { key, .. }
2424 | Self::ForgeAccountIdentity { key, .. }
2425 | Self::DictateOverrides { key, .. }
2426 | Self::ReviewActivityNotice { key, .. }
2427 | Self::DictateStarted { key, .. }
2428 | Self::DictateLevel { key, .. }
2429 | Self::DictateTranscribing { key, .. }
2430 | Self::DictateProgress { key, .. }
2431 | Self::PromptQueued { key, .. }
2432 | Self::PromptLifecycle { key, .. }
2433 | Self::PromptCancelResolved { key, .. }
2434 | Self::DictateEnded { key, .. }
2435 | Self::SlackPostPending { key, .. }
2436 | Self::SlackDraftResolved { key, .. }
2437 | Self::BrowserHandOffPending { key, .. }
2438 | Self::BrowserHandOffResolved { key, .. }
2439 | Self::TerminalHandOffPending { key, .. }
2440 | Self::TerminalHandOffResolved { key, .. }
2441 | Self::TerminalChanged { key, .. }
2442 | Self::DocumentHandOffPending { key, .. }
2443 | Self::DocumentHandOffResolved { key, .. }
2444 | Self::SettingsWritePending { key, .. }
2445 | Self::SettingsWriteResolved { key, .. }
2446 | Self::VaultAskPending { key, .. }
2447 | Self::VaultAskResolved { key, .. }
2448 | Self::VoiceAudio { key, .. }
2449 | Self::VoiceModeState { key, .. }
2450 | Self::ChatAppended { key, .. }
2451 | Self::HookObservation { key, .. }
2452 | Self::ContextUsageSnapshot { key, .. }
2453 | Self::McpSnapshot { key, .. }
2454 | Self::WorkChanged { key, .. }
2455 | Self::TasksChanged { key, .. }
2456 | Self::CronSchedulesChanged { key, .. }
2457 | Self::ConnectorSubscriptionsChanged { key, .. }
2458 | Self::SlashCommandsChanged { key, .. }
2459 | Self::SubagentsChanged { key, .. }
2460 | Self::SubagentCardsChanged { key, .. }
2461 | Self::FileIndexChanged { key, .. }
2462 | Self::ProcessesChanged { key, .. }
2463 | Self::MonitorsChanged { key, .. }
2464 | Self::BackgroundTasksChanged { key, .. }
2465 | Self::PeerEnvelopeAppended { key, .. }
2466 | Self::GotifyNotificationAppended { key, .. }
2467 | Self::CronPromptAppended { key, .. }
2468 | Self::SlackMessageAppended { key, .. } => Some(key),
2469 Self::ServiceStatus { .. }
2470 | Self::CatalogLoaded
2471 | Self::CliVersionChanged
2472 | Self::AccountsChanged { .. }
2473 | Self::PluginsInventoryUpdated { .. }
2474 | Self::PluginsInventoryRefreshFailed { .. }
2475 | Self::PluginsCliActionSucceeded { .. }
2476 | Self::PluginsCliActionFailed { .. }
2477 | Self::PluginsUpdateRunProgress { .. }
2478 | Self::PluginsUpdateRunFinished { .. }
2479 | Self::PluginsRollbackSucceeded { .. }
2480 | Self::PluginsRollbackFailed { .. }
2481 | Self::WorkerStatusChanged { .. }
2482 | Self::DictateAvailability { .. }
2483 | Self::FatalError { .. }
2484 | Self::DictateModelsChanged { .. }
2485 | Self::BiometricEnrolmentsChanged { .. }
2486 | Self::VaultRotated { .. }
2487 | Self::VaultLockChanged { .. } => None,
2488 }
2489 }
2490}
2491
2492impl std::fmt::Debug for SessionUpdate {
2493 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2494 // Custom Debug - discriminant + key/session_id only.
2495 // Payloads can be large or non-Debug-friendly; the
2496 // discriminant + routing key is sufficient for trace logs.
2497 match self {
2498 Self::Spawning { key, project_name, .. } => f
2499 .debug_struct("Spawning")
2500 .field("key", key)
2501 .field("project_name", project_name)
2502 .finish_non_exhaustive(),
2503 Self::Connected { key, .. } => {
2504 f.debug_struct("Connected").field("key", key).finish_non_exhaustive()
2505 }
2506 Self::CallOutput { key, call_id, .. } => f
2507 .debug_struct("CallOutput")
2508 .field("key", key)
2509 .field("call_id", call_id)
2510 .finish_non_exhaustive(),
2511 Self::SessionReplaced { key, .. } => {
2512 f.debug_struct("SessionReplaced").field("key", key).finish_non_exhaustive()
2513 }
2514 Self::Handoff { key, handoff } => f
2515 .debug_struct("Handoff")
2516 .field("key", key)
2517 .field("pair", &handoff.pair())
2518 .field("reason", &handoff.reason)
2519 .finish_non_exhaustive(),
2520 Self::MachineTurn { key, machine } => f
2521 .debug_struct("MachineTurn")
2522 .field("key", key)
2523 .field("machine", machine)
2524 .finish_non_exhaustive(),
2525 Self::ConnectionFailed { key, .. } => {
2526 f.debug_struct("ConnectionFailed").field("key", key).finish_non_exhaustive()
2527 }
2528 Self::Releasing { key } => {
2529 f.debug_struct("Releasing").field("key", key).finish_non_exhaustive()
2530 }
2531 Self::Notice { key, .. } => {
2532 f.debug_struct("Notice").field("key", key).finish_non_exhaustive()
2533 }
2534 Self::SetModeFailed { key, .. } => {
2535 f.debug_struct("SetModeFailed").field("key", key).finish_non_exhaustive()
2536 }
2537 Self::SetModelFailed { key, .. } => {
2538 f.debug_struct("SetModelFailed").field("key", key).finish_non_exhaustive()
2539 }
2540 Self::PermissionRequest { key, tool_id, .. } => f
2541 .debug_struct("PermissionRequest")
2542 .field("key", key)
2543 .field("tool_id", tool_id)
2544 .finish_non_exhaustive(),
2545 Self::QuestionRequest { key, tool_id, .. } => f
2546 .debug_struct("QuestionRequest")
2547 .field("key", key)
2548 .field("tool_id", tool_id)
2549 .finish_non_exhaustive(),
2550 Self::PendingInteractionResolved { key, tool_id, question_index, answered_with } => f
2551 .debug_struct("PendingInteractionResolved")
2552 .field("key", key)
2553 .field("tool_id", tool_id)
2554 .field("answered_with", answered_with)
2555 .field("question_index", question_index)
2556 .finish(),
2557 Self::McpOperationError { key, .. } => {
2558 f.debug_struct("McpOperationError").field("key", key).finish_non_exhaustive()
2559 }
2560 Self::TurnComplete { key, .. } => {
2561 f.debug_struct("TurnComplete").field("key", key).finish_non_exhaustive()
2562 }
2563 Self::TurnCancelled { key } => {
2564 f.debug_struct("TurnCancelled").field("key", key).finish()
2565 }
2566 Self::TurnError { key, .. } => {
2567 f.debug_struct("TurnError").field("key", key).finish_non_exhaustive()
2568 }
2569 Self::ChatAppended { key, .. } => {
2570 f.debug_struct("ChatAppended").field("key", key).finish_non_exhaustive()
2571 }
2572 Self::HookObservation { key, .. } => {
2573 f.debug_struct("HookObservation").field("key", key).finish_non_exhaustive()
2574 }
2575 Self::ForgeAccountIdentity { key, .. } => {
2576 f.debug_struct("ForgeAccountIdentity").field("key", key).finish_non_exhaustive()
2577 }
2578 Self::DictateOverrides { key, .. } => {
2579 f.debug_struct("DictateOverrides").field("key", key).finish_non_exhaustive()
2580 }
2581 Self::ContextUsageSnapshot { key, .. } => {
2582 f.debug_struct("ContextUsageSnapshot").field("key", key).finish_non_exhaustive()
2583 }
2584 Self::McpSnapshot { key, .. } => {
2585 f.debug_struct("McpSnapshot").field("key", key).finish_non_exhaustive()
2586 }
2587 Self::WorkChanged { key, .. } => {
2588 f.debug_struct("WorkChanged").field("key", key).finish_non_exhaustive()
2589 }
2590 Self::TasksChanged { key, tasks } => f
2591 .debug_struct("TasksChanged")
2592 .field("key", key)
2593 .field("count", &tasks.len())
2594 .finish(),
2595 Self::CronSchedulesChanged { key, crons } => f
2596 .debug_struct("CronSchedulesChanged")
2597 .field("key", key)
2598 .field("count", &crons.len())
2599 .finish(),
2600 Self::ConnectorSubscriptionsChanged { key, gotify, slack } => f
2601 .debug_struct("ConnectorSubscriptionsChanged")
2602 .field("key", key)
2603 .field("gotify", &gotify.len())
2604 .field("slack", &slack.len())
2605 .finish(),
2606 Self::SlashCommandsChanged { key, commands } => f
2607 .debug_struct("SlashCommandsChanged")
2608 .field("key", key)
2609 .field("count", &commands.len())
2610 .finish(),
2611 Self::SubagentsChanged { key, subagents } => f
2612 .debug_struct("SubagentsChanged")
2613 .field("key", key)
2614 .field("count", &subagents.len())
2615 .finish(),
2616 Self::SubagentCardsChanged { key, cards } => f
2617 .debug_struct("SubagentCardsChanged")
2618 .field("key", key)
2619 .field("count", &cards.len())
2620 .finish(),
2621 Self::FileIndexChanged { key, index } => f
2622 .debug_struct("FileIndexChanged")
2623 .field("key", key)
2624 .field("count", &index.entries.len())
2625 .finish(),
2626 Self::ProcessesChanged { key, snapshot } => f
2627 .debug_struct("ProcessesChanged")
2628 .field("key", key)
2629 .field("count", &snapshot.processes.len())
2630 .finish(),
2631 Self::MonitorsChanged { key, monitors } => f
2632 .debug_struct("MonitorsChanged")
2633 .field("key", key)
2634 .field("count", &monitors.len())
2635 .finish(),
2636 Self::BackgroundTasksChanged { key, tasks } => f
2637 .debug_struct("BackgroundTasksChanged")
2638 .field("key", key)
2639 .field("count", &tasks.len())
2640 .finish(),
2641 Self::ServiceStatus { .. } => f.debug_struct("ServiceStatus").finish_non_exhaustive(),
2642 Self::PluginsInventoryUpdated { cwd_raw, .. } => f
2643 .debug_struct("PluginsInventoryUpdated")
2644 .field("cwd_raw", cwd_raw)
2645 .finish_non_exhaustive(),
2646 Self::PluginsInventoryRefreshFailed { cwd_raw, .. } => f
2647 .debug_struct("PluginsInventoryRefreshFailed")
2648 .field("cwd_raw", cwd_raw)
2649 .finish_non_exhaustive(),
2650 Self::PluginsCliActionSucceeded { cwd_raw, .. } => f
2651 .debug_struct("PluginsCliActionSucceeded")
2652 .field("cwd_raw", cwd_raw)
2653 .finish_non_exhaustive(),
2654 Self::PluginsCliActionFailed { cwd_raw, .. } => f
2655 .debug_struct("PluginsCliActionFailed")
2656 .field("cwd_raw", cwd_raw)
2657 .finish_non_exhaustive(),
2658 Self::PluginsUpdateRunProgress { cwd_raw, run } => f
2659 .debug_struct("PluginsUpdateRunProgress")
2660 .field("cwd_raw", cwd_raw)
2661 .field("rows", &run.rows.len())
2662 .finish(),
2663 Self::PluginsUpdateRunFinished { cwd_raw, run, .. } => f
2664 .debug_struct("PluginsUpdateRunFinished")
2665 .field("cwd_raw", cwd_raw)
2666 .field("rows", &run.rows.len())
2667 .finish(),
2668 Self::PluginsRollbackSucceeded { cwd_raw, plugin_id, .. } => f
2669 .debug_struct("PluginsRollbackSucceeded")
2670 .field("cwd_raw", cwd_raw)
2671 .field("plugin_id", plugin_id)
2672 .finish_non_exhaustive(),
2673 Self::PluginsRollbackFailed { cwd_raw, plugin_id, .. } => f
2674 .debug_struct("PluginsRollbackFailed")
2675 .field("cwd_raw", cwd_raw)
2676 .field("plugin_id", plugin_id)
2677 .finish_non_exhaustive(),
2678 Self::WorkerStatusChanged { project_key, action, status, worktree } => f
2679 .debug_struct("WorkerStatusChanged")
2680 .field("project_key", project_key)
2681 .field("action", action)
2682 .field("label", &status.label)
2683 .field("worktree", worktree)
2684 .finish_non_exhaustive(),
2685 Self::PeerEnvelopeAppended { key, wrapped, .. } => f
2686 .debug_struct("PeerEnvelopeAppended")
2687 .field("key", key)
2688 .field("id", &wrapped.id)
2689 .field("kind", &wrapped.kind)
2690 .finish_non_exhaustive(),
2691 Self::GotifyNotificationAppended { key, notification, .. } => f
2692 .debug_struct("GotifyNotificationAppended")
2693 .field("key", key)
2694 .field("app", ¬ification.app)
2695 .field("priority", ¬ification.priority)
2696 .finish_non_exhaustive(),
2697 Self::CronPromptAppended { key, .. } => {
2698 f.debug_struct("CronPromptAppended").field("key", key).finish_non_exhaustive()
2699 }
2700 Self::SlackMessageAppended { key, .. } => {
2701 f.debug_struct("SlackMessageAppended").field("key", key).finish_non_exhaustive()
2702 }
2703 Self::SlackPostPending { key, draft } => f
2704 .debug_struct("SlackPostPending")
2705 .field("key", key)
2706 .field("workspace", &draft.workspace)
2707 .field("conversation", &draft.conversation)
2708 .finish_non_exhaustive(),
2709 Self::SlackDraftResolved { key, id, ending } => f
2710 .debug_struct("SlackDraftResolved")
2711 .field("key", key)
2712 .field("id", id)
2713 .field("ending", ending)
2714 .finish(),
2715 Self::BrowserHandOffPending { key, handoff } => f
2716 .debug_struct("BrowserHandOffPending")
2717 .field("key", key)
2718 .field("id", &handoff.id)
2719 .field("tab", &handoff.tab)
2720 .finish_non_exhaustive(),
2721 Self::BrowserHandOffResolved { key, id, answer } => f
2722 .debug_struct("BrowserHandOffResolved")
2723 .field("key", key)
2724 .field("id", id)
2725 .field("answer", answer)
2726 .finish(),
2727 Self::TerminalHandOffPending { key, handoff } => f
2728 .debug_struct("TerminalHandOffPending")
2729 .field("key", key)
2730 .field("id", &handoff.id)
2731 .field("terminal", &handoff.terminal)
2732 .finish_non_exhaustive(),
2733 Self::TerminalHandOffResolved { key, id, answer } => f
2734 .debug_struct("TerminalHandOffResolved")
2735 .field("key", key)
2736 .field("id", id)
2737 .field("answer", answer)
2738 .finish(),
2739 Self::TerminalChanged { key, terminal } => f
2740 .debug_struct("TerminalChanged")
2741 .field("key", key)
2742 .field("terminal", &terminal.as_ref().map(|summary| summary.id))
2743 .finish_non_exhaustive(),
2744 Self::PromptQueued { key, uuid, .. } => f
2745 .debug_struct("PromptQueued")
2746 .field("key", key)
2747 .field("uuid", uuid)
2748 .finish_non_exhaustive(),
2749 Self::PromptLifecycle { key, uuid, state } => f
2750 .debug_struct("PromptLifecycle")
2751 .field("key", key)
2752 .field("uuid", uuid)
2753 .field("state", state)
2754 .finish(),
2755 Self::PromptCancelResolved { key, uuid, cancelled } => f
2756 .debug_struct("PromptCancelResolved")
2757 .field("key", key)
2758 .field("uuid", uuid)
2759 .field("cancelled", cancelled)
2760 .finish(),
2761 Self::ReviewActivityNotice { key, branch, waiting, .. } => f
2762 .debug_struct("ReviewActivityNotice")
2763 .field("key", key)
2764 .field("branch", branch)
2765 .field("waiting", waiting)
2766 .finish_non_exhaustive(),
2767 Self::DictateAvailability => f.write_str("DictateAvailability"),
2768 Self::DictateStarted { key, .. } => {
2769 f.debug_struct("DictateStarted").field("key", key).finish_non_exhaustive()
2770 }
2771 Self::DictateLevel { key, peak_db, .. } => {
2772 f.debug_struct("DictateLevel").field("key", key).field("peak_db", peak_db).finish()
2773 }
2774 Self::DictateTranscribing { key, .. } => {
2775 f.debug_struct("DictateTranscribing").field("key", key).finish()
2776 }
2777 Self::DictateProgress { key, generation, done, total, .. } => f
2778 .debug_struct("DictateProgress")
2779 .field("key", key)
2780 .field("generation", generation)
2781 .field("done", done)
2782 .field("total", total)
2783 .finish(),
2784 Self::DictateEnded { key, outcome, .. } => f
2785 .debug_struct("DictateEnded")
2786 .field("key", key)
2787 .field("outcome", outcome)
2788 .finish_non_exhaustive(),
2789 Self::FatalError { error, .. } => {
2790 f.debug_struct("FatalError").field("error", error).finish()
2791 }
2792 Self::DictateModelsChanged { models } => f
2793 .debug_struct("DictateModelsChanged")
2794 .field("in_use", &models.in_use.len())
2795 .field("rows", &models.rows.len())
2796 .finish(),
2797 Self::DocumentHandOffPending { key, .. } => {
2798 f.debug_struct("DocumentHandOffPending").field("key", key).finish_non_exhaustive()
2799 }
2800 Self::DocumentHandOffResolved { key, .. } => {
2801 f.debug_struct("DocumentHandOffResolved").field("key", key).finish_non_exhaustive()
2802 }
2803 Self::SettingsWritePending { key, ask } => f
2804 .debug_struct("SettingsWritePending")
2805 .field("key", key)
2806 .field("id", &ask.id)
2807 .field("key_paths", &ask.edits.iter().map(|edit| &edit.key).collect::<Vec<_>>())
2808 .finish_non_exhaustive(),
2809 Self::SettingsWriteResolved { key, id, ending } => f
2810 .debug_struct("SettingsWriteResolved")
2811 .field("key", key)
2812 .field("id", id)
2813 .field("ending", ending)
2814 .finish_non_exhaustive(),
2815 Self::VaultAskPending { key, draft } => f
2816 .debug_struct("VaultAskPending")
2817 .field("key", key)
2818 .field("id", &draft.id)
2819 .field("tool", &draft.tool)
2820 .finish_non_exhaustive(),
2821 Self::VaultAskResolved { key, id, ending } => f
2822 .debug_struct("VaultAskResolved")
2823 .field("key", key)
2824 .field("id", id)
2825 .field("ending", ending)
2826 .finish_non_exhaustive(),
2827 Self::BiometricEnrolmentsChanged { enrolments } => f
2828 .debug_struct("BiometricEnrolmentsChanged")
2829 .field("enrolments", &enrolments.len())
2830 .finish(),
2831 // The token is redacted: a Debug line must never carry it.
2832 Self::VaultRotated { .. } => f.write_str("VaultRotated"),
2833 Self::VaultLockChanged { locked } => {
2834 f.debug_struct("VaultLockChanged").field("locked", locked).finish()
2835 }
2836 Self::CatalogLoaded => f.write_str("CatalogLoaded"),
2837 Self::CliVersionChanged => f.write_str("CliVersionChanged"),
2838 Self::AccountsChanged { accounts } => f
2839 .debug_struct("AccountsChanged")
2840 .field("loading", &accounts.loading.len())
2841 .finish_non_exhaustive(),
2842 Self::VoiceAudio { key, seq, sample_rate, pcm } => f
2843 .debug_struct("VoiceAudio")
2844 .field("key", key)
2845 .field("seq", seq)
2846 .field("sample_rate", sample_rate)
2847 .field("samples", &pcm.len())
2848 .finish(),
2849 Self::VoiceModeState { key, listeners } => f
2850 .debug_struct("VoiceModeState")
2851 .field("key", key)
2852 .field("listeners", listeners)
2853 .finish(),
2854 }
2855 }
2856}
2857
2858/// Errors from the dispatch boundary. `UnknownSession` and
2859/// `SessionClosed` are returned by `Workspace::dispatch`;
2860/// `NoActiveSession` is for a caller with no slot to name.
2861#[derive(Debug, thiserror::Error)]
2862pub enum DispatchError {
2863 #[error("no active session")]
2864 NoActiveSession,
2865 #[error("no session task registered for key {}", .0.display())]
2866 UnknownSession(SessionSlot),
2867 #[error("session task for key {} has closed its command channel", .0.display())]
2868 SessionClosed(SessionSlot),
2869 #[error(
2870 "no prompt of that kind is waiting on {tool_id} for key {}: it has been answered, or it asked something else",
2871 .key.display()
2872 )]
2873 NoPromptWaiting { key: SessionSlot, tool_id: String },
2874 /// A Slack answer named a draft the registry does not hold. Its own
2875 /// word rather than [`Self::NoPromptWaiting`]'s: a draft is answered by
2876 /// its own id and names no tool call, and this line is drawn where the
2877 /// dock stood. Answering and the asking session going away are the
2878 /// endings that leave nothing waiting, which is the condition this line
2879 /// names - a send-back is an answer too, and resolves the draft just the
2880 /// same.
2881 #[error(
2882 "that Slack draft is no longer waiting: it has been answered, or its asking session went away"
2883 )]
2884 NoDraftWaiting { key: SessionSlot, id: Uuid },
2885 /// A send-back carried no words. There is nothing to hand the agent,
2886 /// and resolving the draft would drop the reader's card for nothing -
2887 /// so the draft stays waiting and the field can be sent again.
2888 #[error("the feedback was empty, so the draft is still waiting")]
2889 EmptySlackFeedback { key: SessionSlot, id: Uuid },
2890 /// A browser hand-off answer named a hand-off the registry does not hold:
2891 /// answered in another view, or its asking session went away. Its own
2892 /// word for the same reason [`Self::NoDraftWaiting`] has one.
2893 #[error(
2894 "that browser hand-off is no longer waiting: it has been answered, or its asking session went away"
2895 )]
2896 NoBrowserHandOffWaiting { key: SessionSlot, id: Uuid },
2897 /// A terminal hand-off answer named a hand-off the registry does not
2898 /// hold: answered in another view, or its asking session went away.
2899 #[error(
2900 "that terminal hand-off is no longer waiting: it has been answered, or its asking session went away"
2901 )]
2902 NoTerminalHandOffWaiting { key: SessionSlot, id: Uuid },
2903 /// A document hand-off answer named a hand-off the registry does not
2904 /// hold: finished in another view, declined, or its asking session went
2905 /// away. Its own word for the same reason [`Self::NoDraftWaiting`] has
2906 /// one.
2907 #[error(
2908 "that document hand-off is no longer waiting: it has been answered, or its asking session went away"
2909 )]
2910 NoDocumentHandOffWaiting { key: SessionSlot, id: Uuid },
2911 /// A settings answer named a held write the registry does not hold:
2912 /// answered in another view, or its asking session went away. Its own
2913 /// word for the same reason [`Self::NoDraftWaiting`] has one.
2914 #[error(
2915 "that configuration write is no longer waiting: it has been answered, or its asking session went away"
2916 )]
2917 NoSettingsWriteWaiting { key: SessionSlot, id: Uuid },
2918 /// A vault answer named a held call the registry does not hold: answered
2919 /// in another view, or its asking session went away. Its own word for the
2920 /// same reason [`Self::NoDraftWaiting`] has one.
2921 #[error(
2922 "that vault call is no longer waiting: it has been answered, or its asking session went away"
2923 )]
2924 NoVaultAskWaiting { key: SessionSlot, id: Uuid },
2925 /// A biometric enrolment write was refused before anything was stored, in
2926 /// the core's own words: a blob that is not base64, an empty or oversized
2927 /// one, a nameless purpose, or a store that could not be written.
2928 #[error("the biometric enrolment was refused: {reason}")]
2929 BiometricRefused { reason: String },
2930 /// A catalogue check was asked for with `[dictate]` switched off:
2931 /// there are no pinned models for a check to be about.
2932 #[error("dictation is off, so there is no catalogue to check")]
2933 DictateOff,
2934 /// A speaking action was asked for with `[tts]` switched off or no
2935 /// model files resolving: there is nothing to speak with.
2936 #[error("speech is off: {reason}")]
2937 VoiceOff { reason: String },
2938 /// An install is already running: one at a time, like a check.
2939 #[error("a model install is already running")]
2940 Installing,
2941 /// An activation is already running; a second engine build would race
2942 /// the first one's swap.
2943 #[error("a model activation is already running")]
2944 Activating,
2945 /// A take is in flight on `holder` (a slot's display string), so the
2946 /// engine cannot be swapped under it.
2947 #[error("a dictation take is live on {holder}; wait for it to finish")]
2948 TakeLive { holder: String },
2949 /// `forge.toml` pins this role, so the runtime cannot move it; `section`
2950 /// and `key` name the table and key that do.
2951 #[error("[{section}] {key} pins this model in forge.toml; remove the key to change it here")]
2952 PinnedRole { section: &'static str, key: String },
2953 /// A bench holds the machine: it is measuring an engine, so a swap or
2954 /// a second run waits for it to finish or stops it.
2955 #[error("a bench is running; stop it before changing models")]
2956 BenchRunning,
2957 /// A stop arrived and nothing was running to stop.
2958 #[error("no bench is running")]
2959 BenchNotRunning,
2960 /// No read-aloud recording is running, or it is another connection's:
2961 /// only the connection that started one can stop it.
2962 #[error("no read-aloud recording is running here")]
2963 ReadAloudNotRecording,
2964 /// The read-aloud set would not take the change, in the core's own words.
2965 #[error("the read-aloud set could not be changed: {reason}")]
2966 ReadAloudUnavailable { reason: String },
2967 /// The model is what a role runs right now, and the engine holds it
2968 /// loaded: the role has to be moved off it first.
2969 #[error("the {role} runs this file; switch it first", role = role.label())]
2970 ModelInUse { role: crate::dictate::DictateRole },
2971 /// The model could not be removed, in the core's own words.
2972 #[error("the model could not be removed: {reason}")]
2973 UninstallRefused { reason: String },
2974 /// A check is already in flight; the one that lands is the answer,
2975 /// and a second fetch would answer the same thing twice.
2976 #[error("a catalogue check is already running")]
2977 CatalogueChecking,
2978 /// A plugin action is already running: the CLI serializes one at a
2979 /// time, so a second press waits rather than racing the first.
2980 #[error("a plugin action is already running; wait for it to finish")]
2981 PluginActionRunning,
2982 /// No home directory resolves, so a plugin call with no project
2983 /// context has nowhere to run. Refused rather than substituting a
2984 /// cwd-derived directory (hard rule 14).
2985 #[error("no home directory resolves; plugin actions with no project context cannot run")]
2986 PluginHomeUnavailable,
2987 /// A `project`/`local` install named no project; those scopes install
2988 /// into a project's own config, so the project is the caller's to say.
2989 #[error("a project-scoped plugin install needs a project")]
2990 PluginProjectRequired,
2991 /// A project-named install or update names a project `forge.toml`
2992 /// does not declare.
2993 #[error("no project named '{name}' is declared in forge.toml")]
2994 ProjectNotFound { name: String },
2995}
2996
2997#[cfg(test)]
2998mod session_update_variants {
2999 /// The client's reducer classifies a `SessionUpdate` by the name it crosses
3000 /// under, and a name it has no line for is a lookup that misses: the session
3001 /// page stops following its seat and keeps drawing the last record it read,
3002 /// with nothing to say it has fallen behind.
3003 ///
3004 /// **Both ends are parsed, and both ends are the tables themselves.** A
3005 /// hand-typed list is a copy no rename moves - the first version of this
3006 /// test read `turn_cancelled` from a literal while serde had already moved
3007 /// to `turn_cancelled_probe`, and it stayed green. The enum's names are
3008 /// derived from its own source by the rule it declares; the client's are
3009 /// read out of the three tables that classify, and not out of the file at
3010 /// large, where a payload key or a status word would answer for a variant
3011 /// nothing classifies.
3012 ///
3013 /// The client's census of variants is read back as well, rather than
3014 /// trusted because its length is asserted: a count binds it to nothing.
3015 /// **`IGNORED` has no runtime reader at all** - nothing but these tests
3016 /// consults it - so what it holds is only as right as this control.
3017 ///
3018 /// What it does not reach: `client/src/wire/fleet.ts` keeps a table of its
3019 /// own for the fleet's redraws. That is a different question and is
3020 /// deliberately partial, so this binding stops at `apply.ts`.
3021 #[test]
3022 fn every_session_update_variant_is_classified_for_the_client() {
3023 let source = include_str!("protocol.rs");
3024 let client = include_str!("../../../client/src/session/apply.ts");
3025 let declared = wire_names(source);
3026 let classified = classified_names(client);
3027
3028 // The derivation implements ONE `rename_all` rule, so it is only as good
3029 // as the enum declaring that rule: changing the attribute moves every
3030 // name at once, and nothing else here would notice.
3031 assert_eq!(
3032 rename_all(source).as_deref(),
3033 Some("snake_case"),
3034 "this control derives wire names with serde's snake_case rule and the enum no longer \
3035 declares it: extend the derivation before trusting anything below",
3036 );
3037
3038 // The denominator, and it is the enum's own: a parse that stops matching
3039 // never reaches its closing brace, and finding nothing is what a clean
3040 // run looks like - so this is what tells the two apart.
3041 assert!(
3042 declared.closed,
3043 "the parse never reached the end of `SessionUpdate`, so it is the parse that moved \
3044 and not the client",
3045 );
3046
3047 let missing: Vec<&String> =
3048 declared.names.iter().filter(|name| !classified.contains(name)).collect();
3049 assert!(
3050 missing.is_empty(),
3051 "client/src/session/apply.ts has no line for {missing:?}, and a variant the client \
3052 does not classify stops the session page following its seat: add each to HANDLERS \
3053 when the record has a field for it, and to REPLACES or IGNORED when it does not",
3054 );
3055
3056 let stale: Vec<&String> =
3057 classified.iter().filter(|name| !declared.names.contains(name)).collect();
3058 assert!(
3059 stale.is_empty(),
3060 "client/src/session/apply.ts classifies {stale:?}, which the enum does not declare: \
3061 a line for a name the wire never sends is one nobody can tell from a live one",
3062 );
3063
3064 // **And the census the client's own assertions filter over is read
3065 // too**, rather than trusted because its count is asserted. That count
3066 // binds it to nothing; this is what binds it to the enum. It lives in
3067 // the client's TEST file, beside the assertions that filter over it.
3068 let census_source = include_str!("../../../client/src/session/apply.test.ts");
3069 let mut census = array_entries(census_source, "EVERY_VARIANT");
3070 let mut declared_names = declared.names.clone();
3071 census.sort();
3072 declared_names.sort();
3073 assert_eq!(
3074 census, declared_names,
3075 "the client's `EVERY_VARIANT` and the enum's variants are not the same set: the \
3076 census is what the client's own assertions filter over, so a variant missing from \
3077 it is a variant nothing checks",
3078 );
3079 }
3080
3081 /// The draft ending crosses as the core's own externally tagged enum, and
3082 /// `draftEndingLine` narrows exactly that shape - a unit variant as its
3083 /// name, the answered one as a name around its field. Neither side
3084 /// compiles the link, so this is what holds serde's output and the
3085 /// client's narrowing together.
3086 #[test]
3087 fn a_draft_ending_serialises_as_the_client_narrows_it() {
3088 use crate::SessionSlot;
3089 use forge_primitives::slack::SlackDraftEnding;
3090 use uuid::Uuid;
3091
3092 let ending = |ending: SlackDraftEnding| {
3093 let update = super::SessionUpdate::SlackDraftResolved {
3094 key: SessionSlot::from_str_for_test("k"),
3095 id: Uuid::nil(),
3096 ending,
3097 };
3098 serde_json::to_value(update).expect("an update serialises")["slack_draft_resolved"]
3099 ["ending"]
3100 .clone()
3101 };
3102
3103 assert_eq!(ending(SlackDraftEnding::Abandoned), serde_json::json!("abandoned"));
3104 assert_eq!(ending(SlackDraftEnding::SentBack), serde_json::json!("sent_back"));
3105 assert_eq!(
3106 ending(SlackDraftEnding::Answered { approved: true }),
3107 serde_json::json!({ "answered": { "approved": true } }),
3108 );
3109 }
3110
3111 /// The voice frames are additive and rename no field, so a round trip is
3112 /// the whole contract. `sample_rate` is a plain number rather than a
3113 /// closed set, so a model rendering at a rate this build has never seen
3114 /// still reaches a client.
3115 #[test]
3116 fn the_voice_frames_round_trip() {
3117 use crate::SessionSlot;
3118
3119 let key = SessionSlot::from_str_for_test("voice");
3120 let mode = super::Command::VoiceMode {
3121 key: key.clone(),
3122 on: true,
3123 explicit: false,
3124 initiator: None,
3125 };
3126 let json = serde_json::to_string(&mode).expect("a command serialises");
3127 let super::Command::VoiceMode { key: decoded, on, .. } =
3128 serde_json::from_str(&json).expect("a command decodes")
3129 else {
3130 panic!("a voice mode came back as another command");
3131 };
3132 assert_eq!(decoded, key);
3133 assert!(on);
3134
3135 let audio = super::SessionUpdate::VoiceAudio {
3136 key: key.clone(),
3137 seq: 7,
3138 sample_rate: 24_000,
3139 pcm: vec![5, 6],
3140 };
3141 let json = serde_json::to_string(&audio).expect("an update serialises");
3142 let super::SessionUpdate::VoiceAudio { key: decoded, seq, sample_rate, pcm } =
3143 serde_json::from_str(&json).expect("an update decodes")
3144 else {
3145 panic!("a voice audio frame came back as another update");
3146 };
3147 assert_eq!(decoded, key);
3148 assert_eq!((seq, sample_rate, pcm), (7, 24_000, vec![5, 6]));
3149
3150 let state = super::SessionUpdate::VoiceModeState { key: key.clone(), listeners: 2 };
3151 let json = serde_json::to_string(&state).expect("an update serialises");
3152 let super::SessionUpdate::VoiceModeState { key: decoded, listeners } =
3153 serde_json::from_str(&json).expect("an update decodes")
3154 else {
3155 panic!("a voice mode frame came back as another update");
3156 };
3157 assert_eq!((decoded, listeners), (key.clone(), 2));
3158
3159 let unknown = serde_json::json!({
3160 "voice_audio": {
3161 "key": serde_json::to_value(&key).expect("a slot renders"),
3162 "seq": 0,
3163 "sample_rate": 12_345,
3164 "pcm": [],
3165 }
3166 });
3167 let super::SessionUpdate::VoiceAudio { sample_rate, .. } =
3168 serde_json::from_value(unknown).expect("an unknown rate decodes")
3169 else {
3170 panic!("a voice audio frame came back as another update");
3171 };
3172 assert_eq!(sample_rate, 12_345);
3173 }
3174
3175 /// What one parse of the enum found.
3176 struct Census {
3177 names: Vec<String>,
3178 /// Whether the parse reached the enum's closing brace.
3179 closed: bool,
3180 }
3181
3182 /// The names the client's classifying tables hold.
3183 ///
3184 /// The handler table's keys, and the quoted entries of the two lists. An
3185 /// export of anything else is skipped rather than read: a table of the
3186 /// record's FIELD names would answer a variant check with names the enum
3187 /// never declares.
3188 fn classified_names(client: &str) -> Vec<String> {
3189 let mut names = Vec::new();
3190 let mut table = "";
3191 // Whether a list's `[` has been seen and its `]` has not.
3192 let mut open = false;
3193 for line in client.lines() {
3194 let trimmed = line.trim();
3195 if trimmed.starts_with("export const HANDLERS") {
3196 table = "handlers";
3197 open = false;
3198 continue;
3199 }
3200 if trimmed.starts_with("export const REPLACES")
3201 || trimmed.starts_with("export const IGNORED")
3202 {
3203 table = "list";
3204 open = false;
3205 } else if trimmed.starts_with("export const") {
3206 table = "";
3207 open = false;
3208 continue;
3209 }
3210 match table {
3211 // A key of the handler table. The INDENT is the anchor: entries
3212 // sit at exactly two spaces, and an object literal inside a
3213 // handler's body is deeper. What follows the colon is not read,
3214 // so a key written against a bare function name - `turn_error:
3215 // settled,` - counts the same as an inline arrow.
3216 "handlers" => {
3217 if let Some(rest) =
3218 line.strip_prefix(" ").filter(|rest| !rest.starts_with(' '))
3219 && let Some(key) = rest.split_once(':').map(|(key, _)| key)
3220 && is_wire_name(key)
3221 {
3222 names.push(key.to_owned());
3223 }
3224 }
3225 // `['spawning', 'connected']`, one line or many. **Only an
3226 // array is read, and only its entries**: the span between two
3227 // exports also carries prose, and a comment quoting a name must
3228 // not answer for a line that classifies it.
3229 "list" => {
3230 if !open {
3231 // The LAST bracket on the line: the declaration's own
3232 // type carries a `[]` before the array opens.
3233 let Some((_, after)) = trimmed.rsplit_once('[') else {
3234 continue;
3235 };
3236 if let Some((entries, _)) = after.split_once(']') {
3237 names.extend(quoted_names(entries).map(str::to_owned));
3238 // Opened and closed on one line: the export carried
3239 // its whole list, and what follows is not it.
3240 table = "";
3241 } else {
3242 open = true;
3243 names.extend(quoted_names(after).map(str::to_owned));
3244 }
3245 } else if trimmed.starts_with(']') {
3246 open = false;
3247 // **The array is the whole of what its export
3248 // classifies.** The mode used to end at the next
3249 // `export const`, which made the LAST list in the file
3250 // read every `[` below it - a client helper's own
3251 // literals answering as variants.
3252 table = "";
3253 } else if trimmed.starts_with('\'') {
3254 names.extend(quoted_names(trimmed).map(str::to_owned));
3255 }
3256 }
3257 _ => {}
3258 }
3259 }
3260 names
3261 }
3262
3263 /// The quoted entries of one `const NAME = [ ... ]` block, exported or not,
3264 /// which is how the client's census of variants is written.
3265 fn array_entries(client: &str, name: &str) -> Vec<String> {
3266 let heading = format!("const {name}");
3267 let mut entries = Vec::new();
3268 let mut open = false;
3269 for line in client.lines() {
3270 let trimmed = line.trim();
3271 if !open {
3272 let declared = trimmed.strip_prefix("export ").unwrap_or(trimmed);
3273 if !declared.starts_with(&heading) {
3274 continue;
3275 }
3276 let Some((_, after)) = trimmed.rsplit_once('[') else {
3277 continue;
3278 };
3279 if let Some((inside, _)) = after.split_once(']') {
3280 entries.extend(quoted_names(inside).map(str::to_owned));
3281 } else {
3282 open = true;
3283 entries.extend(quoted_names(after).map(str::to_owned));
3284 }
3285 } else if trimmed.starts_with(']') {
3286 break;
3287 } else if trimmed.starts_with('\'') {
3288 entries.extend(quoted_names(trimmed).map(str::to_owned));
3289 }
3290 }
3291 entries
3292 }
3293
3294 /// The names quoted in a run of text: the odd segments of a split on `'`.
3295 fn quoted_names(text: &str) -> impl Iterator<Item = &str> {
3296 text.split('\'').skip(1).step_by(2).filter(|entry| is_wire_name(entry))
3297 }
3298
3299 /// Whether a token reads as a wire name, which is what keeps a bare word in
3300 /// a declaration from being taken for an entry.
3301 fn is_wire_name(token: &str) -> bool {
3302 !token.is_empty()
3303 && token.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
3304 }
3305
3306 /// The `rename_all` the enum itself declares, which is what turns a variant
3307 /// name into the name it crosses under.
3308 fn rename_all(source: &str) -> Option<String> {
3309 let mut nearest = None;
3310 for line in source.lines() {
3311 let trimmed = line.trim();
3312 if trimmed.starts_with("pub enum SessionUpdate {") {
3313 return nearest;
3314 }
3315 // Comments and blank lines do not break the run above the enum: a
3316 // doc comment between the attribute and the enum says the same
3317 // thing, and reading it as "no rename_all" would red a control on a
3318 // reformatting.
3319 if trimmed.is_empty() || trimmed.starts_with("//") {
3320 continue;
3321 }
3322 nearest = trimmed
3323 .strip_prefix("#[serde(rename_all = \"")
3324 .and_then(|rest| rest.split('"').next())
3325 .map(str::to_owned);
3326 }
3327 None
3328 }
3329
3330 /// Every wire name the enum declares, in the order it declares them.
3331 ///
3332 /// The derivation serde applies to it: `rename_all` on the enum, and an
3333 /// explicit `#[serde(rename = "...")]` on the variant above it.
3334 fn wire_names(source: &str) -> Census {
3335 let mut names = Vec::new();
3336 let mut closed = false;
3337 let mut inside = false;
3338 let mut renamed: Option<String> = None;
3339 for line in source.lines() {
3340 if !inside {
3341 inside = line.starts_with("pub enum SessionUpdate {");
3342 continue;
3343 }
3344 if line == "}" {
3345 closed = true;
3346 break;
3347 }
3348 let trimmed = line.trim();
3349 if let Some(rest) = trimmed.strip_prefix("#[serde(rename = \"") {
3350 renamed = rest.split('"').next().map(str::to_owned);
3351 continue;
3352 }
3353 if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with("#[") {
3354 continue;
3355 }
3356 let variant: String =
3357 trimmed.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
3358 if !variant.starts_with(|c: char| c.is_ascii_uppercase()) {
3359 // A field inside a variant's body, or the brace closing one. A
3360 // rename attribute above a field belongs to the FIELD, so it is
3361 // dropped here rather than carried onto the next variant.
3362 renamed = None;
3363 continue;
3364 }
3365 names.push(renamed.take().unwrap_or_else(|| snake_case(&variant)));
3366 }
3367 Census { names, closed }
3368 }
3369
3370 /// The book's variant counts are read from the enums, not trusted: a
3371 /// variant added without the page moving leaves a published number wrong.
3372 #[test]
3373 fn the_book_counts_match_the_enums() {
3374 let source = include_str!("protocol.rs");
3375 let book = include_str!("../../../docs/book/src/socket.md");
3376 let flat = book.split_whitespace().collect::<Vec<_>>().join(" ");
3377
3378 let (command_total, command_struct, command_unit) =
3379 variant_counts(source, "pub enum Command {");
3380 assert!(
3381 flat.contains(&format!(
3382 "`Command` has {command_total} variants, {command_struct} of them struct variants; the {} unit variants",
3383 spell(command_unit),
3384 )),
3385 "the book's Command count is not the enum's: {command_total} total, {command_struct} struct, {command_unit} unit",
3386 );
3387
3388 let (update_total, update_struct, update_unit) =
3389 variant_counts(source, "pub enum SessionUpdate {");
3390 assert!(
3391 flat.contains(&format!(
3392 "{update_struct} of `SessionUpdate`'s {update_total} variants are struct variants"
3393 )),
3394 "the book's SessionUpdate count is not the enum's: {update_total} total, {update_struct} struct, {update_unit} unit",
3395 );
3396 }
3397
3398 /// A small count as the book spells it.
3399 fn spell(n: usize) -> String {
3400 match n {
3401 0 => "zero",
3402 1 => "one",
3403 2 => "two",
3404 3 => "three",
3405 4 => "four",
3406 5 => "five",
3407 6 => "six",
3408 7 => "seven",
3409 8 => "eight",
3410 9 => "nine",
3411 10 => "ten",
3412 _ => return n.to_string(),
3413 }
3414 .to_owned()
3415 }
3416
3417 /// Count an enum's variants, and how many carry a struct body.
3418 fn variant_counts(source: &str, header: &str) -> (usize, usize, usize) {
3419 let mut total = 0;
3420 let mut structs = 0;
3421 let mut units = 0;
3422 let mut inside = false;
3423 for line in source.lines() {
3424 if !inside {
3425 inside = line.starts_with(header);
3426 continue;
3427 }
3428 if line == "}" {
3429 break;
3430 }
3431 let trimmed = line.trim();
3432 if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with("#[") {
3433 continue;
3434 }
3435 let variant: String =
3436 trimmed.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
3437 if !variant.starts_with(|c: char| c.is_ascii_uppercase()) {
3438 continue;
3439 }
3440 total += 1;
3441 if trimmed.contains('{') {
3442 structs += 1;
3443 } else {
3444 units += 1;
3445 }
3446 }
3447 (total, structs, units)
3448 }
3449
3450 /// serde's own rule for this enum's `rename_all`, for a variant with no
3451 /// explicit rename over it.
3452 fn snake_case(name: &str) -> String {
3453 let mut out = String::new();
3454 for (at, c) in name.chars().enumerate() {
3455 if c.is_ascii_uppercase() {
3456 if at != 0 {
3457 out.push('_');
3458 }
3459 out.push(c.to_ascii_lowercase());
3460 } else {
3461 out.push(c);
3462 }
3463 }
3464 out
3465 }
3466}
3467
3468#[cfg(test)]
3469mod dictate_level_wire_tests {
3470 use super::*;
3471
3472 /// A level whose window heard nothing carries a non-finite dB, which
3473 /// serde writes as `null` - and must read back, or a Rust reader
3474 /// cannot decode the message at all. The wire value stays `null`: the
3475 /// tolerance is on the read side, so nothing a client narrows moves.
3476 #[test]
3477 fn a_level_with_no_signal_round_trips_as_null() {
3478 let key = SessionSlot::from_str_for_test("dictate-level".to_owned());
3479 let silent = SessionUpdate::DictateLevel {
3480 key: key.clone(),
3481 peak_db: f32::NEG_INFINITY,
3482 generation: 1,
3483 initiator: None,
3484 };
3485 let encoded = serde_json::to_value(&silent).expect("a level encodes");
3486 assert!(
3487 encoded["dictate_level"]["peak_db"].is_null(),
3488 "a no-signal reading crosses as null, got {encoded}"
3489 );
3490
3491 let back: SessionUpdate = serde_json::from_value(encoded).expect("a level decodes");
3492 let SessionUpdate::DictateLevel { peak_db, .. } = back else {
3493 panic!("the level decoded into another update")
3494 };
3495 assert_eq!(peak_db, f32::NEG_INFINITY, "null must read back as no signal");
3496
3497 // And a reading that IS a signal is untouched by the tolerance.
3498 let heard =
3499 SessionUpdate::DictateLevel { key, peak_db: -22.5, generation: 1, initiator: None };
3500 let encoded = serde_json::to_value(&heard).expect("a level encodes");
3501 let back: SessionUpdate = serde_json::from_value(encoded).expect("a level decodes");
3502 let SessionUpdate::DictateLevel { peak_db, .. } = back else {
3503 panic!("the level decoded into another update")
3504 };
3505 assert_eq!(peak_db, -22.5);
3506 }
3507
3508 /// **A reading and a transcribing name the take they belong to.** They
3509 /// are the two frames a client uses to draw a live take, and a frame
3510 /// that arrived after a newer start is the older take's: without the
3511 /// number the client cannot tell, and writes it into the live meter
3512 /// (#839).
3513 #[test]
3514 fn a_reading_and_a_transcribing_cross_with_their_generation() {
3515 let key = SessionSlot::from_str_for_test("take-generation".to_owned());
3516 let level = SessionUpdate::DictateLevel {
3517 key: key.clone(),
3518 peak_db: -20.0,
3519 generation: 7,
3520 initiator: None,
3521 };
3522 let encoded = serde_json::to_value(&level).expect("a level encodes");
3523 assert_eq!(
3524 encoded["dictate_level"]["generation"],
3525 serde_json::json!(7),
3526 "a reading carries its take's own number: {encoded}"
3527 );
3528
3529 let transcribing =
3530 SessionUpdate::DictateTranscribing { key, generation: 7, initiator: None };
3531 let encoded = serde_json::to_value(&transcribing).expect("a transcribing encodes");
3532 assert_eq!(
3533 encoded["dictate_transcribing"]["generation"],
3534 serde_json::json!(7),
3535 "and so does the frame that says it is transcribing: {encoded}"
3536 );
3537 }
3538
3539 /// A take that heard nothing crosses the peak in its END the same way a
3540 /// level's crosses, so the same tolerance has to read it: a reader that
3541 /// decodes the level and then fails on the end of the same take is just
3542 /// as broken, and the failure names the type rather than the case.
3543 #[test]
3544 fn a_no_audio_end_with_no_signal_round_trips() {
3545 let key = SessionSlot::from_str_for_test("no-audio-end".to_owned());
3546 let silent = SessionUpdate::DictateEnded {
3547 key: key.clone(),
3548 outcome: DictateOutcome::NoAudio { peak_db: f32::NEG_INFINITY, seconds: 3 },
3549 generation: 1,
3550 surface: None,
3551 initiator: None,
3552 };
3553 let encoded = serde_json::to_value(&silent).expect("an end encodes");
3554 assert!(
3555 encoded["dictate_ended"]["outcome"]["no_audio"]["peak_db"].is_null(),
3556 "a no-signal end crosses as null, got {encoded}"
3557 );
3558
3559 let back: SessionUpdate = serde_json::from_value(encoded).expect("an end decodes");
3560 let SessionUpdate::DictateEnded { outcome, .. } = back else {
3561 panic!("the end decoded into another update")
3562 };
3563 assert_eq!(
3564 outcome,
3565 DictateOutcome::NoAudio { peak_db: f32::NEG_INFINITY, seconds: 3 },
3566 "null must read back as no signal"
3567 );
3568
3569 // And a reading that IS a signal is untouched by the tolerance.
3570 let quiet = SessionUpdate::DictateEnded {
3571 key,
3572 outcome: DictateOutcome::NoAudio { peak_db: -41.5, seconds: 3 },
3573 generation: 1,
3574 surface: None,
3575 initiator: None,
3576 };
3577 let encoded = serde_json::to_value(&quiet).expect("an end encodes");
3578 let back: SessionUpdate = serde_json::from_value(encoded).expect("an end decodes");
3579 let SessionUpdate::DictateEnded { outcome, .. } = back else {
3580 panic!("the end decoded into another update")
3581 };
3582 assert_eq!(outcome, DictateOutcome::NoAudio { peak_db: -41.5, seconds: 3 });
3583 }
3584}
3585
3586#[cfg(test)]
3587mod dictate_wire_ownership_tests {
3588 use super::*;
3589
3590 /// The initiator is transport-side state and never crosses: a take's
3591 /// updates serialize exactly as v4's frames did. Catches a `serde(skip)`
3592 /// dropped from any of the five - a token on the wire is a contract
3593 /// change a client would read as an unknown field.
3594 #[test]
3595 fn no_dictate_update_carries_its_initiator_across() {
3596 let key = SessionSlot::from_str_for_test("initiator-wire".to_owned());
3597 let updates = [
3598 SessionUpdate::DictateStarted {
3599 key: key.clone(),
3600 floor_db: -50.0,
3601 generation: 1,
3602 initiator: Some(7),
3603 },
3604 SessionUpdate::DictateLevel {
3605 key: key.clone(),
3606 peak_db: -20.0,
3607 generation: 1,
3608 initiator: Some(7),
3609 },
3610 SessionUpdate::DictateTranscribing {
3611 key: key.clone(),
3612 generation: 1,
3613 initiator: Some(7),
3614 },
3615 SessionUpdate::DictateProgress {
3616 key: key.clone(),
3617 generation: 1,
3618 done: 1,
3619 total: Some(2),
3620 initiator: Some(7),
3621 },
3622 SessionUpdate::DictateEnded {
3623 key,
3624 outcome: DictateOutcome::Cancelled,
3625 generation: 1,
3626 surface: None,
3627 initiator: Some(7),
3628 },
3629 ];
3630 for update in &updates {
3631 let encoded = serde_json::to_value(update).expect("an update encodes");
3632 let body = encoded
3633 .as_object()
3634 .and_then(|wrapper| wrapper.values().next())
3635 .expect("an update encodes as its own name around its body");
3636 assert!(
3637 body.get("initiator").is_none(),
3638 "{update:?} carried its initiator across the wire: {encoded}"
3639 );
3640 }
3641 }
3642}
3643
3644#[cfg(test)]
3645mod workers_command_tests {
3646 use super::*;
3647
3648 #[test]
3649 fn worker_spawn_reply_constructs() {
3650 let r = WorkerSpawnReply {
3651 session_id: "abc".into(),
3652 tag: "forge:worker:reviewer".into(),
3653 mcp_families: None,
3654 rate_limited_account: None,
3655 durability_warning: None,
3656 worktree: None,
3657 session_choice: SessionChoice::Fresh,
3658 };
3659 assert_eq!(r.tag, "forge:worker:reviewer");
3660 }
3661
3662 /// The board's edits are the user's own: no session routes them, so a
3663 /// command with no key must reach the app-level path rather than be
3664 /// dropped as unroutable.
3665 #[test]
3666 fn the_board_commands_are_app_level() {
3667 for command in [
3668 Command::TaskVerdict {
3669 project: "p".to_owned(),
3670 id: "t".to_owned(),
3671 approve: true,
3672 words: None,
3673 },
3674 Command::TaskAnswer {
3675 project: "p".to_owned(),
3676 id: "t".to_owned(),
3677 words: "w".to_owned(),
3678 },
3679 Command::TaskRank { project: "p".to_owned(), id: "t".to_owned(), to: RankMove::Top },
3680 Command::TaskAssign { project: "p".to_owned(), id: "t".to_owned(), owner: None },
3681 Command::TaskCreate { project: "p".to_owned(), subject: "s".to_owned(), parent: None },
3682 Command::TaskMove {
3683 project: "p".to_owned(),
3684 id: "t".to_owned(),
3685 to: forge_primitives::tasks::TaskStatus::InProgress,
3686 place: None,
3687 },
3688 ] {
3689 assert!(command.key().is_none(), "app-level: {command:?}");
3690 }
3691 }
3692
3693 /// The add's dispatch id crosses, and a press that named none still
3694 /// parses: the accessor is what a refusal echoes and the outcome frames
3695 /// carry, which is the identity a page keys its own answer on.
3696 #[test]
3697 fn a_marketplace_add_names_the_dispatch_its_press_made() {
3698 let named: Command =
3699 serde_json::from_str(r#"{"marketplace_add":{"source":"owner/repo","dispatch_id":41}}"#)
3700 .expect("a press that names its dispatch");
3701 assert_eq!(named.dispatch_id(), Some(41), "the accessor reads the press's own id");
3702
3703 let bare: Command = serde_json::from_str(r#"{"marketplace_add":{"source":"owner/repo"}}"#)
3704 .expect("a press that named none still parses");
3705 assert_eq!(bare.dispatch_id(), None, "and answers none");
3706
3707 assert_eq!(
3708 Command::PluginCheckUpdates.dispatch_id(),
3709 None,
3710 "a command no press names a dispatch for carries none",
3711 );
3712 }
3713
3714 /// **A prompt's press names itself too** (#1583). The refusal of a
3715 /// prompt echoes the dispatch id it carried, so a composer with sends
3716 /// out on two seats marks the one the core refused rather than both -
3717 /// and a view that minted none, which is every view before this field,
3718 /// still parses.
3719 #[test]
3720 fn a_prompt_under_names_the_dispatch_its_press_made() {
3721 let named: Command = serde_json::from_str(
3722 r#"{"prompt_under":{"key":{"org":"Org","project":"p","label":"lead"},"text":"hi","attachments":[],"uuid":"u1","source":"you","dispatch_id":9}}"#,
3723 )
3724 .expect("a press that names its dispatch");
3725 assert_eq!(named.dispatch_id(), Some(9), "the accessor reads the press's own id");
3726
3727 let bare: Command = serde_json::from_str(
3728 r#"{"prompt_under":{"key":{"org":"Org","project":"p","label":"lead"},"text":"hi","attachments":[],"uuid":"u1","source":"you"}}"#,
3729 )
3730 .expect("a press that named none still parses");
3731 assert_eq!(bare.dispatch_id(), None, "and answers none");
3732 }
3733
3734 /// A move WITHOUT a place parses: the keyboard's lane step sends the
3735 /// state alone, and the default on the field is what keeps that frame
3736 /// from coming back as an unknown message. A place that IS there comes
3737 /// through rather than being dropped.
3738 #[test]
3739 fn a_task_move_carries_an_optional_place() {
3740 let bare: Command =
3741 serde_json::from_str(r#"{"task_move":{"project":"p","id":"t","to":"in_progress"}}"#)
3742 .expect("a place-less move");
3743 match bare {
3744 Command::TaskMove { place, to, .. } => {
3745 assert_eq!(place, None, "an absent place is None");
3746 assert_eq!(to, forge_primitives::tasks::TaskStatus::InProgress);
3747 }
3748 other => panic!("parsed as {other:?}"),
3749 }
3750 let placed: Command = serde_json::from_str(
3751 r#"{"task_move":{"project":"p","id":"t","to":"waiting","place":{"before":"a"}}}"#,
3752 )
3753 .expect("a move with a place");
3754 match placed {
3755 Command::TaskMove { place, .. } => {
3756 assert_eq!(place, Some(RankMove::Before(Some("a".to_owned()))));
3757 }
3758 other => panic!("parsed as {other:?}"),
3759 }
3760 }
3761
3762 /// A spawn frame written before `cwd` existed parses: the recorded
3763 /// frames and the in-repo client's own payloads predate the key, and
3764 /// the default on the field is what keeps one from coming back as an
3765 /// unknown message. A key that IS there comes through rather than
3766 /// being dropped.
3767 #[test]
3768 fn a_spawn_worker_carries_an_optional_cwd() {
3769 let frame = |cwd: &str| {
3770 format!(
3771 r#"{{"spawn_worker":{{"project_key":"proj","label":"w","charter":"c","spawned_by":{{"org":"o","project":"p","label":"lead"}},"resume_existing":null,"kick":null,"resume_kick":null,"interactive":false,"mcp_families":null{cwd},"from_boot_respawn":false}}}}"#
3772 )
3773 };
3774 let bare: Command =
3775 serde_json::from_str(&frame("")).expect("a frame written before the key");
3776 match bare {
3777 Command::SpawnWorker { cwd, .. } => {
3778 assert_eq!(cwd, None, "an absent tree is None, which is the project's own path");
3779 }
3780 other => panic!("parsed as {other:?}"),
3781 }
3782
3783 let stated: Command =
3784 serde_json::from_str(&frame(r#","cwd":"/tmp/nazar""#)).expect("a frame with a tree");
3785 match stated {
3786 Command::SpawnWorker { cwd, .. } => {
3787 assert_eq!(cwd.as_deref(), Some("/tmp/nazar"), "a stated tree crosses whole");
3788 }
3789 other => panic!("parsed as {other:?}"),
3790 }
3791 }
3792}